diff --git a/docs/features/agent.md b/docs/features/agent.md index cc3c90be2..8afc3dafa 100644 --- a/docs/features/agent.md +++ b/docs/features/agent.md @@ -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.1-Flash first, then MiniMax-M3, 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. Flash is the first available choice for new credentials; saved model selections are not rewritten. Flash uses adaptive thinking with the API's default `max` effort. M3 uses the protocol default (adaptive on Chat Completions, disabled on Messages). Instatic does not expose an effort or thinking override. MiniMax reasoning is retained during tool continuation. Direct list prices describe the standard short-context tier; M3 costs increase above 512k context. The gateway catalogue keeps its own prices and context limits, offering `minimax/minimax-m3.1-flash:free` before `minimax/minimax-m3` while preserving saved legacy selections. + **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. --- diff --git a/server/ai/drivers/anthropic.ts b/server/ai/drivers/anthropic.ts index 57176251e..ef62ed91e 100644 --- a/server/ai/drivers/anthropic.ts +++ b/server/ai/drivers/anthropic.ts @@ -201,7 +201,13 @@ interface AnthropicToolResultBlock { content: string | (AnthropicTextBlock | AnthropicImageBlock)[] is_error?: boolean } +interface AnthropicThinkingBlock { + type: 'thinking' + thinking: string + signature?: string +} type AnthropicContentBlock = + | AnthropicThinkingBlock | AnthropicTextBlock | AnthropicImageBlock | AnthropicToolUseBlock @@ -216,10 +222,58 @@ export interface AnthropicMessage { // Adapter // --------------------------------------------------------------------------- -const anthropicAdapter: ProviderAdapter = { +export function makeAnthropicMessagesAdapter(opts: { + label: string + endpoint: string + buildHeaders: (req: AiStreamRequest) => Record + requestBodyExtras?: (req: AiStreamRequest) => Record + preserveReasoning?: boolean +}): ProviderAdapter { + const { label, endpoint, buildHeaders, requestBodyExtras, preserveReasoning } = opts + return { + label, + endpoint, + + buildHeaders, + + mapHistory(req) { + return mapHistory(req.messages) + }, + + buildRequestBody(messages, req) { + const body: Record = { + 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, + })) + } + const extra = requestBodyExtras?.(req) + if (extra) Object.assign(body, extra) + return body + }, + + buildToolResultMessage(results) { + return buildToolResultMessage(results) + }, + + createTurnTranslator() { + return new AnthropicTurnTranslator(label, preserveReasoning) + }, + } +} + +const anthropicAdapter = makeAnthropicMessagesAdapter({ label: 'Anthropic', endpoint: ANTHROPIC_ENDPOINT, - buildHeaders(req) { return { 'x-api-key': req.credentials.apiKey!, @@ -227,38 +281,7 @@ const anthropicAdapter: ProviderAdapter = { 'content-type': 'application/json', } }, - - mapHistory(req) { - return mapHistory(req.messages) - }, - - buildRequestBody(messages, req) { - const body: Record = { - 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 @@ -432,6 +455,8 @@ const AnthropicSseEventSchema = Type.Object( id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), input: Type.Optional(Type.Unknown()), + thinking: Type.Optional(Type.String()), + signature: Type.Optional(Type.String()), }, { additionalProperties: true }, ), @@ -441,6 +466,8 @@ const AnthropicSseEventSchema = Type.Object( { type: Type.Optional(Type.String()), text: Type.Optional(Type.String()), + thinking: Type.Optional(Type.String()), + signature: Type.Optional(Type.String()), partial_json: Type.Optional(Type.String()), stop_reason: Type.Optional(Type.Union([Type.String(), Type.Null()])), }, @@ -476,10 +503,19 @@ interface MutableUsage { } export class AnthropicTurnTranslator implements TurnTranslator { + private readonly label: string + private readonly preserveReasoning: boolean + + constructor(label = 'Anthropic', preserveReasoning = false) { + this.label = label + this.preserveReasoning = preserveReasoning + } + // 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[] = [] private readonly textByIndex = new Map() + private readonly thinkingByIndex = new Map() private readonly toolByIndex = new Map() private readonly toolCalls: TurnToolCall[] = [] private usage: MutableUsage = {} @@ -510,6 +546,13 @@ export class AnthropicTurnTranslator implements TurnTranslator name: typeof block.name === 'string' ? block.name : 'tool', json: '', }) + } else if (this.preserveReasoning && block?.type === 'thinking') { + this.order.push(index) + this.thinkingByIndex.set(index, { + type: 'thinking', + thinking: block.thinking ?? '', + ...(block.signature !== undefined ? { signature: block.signature } : {}), + }) } else if (block?.type === 'text') { this.order.push(index) this.textByIndex.set(index, '') @@ -520,6 +563,13 @@ export class AnthropicTurnTranslator implements TurnTranslator case 'content_block_delta': { const index = event.index ?? 0 const delta = event.delta + const thinking = this.thinkingByIndex.get(index) + if (thinking && delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') { + thinking.thinking += delta.thinking + } + if (thinking && delta?.type === 'signature_delta' && typeof delta.signature === 'string') { + thinking.signature = (thinking.signature ?? '') + delta.signature + } if (delta?.type === 'text_delta' && typeof delta.text === 'string') { this.textByIndex.set(index, (this.textByIndex.get(index) ?? '') + delta.text) return [{ type: 'text', text: delta.text }] @@ -557,8 +607,8 @@ export class AnthropicTurnTranslator implements TurnTranslator 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.`, }] } @@ -571,6 +621,11 @@ export class AnthropicTurnTranslator implements TurnTranslator finish(): TurnResult { const content: AnthropicContentBlock[] = [] for (const index of this.order) { + const thinking = this.thinkingByIndex.get(index) + if (thinking) { + content.push(thinking) + continue + } const text = this.textByIndex.get(index) if (text !== undefined) { if (text) content.push({ type: 'text', text }) diff --git a/server/ai/drivers/http/chatCompletions.ts b/server/ai/drivers/http/chatCompletions.ts index f668c2d5f..e571d69c7 100644 --- a/server/ai/drivers/http/chatCompletions.ts +++ b/server/ai/drivers/http/chatCompletions.ts @@ -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' // --------------------------------------------------------------------------- @@ -44,7 +45,7 @@ export type ChatToolCall = { export type ChatMessage = | { role: 'system'; content: string } | { role: 'user'; content: string | ChatContentPart[] } - | { role: 'assistant'; content: string; tool_calls?: ChatToolCall[] } + | { role: 'assistant'; content: string; tool_calls?: ChatToolCall[]; reasoning_content?: string } | { role: 'tool'; tool_call_id: string; content: string } // Each canonical `AiMessage` maps to one or more chat messages (an assistant @@ -192,6 +193,7 @@ const ChatChunkSchema = Type.Object( Type.Object( { content: nullable(Type.String()), + reasoning_content: nullable(Type.String()), tool_calls: nullable(Type.Array(ChatToolCallDeltaSchema)), }, { additionalProperties: true }, @@ -224,7 +226,14 @@ interface MutableToolCall { } export class ChatCompletionsTurnTranslator implements TurnTranslator { + private readonly preserveReasoning: boolean + + constructor(preserveReasoning = false) { + this.preserveReasoning = preserveReasoning + } + private text = '' + private reasoning = '' // Tool calls accumulate by their streamed `index`; fragments arrive across // chunks (id + name on the first, arguments piecemeal after). private readonly toolsByIndex = new Map() @@ -254,6 +263,9 @@ export class ChatCompletionsTurnTranslator implements TurnTranslator { const events: AiStreamEvent[] = [] const delta = choice.delta if (delta) { + if (this.preserveReasoning && typeof delta.reasoning_content === 'string') { + this.reasoning += delta.reasoning_content + } if (typeof delta.content === 'string' && delta.content.length > 0) { this.text += delta.content events.push({ type: 'text', text: delta.content }) @@ -312,10 +324,12 @@ export class ChatCompletionsTurnTranslator implements TurnTranslator { ? { role: 'assistant', content: this.text, tool_calls: chatToolCalls } : { role: 'assistant', content: this.text } + if (this.reasoning) assistant.reasoning_content = this.reasoning + return { stop: toolCalls.length === 0, toolCalls, - assistantMessage: this.text || chatToolCalls.length > 0 ? [assistant] : null, + assistantMessage: this.text || this.reasoning || chatToolCalls.length > 0 ? [assistant] : null, usage: this.usage, } } @@ -329,8 +343,10 @@ export function makeChatCompletionsAdapter(opts: { baseUrl: string apiKey: string | null label: string + requestBodyExtras?: (req: AiStreamRequest) => Record + preserveReasoning?: boolean }): ProviderAdapter { - const { baseUrl, apiKey, label } = opts + const { baseUrl, apiKey, label, requestBodyExtras, preserveReasoning } = opts return { label, endpoint: `${normalizeOpenAiBaseUrl(baseUrl)}/v1/chat/completions`, @@ -355,6 +371,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 { @@ -365,7 +383,7 @@ export function makeChatCompletionsAdapter(opts: { })) }, createTurnTranslator() { - return new ChatCompletionsTurnTranslator() + return new ChatCompletionsTurnTranslator(preserveReasoning) }, } } diff --git a/server/ai/drivers/index.ts b/server/ai/drivers/index.ts index 01472cef7..356130f74 100644 --- a/server/ai/drivers/index.ts +++ b/server/ai/drivers/index.ts @@ -10,6 +10,7 @@ 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' @@ -17,6 +18,7 @@ import { openaiCompatibleDriver } from './openaiCompatible' const DRIVERS: Record = { anthropic: anthropicDriver, openai: openaiDriver, + minimax: minimaxDriver, ollama: ollamaDriver, openrouter: openrouterDriver, 'openai-compatible': openaiCompatibleDriver, @@ -30,4 +32,3 @@ export function resolveDriver(providerId: AiProviderId): AiProvider { } return driver } - diff --git a/server/ai/drivers/minimax.test.ts b/server/ai/drivers/minimax.test.ts new file mode 100644 index 000000000..570f61a7a --- /dev/null +++ b/server/ai/drivers/minimax.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { Type } from '@core/utils/typeboxHelpers' +import type { AiStreamEvent } from '../runtime/types' +import { minimaxDriver } from './minimax' +import { openrouterDriver } from './openrouter' +import type { AiResolvedCredential, AiStreamRequest } from './types' + +const originalFetch = globalThis.fetch +afterEach(() => { globalThis.fetch = originalFetch }) + +function creds(baseUrl: string | null, apiKey: string | null = 'test-key'): AiResolvedCredential { + return { id: 'c1', providerId: 'minimax', authMode: 'baseUrl', apiKey, baseUrl } +} + +function sse(events: object[], anthropic = false): Response { + return new Response(events.map((event) => `${anthropic ? `event: ${'type' in event ? event.type : ''}\n` : ''}data: ${JSON.stringify(event)}\n\n`).join(''), { + headers: { 'content-type': 'text/event-stream' }, + }) +} + +function request(baseUrl: string, modelId = 'MiniMax-M3.1-Flash'): AiStreamRequest { + 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: { async callBrowser() { return { ok: true } } }, + toolContextBase: { + db: {} as never, userId: 'u1', capabilities: [], scope: 'site', conversationId: 'c1', snapshot: {}, + }, + } +} + +async function consume(req: AiStreamRequest): Promise { + const events: AiStreamEvent[] = [] + for await (const event of minimaxDriver.stream(req)) events.push(event) + expect(events.filter((event) => event.type === 'error')).toEqual([]) + return events +} + +const endpoints = [ + 'https://api.minimax.io/v1', + 'https://api.minimaxi.com/v1', + 'https://api.minimax.io/anthropic', + 'https://api.minimaxi.com/anthropic', +] + +describe('minimax driver', () => { + it('requires a base URL and key', async () => { + expect(minimaxDriver.supportedAuthModes).toEqual(['baseUrl']) + expect(await minimaxDriver.listModels(creds(null))).toEqual([]) + expect(await minimaxDriver.listModels(creds(endpoints[0]!, null))).toEqual([]) + }) + + for (const baseUrl of endpoints) { + const anthropic = baseUrl.endsWith('/anthropic') + it(`offers only live Flash then M3 at ${baseUrl}`, async () => { + let calledUrl = '' + let headers = new Headers() + globalThis.fetch = (async (input, init) => { + calledUrl = String(input) + headers = new Headers(init?.headers) + return Response.json({ data: [ + { id: 'MiniMax-M2.7' }, { id: 'MiniMax-M3' }, { id: 'other' }, { id: 'MiniMax-M3.1-Flash' }, + ] }) + }) as typeof fetch + const models = await minimaxDriver.listModels(creds(baseUrl)) + expect(calledUrl).toBe(`${baseUrl}${anthropic ? '/v1' : ''}/models`) + expect(headers.get(anthropic ? 'X-Api-Key' : 'Authorization')).toBe(anthropic ? 'test-key' : 'Bearer test-key') + expect(models.map((model) => model.id)).toEqual(['MiniMax-M3.1-Flash', 'MiniMax-M3']) + expect(models[0]).toMatchObject({ + contextWindow: 1_000_000, catalogueSource: 'live', + pricing: { inputPerMTok: 0.15, outputPerMTok: 0.6, cacheReadPerMTok: 0.015, cacheWritePerMTok: null }, + capabilities: { visionInput: true, videoInput: true }, + }) + expect(models[1]).toMatchObject({ + contextWindow: 1_000_000, + pricing: { inputPerMTok: 0.3, outputPerMTok: 1.2, cacheReadPerMTok: 0.06, cacheWritePerMTok: null }, + }) + }) + + for (const modelId of ['MiniMax-M3.1-Flash', 'MiniMax-M3', 'MiniMax-M2.7', 'custom-saved-model']) { + it(`serializes ${modelId} unchanged at ${baseUrl} with protocol thinking/effort defaults`, async () => { + let calledUrl = '' + let body: Record = {} + globalThis.fetch = (async (input, init) => { + calledUrl = String(input) + body = JSON.parse(String(init?.body)) + return anthropic + ? sse([{ type: 'message_delta', delta: { stop_reason: 'end_turn' } }], true) + : sse([{ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }]) + }) as typeof fetch + await consume(request(baseUrl, modelId)) + expect(calledUrl).toBe(`${baseUrl}${anthropic ? '/v1/messages' : '/chat/completions'}`) + expect(body.model).toBe(modelId) + expect(body.stream).toBe(true) + if (modelId === 'MiniMax-M3') { + expect(body.thinking).toEqual({ type: anthropic ? 'disabled' : 'adaptive' }) + } else { + expect(body.thinking).toBeUndefined() + } + expect(body.reasoning_effort).toBeUndefined() + expect(body.output_config).toBeUndefined() + expect(body.service_tier).toBeUndefined() + if (!anthropic) expect(body.reasoning_split).toBe(true) + }) + } + + it(`preserves reasoning, tools, prior history and images through tool continuation at ${baseUrl}`, async () => { + const bodies: Record[] = [] + globalThis.fetch = (async (_input, init) => { + bodies.push(JSON.parse(String(init?.body))) + if (bodies.length > 1) return anthropic + ? sse([{ type: 'message_delta', delta: { stop_reason: 'end_turn' } }], true) + : sse([{ choices: [{ delta: { content: 'Done' }, finish_reason: 'stop' }] }]) + return anthropic ? sse([ + { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'Plan ' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'carefully' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'signature' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'call-1', name: 'lookup' } }, + { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{}' } }, + { type: 'content_block_stop', index: 1 }, + { type: 'message_delta', delta: { stop_reason: 'tool_use' } }, + ], true) : sse([ + { choices: [{ delta: { reasoning_content: 'Plan ' } }] }, + { choices: [{ delta: { reasoning_content: 'carefully', content: 'Checking', tool_calls: [ + { index: 0, id: 'call-1', function: { name: 'lookup', arguments: '{}' } }, + ] }, finish_reason: 'tool_calls' }] }, + ]) + }) as typeof fetch + const req = request(baseUrl) + req.messages.push( + { role: 'assistant', content: [{ kind: 'text', text: 'Prior answer' }] }, + { role: 'user', content: [{ kind: 'image', mimeType: 'image/png', data: 'aW1hZ2U=' }] }, + ) + req.tools.push({ name: 'lookup', description: 'Read a value', scope: 'shared', execution: 'server', inputSchema: Type.Object({}), async handler() { return { value: 42 } } }) + await consume(req) + expect(bodies).toHaveLength(2) + expect(bodies[0]!.tools).toHaveLength(1) + const replay = JSON.stringify(bodies[1]!.messages) + expect(replay).toContain('Prior answer') + expect(replay).toContain('aW1hZ2U=') + expect(replay).toContain('call-1') + expect(replay).toContain('42') + expect(replay).toContain('Plan carefully') + if (anthropic) expect(replay).toContain('signature') + else expect(replay).toContain('reasoning_content') + }) + } + + it('sends the first discovered model for a newly selected credential', async () => { + let sentModel = '' + globalThis.fetch = (async (input, init) => { + if (String(input).endsWith('/models')) { + return Response.json({ data: [{ id: 'MiniMax-M3' }, { id: 'MiniMax-M3.1-Flash' }] }) + } + sentModel = JSON.parse(String(init?.body)).model + return sse([{ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }]) + }) as typeof fetch + const models = await minimaxDriver.listModels(creds(endpoints[0]!)) + const preferred = models.find((model) => model.tier === 'smartest') ?? models[0]! + await consume(request(endpoints[0]!, preferred.id)) + expect(sentModel).toBe('MiniMax-M3.1-Flash') + }) + + it('does not invent availability when Flash is absent or the endpoint fails', async () => { + globalThis.fetch = (async () => Response.json({ data: [{ id: 'MiniMax-M3' }, { id: 'MiniMax-M2.7' }] })) as typeof fetch + expect((await minimaxDriver.listModels(creds(endpoints[0]!))).map((model) => model.id)).toEqual(['MiniMax-M3']) + globalThis.fetch = (async () => new Response('', { status: 503 })) as typeof fetch + expect(await minimaxDriver.listModels(creds(endpoints[0]!))).toEqual([]) + expect(minimaxDriver.capabilities('MiniMax-M2.7').visionInput).toBe(false) + }) +}) + +describe('MiniMax gateway choices', () => { + it('filters ordinary choices while preserving gateway metadata and saved legacy capabilities', async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: 'unrelated/first', context_length: 123 }, + { id: 'minimax/minimax-m2.7', architecture: { input_modalities: ['text', 'image'] } }, + { id: 'minimax/minimax-m3', context_length: 1048576, pricing: { prompt: '0.0000003', completion: '0.0000012' } }, + { id: 'unrelated/last' }, + { id: 'minimax/media-fixture' }, + { id: 'minimax/minimax-m3.1-flash:free', context_length: 1000000, pricing: { prompt: '0', completion: '0' } }, + ] })) as typeof fetch + const credential: AiResolvedCredential = { ...creds(null), providerId: 'openrouter', authMode: 'apiKey' } + const models = await openrouterDriver.listModels(credential) + expect(models.map((model) => model.id)).toEqual([ + 'unrelated/first', 'minimax/minimax-m3.1-flash:free', 'minimax/minimax-m3', 'unrelated/last', 'minimax/media-fixture', + ]) + expect(models[1]).toMatchObject({ contextWindow: 1000000, pricing: { inputPerMTok: 0, outputPerMTok: 0 } }) + expect(models[2]).toMatchObject({ contextWindow: 1048576, pricing: { inputPerMTok: 0.3, outputPerMTok: 1.2 } }) + expect((await openrouterDriver.resolveCapabilities!(credential, 'minimax/minimax-m2.7', new AbortController().signal)).visionInput).toBe(true) + }) +}) diff --git a/server/ai/drivers/minimax.ts b/server/ai/drivers/minimax.ts new file mode 100644 index 000000000..cdadd47b1 --- /dev/null +++ b/server/ai/drivers/minimax.ts @@ -0,0 +1,209 @@ +/** + * MiniMax driver — direct HTTP against the documented MiniMax API. + * + * The runtime reuses the shared OpenAI-compatible chat/completions transport + * for MiniMax's text models, then overlays the provider-specific model + * catalogue and request fields. + */ + +import { isAbortError } from '@core/http' +import { Type, parseValue } from '@core/utils/typeboxHelpers' +import type { + AiAuthMode, + AiProviderId, + AiStreamEvent, +} from '../runtime/types' +import type { + AiProvider, + AiProviderCapabilities, + AiProviderModel, + AiResolvedCredential, + AiStreamRequest, +} from './types' +import { runToolLoop } from './http/toolLoop' +import { makeAnthropicMessagesAdapter } from './anthropic' +import { makeChatCompletionsAdapter, normalizeOpenAiBaseUrl, trimSlash } from './http/chatCompletions' + +const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['baseUrl'] + +const MINIMAX_MODELS: AiProviderModel[] = [ + { + id: 'MiniMax-M3.1-Flash', + label: 'MiniMax M3.1 Flash', + capabilities: { + toolCalling: true, + visionInput: true, + videoInput: true, + toolResultImages: false, + promptCache: false, + streaming: true, + }, + pricing: { + inputPerMTok: 0.15, + outputPerMTok: 0.6, + cacheReadPerMTok: 0.015, + cacheWritePerMTok: null, + }, + contextWindow: 1_000_000, + catalogueSource: 'live', + }, + { + id: 'MiniMax-M3', + label: 'MiniMax M3', + capabilities: { + toolCalling: true, + visionInput: true, + videoInput: true, + toolResultImages: false, + promptCache: false, + streaming: true, + }, + pricing: { + inputPerMTok: 0.3, + outputPerMTok: 1.2, + cacheReadPerMTok: 0.06, + cacheWritePerMTok: null, + }, + contextWindow: 1_000_000, + catalogueSource: 'live', + }, +] + +const DEFAULT_CAPABILITIES: AiProviderCapabilities = { + toolCalling: true, + visionInput: false, + videoInput: false, + toolResultImages: false, + promptCache: false, + streaming: true, +} + +function staticCapabilities(modelId: string): AiProviderCapabilities { + if (modelId === 'MiniMax-M3.1-Flash' || modelId === 'MiniMax-M3') { + return { ...DEFAULT_CAPABILITIES, visionInput: true, videoInput: true } + } + return { ...DEFAULT_CAPABILITIES } +} + +function usesAnthropicProtocol(baseUrl: string): boolean { + try { + return /\/anthropic(?:\/v1)?\/?$/.test(new URL(baseUrl).pathname) + } catch { + return /\/anthropic(?:\/v1)?\/?$/.test(baseUrl) + } +} + +function normalizeAnthropicBaseUrl(baseUrl: string): string { + return trimSlash(baseUrl).replace(/\/v1$/, '') +} + +function minimaxOpenAiAdapter(baseUrl: string, apiKey: string) { + return makeChatCompletionsAdapter({ + baseUrl, + apiKey, + label: 'MiniMax', + preserveReasoning: true, + requestBodyExtras(req) { + // MiniMax-M3 defaults to adaptive thinking on Chat Completions. Keep + // that selection explicit so the protocol supports the model's + // disabled-thinking alternative on the Anthropic route below without + // changing Flash's always-on behavior. + return { + reasoning_split: true, + ...(req.modelId === 'MiniMax-M3' ? { thinking: { type: 'adaptive' } } : {}), + } + }, + }) +} + +function minimaxAnthropicAdapter(baseUrl: string, apiKey: string) { + return makeAnthropicMessagesAdapter({ + label: 'MiniMax', + preserveReasoning: true, + requestBodyExtras(req) { + // MiniMax-M3 defaults to thinking disabled on Messages. The OpenAI + // route uses the documented adaptive default; Flash remains always-on. + return req.modelId === 'MiniMax-M3' ? { thinking: { type: 'disabled' } } : {} + }, + endpoint: `${normalizeAnthropicBaseUrl(baseUrl)}/v1/messages`, + buildHeaders() { + return { + Authorization: `Bearer ${apiKey}`, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + } + }, + }) +} + +const ModelsResponseSchema = Type.Object( + { data: Type.Array(Type.Object({ id: Type.String() }, { additionalProperties: true })) }, + { additionalProperties: true }, +) + +async function fetchMiniMaxModels( + baseUrl: string, + apiKey: string, + signal?: AbortSignal, +): Promise { + const anthropic = usesAnthropicProtocol(baseUrl) + const endpoint = anthropic + ? `${normalizeAnthropicBaseUrl(baseUrl)}/v1/models` + : `${normalizeOpenAiBaseUrl(baseUrl)}/v1/models` + const headers: Record = anthropic + ? { 'X-Api-Key': apiKey } + : { Authorization: `Bearer ${apiKey}` } + + try { + const res = await fetch(endpoint, { headers, signal }) + if (!res.ok) return [] + const parsed = parseValue(ModelsResponseSchema, await res.json()) + const liveIds = new Set(parsed.data.map((model) => model.id)) + return MINIMAX_MODELS.filter((model) => liveIds.has(model.id)) + } catch (err) { + if (signal?.aborted || isAbortError(err)) throw err + console.error('[ai/minimax] models request failed:', err) + return [] + } +} + +export const minimaxDriver: AiProvider = { + id: 'minimax' as AiProviderId, + label: 'MiniMax', + supportedAuthModes: SUPPORTED_AUTH_MODES, + + capabilities(modelId: string) { + return staticCapabilities(modelId) + }, + + async listModels(creds: AiResolvedCredential, signal?: AbortSignal) { + if (creds.authMode !== 'baseUrl' || !creds.baseUrl || !creds.apiKey) return [] + return fetchMiniMaxModels(creds.baseUrl, creds.apiKey, signal) + }, + + async *stream(req: AiStreamRequest): AsyncIterable { + if ( + req.credentials.authMode !== 'baseUrl' + || !req.credentials.baseUrl + || !req.credentials.apiKey + ) { + yield { + type: 'error', + message: + 'MiniMax requires a base URL and API key. Add the credential in /admin/ai/providers and pick it for the site default.', + } + return + } + if (usesAnthropicProtocol(req.credentials.baseUrl)) { + yield* runToolLoop( + minimaxAnthropicAdapter(req.credentials.baseUrl, req.credentials.apiKey), + req, + ) + return + } + yield* runToolLoop( + minimaxOpenAiAdapter(req.credentials.baseUrl, req.credentials.apiKey), + req, + ) + }, +} diff --git a/server/ai/drivers/modelCapabilities.ts b/server/ai/drivers/modelCapabilities.ts index e6825594c..009068658 100644 --- a/server/ai/drivers/modelCapabilities.ts +++ b/server/ai/drivers/modelCapabilities.ts @@ -52,10 +52,10 @@ export async function resolveModelCapabilities( return value } catch (err) { console.error(`[ai/${driver.id}] model capability lookup failed:`, err) - // A provider-specific lookup exists because its static vision flag is not - // authoritative. Network/schema failures therefore fail closed for image + // A provider-specific lookup exists because its static input flags are not + // authoritative. Network/schema failures therefore fail closed for media // input while retaining the driver's safe defaults for other features. - return { ...fallback, visionInput: false } + return { ...fallback, visionInput: false, videoInput: false } } finally { lookups.delete(key) } diff --git a/server/ai/drivers/openrouter.ts b/server/ai/drivers/openrouter.ts index e656e1cee..8fedee7b2 100644 --- a/server/ai/drivers/openrouter.ts +++ b/server/ai/drivers/openrouter.ts @@ -75,7 +75,17 @@ export const openrouterDriver: AiProvider = { }, async listModels(creds: AiResolvedCredential, signal?: AbortSignal) { - return fetchOpenRouterModels(creds, signal) + const models = await fetchOpenRouterModels(creds, signal) + const currentMiniMaxIds = ['minimax/minimax-m3.1-flash:free', 'minimax/minimax-m3'] + const currentMiniMax = currentMiniMaxIds.flatMap((id) => models.filter((model) => model.id === id)) + let inserted = false + return models.flatMap((model) => { + // Scope the picker policy to MiniMax's text families, preserving media routes. + if (!/^minimax\/minimax-(?:m\d|01)/.test(model.id)) return [model] + if (inserted) return [] + inserted = true + return currentMiniMax + }) }, async *stream(req: AiStreamRequest): AsyncIterable { diff --git a/server/ai/drivers/types.ts b/server/ai/drivers/types.ts index 0561b6d11..e0f90e20b 100644 --- a/server/ai/drivers/types.ts +++ b/server/ai/drivers/types.ts @@ -49,6 +49,8 @@ export interface AiProviderCapabilities { readonly toolCalling: boolean /** Model accepts image content blocks. */ readonly visionInput: boolean + /** Model advertises support for video input. */ + readonly videoInput?: boolean /** Provider wire format can deliver native images inside tool results. */ readonly toolResultImages: boolean /** Provider supports Anthropic-style cache_control on the static prefix. */ @@ -79,6 +81,8 @@ export interface AiProviderModel { readonly pricing?: { readonly inputPerMTok: number readonly outputPerMTok: number + readonly cacheReadPerMTok?: number | null + readonly cacheWritePerMTok?: number | null } /** * Max context window (total input+output tokens) for the model, from the same diff --git a/server/ai/handlers/credentials.ts b/server/ai/handlers/credentials.ts index be264ca6d..99f1712a6 100644 --- a/server/ai/handlers/credentials.ts +++ b/server/ai/handlers/credentials.ts @@ -35,6 +35,7 @@ const ALL_SCOPES: ToolScope[] = ['site', 'content', 'data', 'plugin'] const ProviderId = Type.Union([ Type.Literal('anthropic'), Type.Literal('openai'), + Type.Literal('minimax'), Type.Literal('ollama'), Type.Literal('openrouter'), Type.Literal('openai-compatible'), @@ -109,6 +110,12 @@ async function handleCreate(req: Request, db: DbClient): Promise { const body = await readValidatedBody(req, CreateBodySchema) if (!body) return badRequest('Invalid request body.') + if ( + body.providerId === 'minimax' + && (body.authMode !== 'baseUrl' || !body.apiKey?.trim()) + ) { + return badRequest('MiniMax requires a base URL and API key.') + } try { const record = await createCredentialForUser(db, userOrResponse.id, body) diff --git a/server/ai/handlers/models.ts b/server/ai/handlers/models.ts index 12374db7a..04f3fe0ba 100644 --- a/server/ai/handlers/models.ts +++ b/server/ai/handlers/models.ts @@ -20,7 +20,7 @@ import { getModelCatalogue, pricingKey } from '../pricing' import type { AiProviderModel } from '../drivers/types' import type { AiProviderId } from '../runtime/types' -const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter', 'openai-compatible'] +const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'minimax', 'ollama', 'openrouter', 'openai-compatible'] export function tryHandleAiModels( req: Request, @@ -77,7 +77,7 @@ async function handleModels( id: '', providerId, authMode: - providerId === 'ollama' || providerId === 'openai-compatible' + providerId === 'ollama' || providerId === 'minimax' || providerId === 'openai-compatible' ? ('baseUrl' as const) : ('apiKey' as const), apiKey: null, diff --git a/server/ai/runtime/types.ts b/server/ai/runtime/types.ts index b75e9b4b7..7936cd973 100644 --- a/server/ai/runtime/types.ts +++ b/server/ai/runtime/types.ts @@ -24,7 +24,7 @@ export type { AiContentBlock, AiToolImage, AiToolOutput } from '@core/ai' // Provider identity + auth modes // --------------------------------------------------------------------------- -export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'openai-compatible' +export type AiProviderId = 'anthropic' | 'openai' | 'minimax' | 'ollama' | 'openrouter' | 'openai-compatible' /** * Credential auth modes. * @@ -207,4 +207,3 @@ export interface AiBrowserBridge { // Aggregated usage — drivers report token counts so the handler can persist // per-message + per-conversation totals and compute cost from pricing.ts. // --------------------------------------------------------------------------- - diff --git a/src/__tests__/ai/credentialsHandler.test.ts b/src/__tests__/ai/credentialsHandler.test.ts index 043095fee..3881dbf3f 100644 --- a/src/__tests__/ai/credentialsHandler.test.ts +++ b/src/__tests__/ai/credentialsHandler.test.ts @@ -135,6 +135,25 @@ describe('AI credential handler', () => { expect(warnings.join('\n')).toContain('auto-default skipped') }) + it('requires a MiniMax base URL credential to include an API key', async () => { + const cookie = await harness.setupOwner() + const res = await harness.ai('/admin/api/ai/credentials', { + method: 'POST', + cookie, + json: { + providerId: 'minimax', + authMode: 'baseUrl', + displayLabel: 'MiniMax', + baseUrl: 'https://api.minimax.io/v1', + }, + }) + + expect(res.status).toBe(400) + expect(await readJson<{ error: string }>(res)).toEqual({ + error: 'MiniMax requires a base URL and API key.', + }) + }) + it('redacts API keys from auto-default model lookup warnings', async () => { const cookie = await harness.setupOwner() const apiKey = 'sk-proj-redaction-test' diff --git a/src/__tests__/ai/modelCapabilities.test.ts b/src/__tests__/ai/modelCapabilities.test.ts index 146f77e4e..5aae40749 100644 --- a/src/__tests__/ai/modelCapabilities.test.ts +++ b/src/__tests__/ai/modelCapabilities.test.ts @@ -10,6 +10,7 @@ import type { const TEXT_ONLY: AiProviderCapabilities = { toolCalling: true, visionInput: false, + videoInput: false, toolResultImages: false, promptCache: false, streaming: true, @@ -18,6 +19,7 @@ const TEXT_ONLY: AiProviderCapabilities = { const VISION: AiProviderCapabilities = { toolCalling: true, visionInput: true, + videoInput: false, toolResultImages: false, promptCache: false, streaming: true, diff --git a/src/__tests__/ai/providersTab.test.tsx b/src/__tests__/ai/providersTab.test.tsx index b95eee102..3255dfe51 100644 --- a/src/__tests__/ai/providersTab.test.tsx +++ b/src/__tests__/ai/providersTab.test.tsx @@ -33,7 +33,7 @@ describe('ProvidersTab', () => { expect(screen.queryByRole('combobox', { name: 'Provider' })).toBeNull() expect(screen.queryByLabelText('Authentication')).toBeNull() - expect(screen.getByLabelText('API key')).toBeDefined() + expect(screen.getByLabelText(/API key/)).toBeDefined() expect(screen.queryByRole('button', { name: 'Add' })).toBeNull() expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull() expect(screen.queryByText('Secrets are encrypted at rest and never returned to the browser.')).toBeNull() @@ -54,6 +54,26 @@ describe('ProvidersTab', () => { expect(screen.queryByLabelText('API key')).toBeNull() }) + it('shows MiniMax with the documented regional protocol endpoints and a required API key', async () => { + mockEmptyCredentials() + + render( {}} />) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined()) + + fireEvent.click(screen.getByRole('button', { name: 'MiniMax M3.1 Flash / M3' })) + + expect(screen.getByRole('heading', { name: 'Connect MiniMax' })).toBeDefined() + const endpoint = screen.getByRole('combobox', { name: 'Base URL' }) as HTMLInputElement + expect(endpoint.value).toBe('Global - OpenAI-compatible') + fireEvent.click(endpoint) + fireEvent.click(screen.getByRole('option', { name: 'China - Anthropic-compatible' })) + expect(endpoint.value).toBe('China - Anthropic-compatible') + const nativeEndpoint = document.querySelector('select[aria-hidden="true"]') as HTMLSelectElement + expect(nativeEndpoint.value).toBe('https://api.minimaxi.com/anthropic') + expect((screen.getByLabelText('API key') as HTMLInputElement).required).toBe(true) + expect(screen.queryByLabelText('Authentication')).toBeNull() + }) + it('opens configured credentials in the detail inspector', async () => { globalThis.fetch = mock(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() diff --git a/src/admin/ai/api.ts b/src/admin/ai/api.ts index 0eb0c165a..f6c715c2d 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -36,6 +36,7 @@ import { const ProviderId = Type.Union([ Type.Literal('anthropic'), Type.Literal('openai'), + Type.Literal('minimax'), Type.Literal('ollama'), Type.Literal('openrouter'), Type.Literal('openai-compatible'), @@ -86,6 +87,7 @@ const ModelSchema = Type.Object({ capabilities: Type.Object({ toolCalling: Type.Boolean(), visionInput: Type.Boolean(), + videoInput: Type.Optional(Type.Boolean()), toolResultImages: Type.Boolean(), promptCache: Type.Boolean(), streaming: Type.Boolean(), @@ -95,6 +97,8 @@ const ModelSchema = Type.Object({ pricing: Type.Optional(Type.Object({ inputPerMTok: Type.Number(), outputPerMTok: Type.Number(), + cacheReadPerMTok: Type.Optional(Type.Union([Type.Number(), Type.Null()])), + cacheWritePerMTok: Type.Optional(Type.Union([Type.Number(), Type.Null()])), })), /** Max context window (total tokens) — feeds the composer context meter. */ contextWindow: Type.Optional(Type.Number()), @@ -182,13 +186,13 @@ export async function listCredentials(signal?: AbortSignal): Promise { const key = `${providerId}\0${credentialId ?? ''}` diff --git a/src/admin/pages/ai/providerCatalog.ts b/src/admin/pages/ai/providerCatalog.ts index 9f4c92af2..e9973bb6a 100644 --- a/src/admin/pages/ai/providerCatalog.ts +++ b/src/admin/pages/ai/providerCatalog.ts @@ -1,4 +1,4 @@ -export type ProviderId = 'anthropic' | 'openai' | 'openrouter' | 'ollama' | 'openai-compatible' +export type ProviderId = 'anthropic' | 'openai' | 'minimax' | 'openrouter' | 'ollama' | 'openai-compatible' export type ProviderAuthMode = 'apiKey' | 'baseUrl' export interface ProviderSpec { @@ -8,6 +8,8 @@ export interface ProviderSpec { description: string authMode: ProviderAuthMode endpointLabel: string + requiresApiKey?: boolean + baseUrlOptions?: readonly { value: string; label: string }[] } export const PROVIDER_SPECS: ProviderSpec[] = [ @@ -27,6 +29,21 @@ export const PROVIDER_SPECS: ProviderSpec[] = [ authMode: 'apiKey', endpointLabel: 'api.openai.com', }, + { + id: 'minimax', + label: 'MiniMax', + shortLabel: 'M3.1 Flash / M3', + description: 'MiniMax text models with the documented API endpoint.', + authMode: 'baseUrl', + endpointLabel: 'Global / China endpoints', + requiresApiKey: true, + baseUrlOptions: [ + { value: 'https://api.minimax.io/v1', label: 'Global - OpenAI-compatible' }, + { value: 'https://api.minimax.io/anthropic', label: 'Global - Anthropic-compatible' }, + { value: 'https://api.minimaxi.com/v1', label: 'China - OpenAI-compatible' }, + { value: 'https://api.minimaxi.com/anthropic', label: 'China - Anthropic-compatible' }, + ], + }, { id: 'openrouter', label: 'OpenRouter', diff --git a/src/admin/pages/ai/tabs/AuditTab.tsx b/src/admin/pages/ai/tabs/AuditTab.tsx index 80630dcd3..aec0053de 100644 --- a/src/admin/pages/ai/tabs/AuditTab.tsx +++ b/src/admin/pages/ai/tabs/AuditTab.tsx @@ -114,6 +114,7 @@ export function AuditTab() { const PROVIDER_LABEL: Record = { anthropic: 'Anthropic', openai: 'OpenAI', + minimax: 'MiniMax', ollama: 'Ollama', unknown: 'Unknown (deleted credential)', } diff --git a/src/admin/pages/ai/tabs/ProvidersTab.tsx b/src/admin/pages/ai/tabs/ProvidersTab.tsx index 88de6ce72..6d5184e86 100644 --- a/src/admin/pages/ai/tabs/ProvidersTab.tsx +++ b/src/admin/pages/ai/tabs/ProvidersTab.tsx @@ -4,6 +4,7 @@ import { useAsyncResource } from '@admin/lib/useAsyncResource' import { Button } from '@ui/components/Button' import { Dialog } from '@ui/components/Dialog' import { Input } from '@ui/components/Input' +import { Select } from '@ui/components/Select' import { pushToast } from '@ui/components/Toast' import { ArrowRightIcon } from 'pixel-art-icons/icons/arrow-right' import { CheckIcon } from 'pixel-art-icons/icons/check' @@ -38,6 +39,7 @@ type Selection = const API_KEY_PLACEHOLDER: Partial> = { anthropic: 'sk-ant-...', openai: 'sk-...', + minimax: 'sk-...', openrouter: 'sk-or-...', 'openai-compatible': 'sk-... (optional)', } @@ -495,7 +497,7 @@ function ProviderSetupPanel({

Instatic validates and encrypts the credential before storing it.

- + @@ -515,8 +517,9 @@ function AddCredentialForm({ const baseUrlInputId = useId() const [displayLabel, setDisplayLabel] = useState('') const [apiKey, setApiKey] = useState('') - const [baseUrl, setBaseUrl] = useState('') + const [baseUrl, setBaseUrl] = useState(provider.baseUrlOptions?.[0]?.value ?? '') const [busy, setBusy] = useState(false) + const apiKeyRequired = provider.authMode === 'apiKey' || provider.requiresApiKey === true const baseUrlPlaceholder = provider.id === 'ollama' ? 'http://localhost:11434' : 'https://api.example.com/v1' @@ -573,14 +576,24 @@ function AddCredentialForm({
- setBaseUrl(event.currentTarget.value)} - placeholder={baseUrlPlaceholder} - required - /> -

The root URL of the compatible API.

+ {provider.baseUrlOptions ? ( + setBaseUrl(event.currentTarget.value)} + placeholder={baseUrlPlaceholder} + required + /> + )} +

{provider.baseUrlOptions ? 'Select the regional API and wire protocol.' : 'The root URL of the compatible API.'}

)} @@ -590,7 +603,7 @@ function AddCredentialForm({ {provider.authMode === 'apiKey' ? 'API key' : provider.id === 'ollama' ? 'Bearer token' : 'API key'} - {provider.authMode === 'baseUrl' && Optional} + {provider.authMode === 'baseUrl' && !apiKeyRequired && Optional}
-

{provider.authMode === 'apiKey' ? 'Stored encrypted and never displayed again.' : 'Leave blank when the endpoint does not require authentication.'}

+

{apiKeyRequired ? 'Stored encrypted and never displayed again.' : 'Leave blank when the endpoint does not require authentication.'}