Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,15 @@ Each entry in **Settings → AI → Providers** stores one credential. The provi
|---|---|---|---|---|---|
| `anthropic` | Anthropic (Claude) | `apiKey` | API key (`sk-ant-…`) | — | Static `claude-*` catalogue enriched with OpenRouter prices + context windows |
| `openai` | OpenAI | `apiKey` | API key (`sk-…`) | — | Static `gpt-*` / `o*` catalogue enriched with OpenRouter prices + context windows |
| `minimax` | MiniMax | `baseUrl` | Regional OpenAI- or Anthropic-compatible endpoint + API key | — | Live regional `/models` endpoint filtered to MiniMax-M3 and MiniMax-M2.7 with provider metadata |
| `openrouter` | OpenRouter | `apiKey` | API key (`sk-or-…`) | — | Live `GET /api/v1/models` (cross-provider; native cost reporting) |
| `ollama` | Ollama (local) | `baseUrl` | Base URL (e.g. `http://localhost:11434`) | API key (bearer, for proxied deployments) | Live `GET {baseUrl}/api/tags`, with `POST {baseUrl}/api/show` capability lookup per model; static fallback list when unreachable |
| `openai-compatible` | Custom Provider | `baseUrl` | Base URL — any host serving the OpenAI `/v1/chat/completions` wire protocol | API key (bearer; cloud services need one, local servers often don't) | Live `GET {baseUrl}/v1/models` (standard OpenAI list shape); model `id` used as label |

**Custom Provider** (id `openai-compatible`) is the generic adapter for any endpoint that speaks the OpenAI chat/completions wire protocol — Groq (`https://api.groq.com/openai`), Together, DeepSeek, Mistral, Fireworks, self-hosted vLLM, LM Studio, and others. Capabilities default to `{ toolCalling: true, visionInput: false, toolResultImages: false, promptCache: false, streaming: true }`; the operator is responsible for selecting a model that actually supports tool calling. Because arbitrary endpoints are not in the OpenRouter catalogue, no context-window enrichment is available and the context meter stays hidden for these models.

**MiniMax** provides presets for the global and China APIs over both supported wire protocols. The OpenAI-compatible endpoints end in `/v1`; the Anthropic-compatible endpoints end in `/anthropic`. The selected model catalogue retains MiniMax-specific context, input capability, and list-price metadata.

**The server is the model-capability authority.** The composer catalogue flags are early UX gates, but they are not trusted for persistence, provider calls, or tool screenshots. `chat.ts` resolves the selected model on every turn through `resolveModelCapabilities`. Providers with stable capabilities (Anthropic, OpenAI, Custom Provider) use their driver default. Model-specific providers own a selected-model lookup: OpenRouter resolves the exact entry's `architecture.input_modalities`, while Ollama sends an authenticated `POST /api/show` for only the selected model. The shared resolver de-duplicates concurrent lookups, applies a ten-second provider timeout, includes credential/backend revisions in its cache key, and caches successful results for five minutes. Missing or unavailable model-specific metadata fails closed for vision input; Custom Provider also remains fail-closed in v1. An image targeting a non-vision model, or an editor-agent turn targeting a model known not to support tools, receives 422 before the user message is stored.

---
Expand Down
91 changes: 55 additions & 36 deletions server/ai/drivers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,49 +216,62 @@ export interface AnthropicMessage {
// Adapter
// ---------------------------------------------------------------------------

const anthropicAdapter: ProviderAdapter<AnthropicMessage> = {
export function makeAnthropicMessagesAdapter(opts: {
label: string
endpoint: string
buildHeaders: (req: AiStreamRequest) => Record<string, string>
}): ProviderAdapter<AnthropicMessage> {
const { label, endpoint, buildHeaders } = opts
return {
label,
endpoint,

buildHeaders,

mapHistory(req) {
return mapHistory(req.messages)
},

buildRequestBody(messages, req) {
const body: Record<string, unknown> = {
model: req.modelId,
max_tokens: MAX_OUTPUT_TOKENS,
system: buildSystemBlocks(req.systemPrompt),
messages,
stream: true,
}
if (req.tools.length > 0) {
body.tools = req.tools.map((t) => ({
name: t.name,
description: t.description,
// The TypeBox schema IS JSON Schema — pass it straight through.
input_schema: t.inputSchema,
}))
}
return body
},

buildToolResultMessage(results) {
return buildToolResultMessage(results)
},

createTurnTranslator() {
return new AnthropicTurnTranslator(label)
},
}
}

const anthropicAdapter = makeAnthropicMessagesAdapter({
label: 'Anthropic',
endpoint: ANTHROPIC_ENDPOINT,

buildHeaders(req) {
return {
'x-api-key': req.credentials.apiKey!,
'anthropic-version': ANTHROPIC_VERSION,
'content-type': 'application/json',
}
},

mapHistory(req) {
return mapHistory(req.messages)
},

buildRequestBody(messages, req) {
const body: Record<string, unknown> = {
model: req.modelId,
max_tokens: MAX_OUTPUT_TOKENS,
system: buildSystemBlocks(req.systemPrompt),
messages,
stream: true,
}
if (req.tools.length > 0) {
body.tools = req.tools.map((t) => ({
name: t.name,
description: t.description,
// The TypeBox schema IS JSON Schema — pass it straight through.
input_schema: t.inputSchema,
}))
}
return body
},

buildToolResultMessage(results) {
return buildToolResultMessage(results)
},

createTurnTranslator() {
return new AnthropicTurnTranslator()
},
}
})

// ---------------------------------------------------------------------------
// System prompt → system blocks
Expand Down Expand Up @@ -476,6 +489,12 @@ interface MutableUsage {
}

export class AnthropicTurnTranslator implements TurnTranslator<AnthropicMessage> {
private readonly label: string

constructor(label = 'Anthropic') {
this.label = label
}

// Block order as it streams, so the assistant turn rebuilds text/tool_use
// blocks in the sequence the model emitted them.
private readonly order: number[] = []
Expand Down Expand Up @@ -557,8 +576,8 @@ export class AnthropicTurnTranslator implements TurnTranslator<AnthropicMessage>
return [{
type: 'error',
message: detail
? `Anthropic error: ${detail}`
: 'Anthropic stream failed. Check your credentials in /admin/ai/providers.',
? `${this.label} error: ${detail}`
: `${this.label} stream failed. Check your credentials in /admin/ai/providers.`,
}]
}

Expand Down
6 changes: 5 additions & 1 deletion server/ai/drivers/http/chatCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from './toolLoop'
import type { SseFrame } from './sse'
import { parseToolArguments } from './toolArgs'
import type { AiStreamRequest } from '../types'
import { nanoid } from 'nanoid'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -329,8 +330,9 @@ export function makeChatCompletionsAdapter(opts: {
baseUrl: string
apiKey: string | null
label: string
requestBodyExtras?: (req: AiStreamRequest) => Record<string, unknown>
}): ProviderAdapter<ChatTurn> {
const { baseUrl, apiKey, label } = opts
const { baseUrl, apiKey, label, requestBodyExtras } = opts
return {
label,
endpoint: `${normalizeOpenAiBaseUrl(baseUrl)}/v1/chat/completions`,
Expand All @@ -355,6 +357,8 @@ export function makeChatCompletionsAdapter(opts: {
function: { name: t.name, description: t.description, parameters: t.inputSchema },
}))
}
const extra = requestBodyExtras?.(req)
if (extra) Object.assign(body, extra)
return body
},
buildToolResultMessage(results: TurnToolResult[]): ChatTurn {
Expand Down
3 changes: 2 additions & 1 deletion server/ai/drivers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import type { AiProvider } from './types'
import type { AiProviderId } from '../runtime/types'
import { anthropicDriver } from './anthropic'
import { openaiDriver } from './openai'
import { minimaxDriver } from './minimax'
import { ollamaDriver } from './ollama'
import { openrouterDriver } from './openrouter'
import { openaiCompatibleDriver } from './openaiCompatible'

const DRIVERS: Record<AiProviderId, AiProvider> = {
anthropic: anthropicDriver,
openai: openaiDriver,
minimax: minimaxDriver,
ollama: ollamaDriver,
openrouter: openrouterDriver,
'openai-compatible': openaiCompatibleDriver,
Expand All @@ -30,4 +32,3 @@ export function resolveDriver(providerId: AiProviderId): AiProvider {
}
return driver
}

212 changes: 212 additions & 0 deletions server/ai/drivers/minimax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { afterEach, describe, expect, it } from 'bun:test'
import type { AiBrowserBridge, AiStreamEvent } from '../runtime/types'
import { minimaxDriver } from './minimax'
import type { AiResolvedCredential, AiStreamRequest } from './types'

const originalFetch = globalThis.fetch

afterEach(() => {
globalThis.fetch = originalFetch
})

function creds(baseUrl: string | null, apiKey: string | null = 'sk-test'): AiResolvedCredential {
return { id: 'c1', providerId: 'minimax', authMode: 'baseUrl', apiKey, baseUrl }
}

function sseResponse(body: string): Response {
return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
}

function request(baseUrl: string, modelId = 'MiniMax-M3'): AiStreamRequest {
const bridge: AiBrowserBridge = {
async callBrowser() {
return { ok: true }
},
}
return {
systemPrompt: ['You are a test.'],
messages: [{ role: 'user', content: [{ kind: 'text', text: 'Hello' }] }],
tools: [],
modelId,
modelCapabilities: minimaxDriver.capabilities(modelId),
credentials: creds(baseUrl),
signal: new AbortController().signal,
bridge,
toolContextBase: {
db: {} as never,
userId: 'u1',
scope: 'site',
conversationId: 'c1',
snapshot: {},
},
}
}

describe('minimax driver', () => {
it('reports baseUrl as its only auth mode', () => {
expect(minimaxDriver.supportedAuthModes).toEqual(['baseUrl'])
})

it('returns the MiniMax model catalogue when the live endpoint is reachable', async () => {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
expect(url).toBe('https://api.minimax.io/v1/models')
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer sk-test')
return new Response(JSON.stringify({
data: [{ id: 'MiniMax-M3' }, { id: 'MiniMax-M2.7' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

const models = await minimaxDriver.listModels(creds('https://api.minimax.io/v1'))
expect(models.map((model) => model.id)).toEqual(['MiniMax-M3', 'MiniMax-M2.7'])
expect(models[0]).toMatchObject({
label: 'MiniMax M3',
capabilities: {
toolCalling: true,
visionInput: true,
videoInput: true,
promptCache: false,
streaming: true,
},
contextWindow: 1000000,
pricing: {
inputPerMTok: 0.6,
outputPerMTok: 2.4,
cacheReadPerMTok: 0.12,
cacheWritePerMTok: null,
},
})
expect(models[1]).toMatchObject({
label: 'MiniMax M2.7',
capabilities: {
visionInput: false,
videoInput: false,
},
contextWindow: 204800,
pricing: {
inputPerMTok: 0.3,
outputPerMTok: 1.2,
cacheReadPerMTok: 0.06,
cacheWritePerMTok: 0.375,
},
})
})

it('uses the China Anthropic-compatible model endpoint and API key header', async () => {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
expect(url).toBe('https://api.minimaxi.com/anthropic/v1/models')
expect(new Headers(init?.headers).get('X-Api-Key')).toBe('sk-test')
return Response.json({ data: [{ id: 'MiniMax-M3' }] })
}) as typeof fetch

const models = await minimaxDriver.listModels(creds('https://api.minimaxi.com/anthropic'))
expect(models.map((model) => model.id)).toEqual(['MiniMax-M3'])
})

it('returns [] when no base URL is configured', async () => {
expect(await minimaxDriver.listModels(creds(null))).toEqual([])
})

it('returns [] when no API key is configured', async () => {
expect(await minimaxDriver.listModels(creds('https://api.minimax.io/v1', null))).toEqual([])
})

it('reports the MiniMax M3 media input capabilities without enabling prompt cache', () => {
expect(minimaxDriver.capabilities('MiniMax-M3')).toMatchObject({
toolCalling: true,
visionInput: true,
videoInput: true,
toolResultImages: false,
promptCache: false,
streaming: true,
})
expect(minimaxDriver.capabilities('MiniMax-M2.7')).toMatchObject({
visionInput: false,
videoInput: false,
})
})

it('streams through the OpenAI-compatible global endpoint with adaptive M3 thinking', async () => {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
expect(String(input)).toBe('https://api.minimax.io/v1/chat/completions')
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer sk-test')
const body = JSON.parse(String(init?.body))
expect(body).toMatchObject({
model: 'MiniMax-M3',
reasoning_split: true,
thinking: { type: 'adaptive' },
})
return sseResponse([
'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
'data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
}) as typeof fetch

const events: AiStreamEvent[] = []
for await (const event of minimaxDriver.stream(request('https://api.minimax.io/v1'))) {
events.push(event)
}
expect(events.filter((event) => event.type === 'text')).toEqual([{ type: 'text', text: 'ok' }])
})

it('keeps MiniMax M2.7 always-on thinking on the provider default', async () => {
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body))
expect(body).toMatchObject({ model: 'MiniMax-M2.7', reasoning_split: true })
expect(body.thinking).toBeUndefined()
return sseResponse([
'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
'data: [DONE]',
'',
].join('\n\n'))
}) as typeof fetch

for await (const _event of minimaxDriver.stream(
request('https://api.minimax.io/v1', 'MiniMax-M2.7'),
)) {
// Consume the stream so the request body is exercised.
}
})

it('streams through the Anthropic-compatible China endpoint with bearer auth', async () => {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
expect(String(input)).toBe('https://api.minimaxi.com/anthropic/v1/messages')
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer sk-test')
expect(JSON.parse(String(init?.body))).toMatchObject({ model: 'MiniMax-M3', stream: true })
return sseResponse([
'event: message_start',
'data: {"type":"message_start","message":{"usage":{"input_tokens":2,"output_tokens":0}}}',
'',
'event: content_block_start',
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}',
'',
'event: content_block_stop',
'data: {"type":"content_block_stop","index":0}',
'',
'event: message_delta',
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}',
'',
'event: message_stop',
'data: {"type":"message_stop"}',
'',
].join('\n'))
}) as typeof fetch

const events: AiStreamEvent[] = []
for await (const event of minimaxDriver.stream(request('https://api.minimaxi.com/anthropic'))) {
events.push(event)
}
expect(events.filter((event) => event.type === 'text')).toEqual([{ type: 'text', text: 'ok' }])
})
})
Loading