From 47408570de9532332817a132d6f2615df78f1157 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:23:42 +0800 Subject: [PATCH 1/6] feat(ai): add MiniMax provider support --- server/ai/drivers/http/chatCompletions.ts | 6 +- server/ai/drivers/index.ts | 3 +- server/ai/drivers/minimax.test.ts | 53 ++++++++++ server/ai/drivers/minimax.ts | 115 ++++++++++++++++++++++ server/ai/handlers/credentials.ts | 1 + server/ai/handlers/models.ts | 4 +- server/ai/runtime/types.ts | 3 +- src/__tests__/ai/providersTab.test.tsx | 17 +++- src/admin/ai/api.ts | 7 +- src/admin/pages/ai/providerCatalog.ts | 10 +- src/admin/pages/ai/tabs/AuditTab.tsx | 1 + src/admin/pages/ai/tabs/ProvidersTab.tsx | 3 + 12 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 server/ai/drivers/minimax.test.ts create mode 100644 server/ai/drivers/minimax.ts diff --git a/server/ai/drivers/http/chatCompletions.ts b/server/ai/drivers/http/chatCompletions.ts index f668c2d5f..b137cec7b 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' // --------------------------------------------------------------------------- @@ -329,8 +330,9 @@ export function makeChatCompletionsAdapter(opts: { baseUrl: string apiKey: string | null label: string + requestBodyExtras?: (req: AiStreamRequest) => Record }): ProviderAdapter { - const { baseUrl, apiKey, label } = opts + const { baseUrl, apiKey, label, requestBodyExtras } = opts return { label, endpoint: `${normalizeOpenAiBaseUrl(baseUrl)}/v1/chat/completions`, @@ -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 { 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..35060e08f --- /dev/null +++ b/server/ai/drivers/minimax.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { minimaxDriver } from './minimax' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function creds(baseUrl: string | null) { + return { id: 'c1', providerId: 'minimax', authMode: 'baseUrl', apiKey: 'sk-test', baseUrl } +} + +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) => { + const url = typeof input === 'string' ? input : input.toString() + expect(url).toBe('https://api.minimax.io/v1/models') + 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, promptCache: false, streaming: true }, + contextWindow: 1000000, + }) + }) + + it('returns [] when no base URL is configured', async () => { + expect(await minimaxDriver.listModels(creds(null))).toEqual([]) + }) + + it('reports the MiniMax M3 vision capability without enabling prompt cache', () => { + expect(minimaxDriver.capabilities('MiniMax-M3')).toMatchObject({ + toolCalling: true, + visionInput: true, + toolResultImages: false, + promptCache: false, + streaming: true, + }) + }) +}) diff --git a/server/ai/drivers/minimax.ts b/server/ai/drivers/minimax.ts new file mode 100644 index 000000000..0f52153d3 --- /dev/null +++ b/server/ai/drivers/minimax.ts @@ -0,0 +1,115 @@ +/** + * 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 type { + AiAuthMode, + AiProviderId, + AiStreamEvent, +} from '../runtime/types' +import type { + AiProvider, + AiProviderCapabilities, + AiProviderModel, + AiResolvedCredential, + AiStreamRequest, +} from './types' +import { runToolLoop } from './http/toolLoop' +import { makeChatCompletionsAdapter } from './http/chatCompletions' +import { openaiCompatibleDriver } from './openaiCompatible' + +const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['baseUrl'] + +const MINIMAX_MODELS: AiProviderModel[] = [ + { + id: 'MiniMax-M3', + label: 'MiniMax M3', + capabilities: { + toolCalling: true, + visionInput: true, + toolResultImages: false, + promptCache: false, + streaming: true, + }, + pricing: { inputPerMTok: 0.6, outputPerMTok: 2.4 }, + contextWindow: 1_000_000, + catalogueSource: 'live', + }, + { + id: 'MiniMax-M2.7', + label: 'MiniMax M2.7', + capabilities: { + toolCalling: true, + visionInput: false, + toolResultImages: false, + promptCache: false, + streaming: true, + }, + pricing: { inputPerMTok: 0.3, outputPerMTok: 1.2 }, + contextWindow: 204_800, + catalogueSource: 'live', + }, +] + +const DEFAULT_CAPABILITIES: AiProviderCapabilities = { + toolCalling: true, + visionInput: false, + toolResultImages: false, + promptCache: false, + streaming: true, +} + +function staticCapabilities(modelId: string): AiProviderCapabilities { + if (modelId === 'MiniMax-M3') { + return { ...DEFAULT_CAPABILITIES, visionInput: true } + } + return { ...DEFAULT_CAPABILITIES } +} + +function minimaxAdapter(baseUrl: string, apiKey: string | null) { + return makeChatCompletionsAdapter({ + baseUrl, + apiKey, + label: 'MiniMax', + requestBodyExtras() { + return { + reasoning_split: true, + thinking: { type: 'adaptive' }, + } + }, + }) +} + +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) return [] + const liveModels = await openaiCompatibleDriver.listModels(creds, signal) + const liveIds = new Set(liveModels.map((model) => model.id)) + const models = MINIMAX_MODELS.filter((model) => liveIds.has(model.id)) + return models.length > 0 ? models : [] + }, + + async *stream(req: AiStreamRequest): AsyncIterable { + if (req.credentials.authMode !== 'baseUrl' || !req.credentials.baseUrl) { + yield { + type: 'error', + message: + 'MiniMax requires a base URL. Add a base-URL credential in /admin/ai/providers and pick it for the site default.', + } + return + } + yield* runToolLoop(minimaxAdapter(req.credentials.baseUrl, req.credentials.apiKey), req) + }, +} diff --git a/server/ai/handlers/credentials.ts b/server/ai/handlers/credentials.ts index be264ca6d..e69d75912 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'), 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/providersTab.test.tsx b/src/__tests__/ai/providersTab.test.tsx index b95eee102..9e7702116 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,21 @@ describe('ProvidersTab', () => { expect(screen.queryByLabelText('API key')).toBeNull() }) + it('shows MiniMax as a base-url provider with the documented endpoint placeholder', async () => { + mockEmptyCredentials() + + render( {}} />) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined()) + + fireEvent.click(screen.getByRole('button', { name: 'MiniMax M3 / M2.7' })) + + expect(screen.getByRole('heading', { name: 'Connect MiniMax' })).toBeDefined() + expect(screen.getByLabelText('Base URL')).toBeDefined() + expect(screen.getByLabelText('Base URL').getAttribute('placeholder')).toBe('https://api.minimax.io/v1') + expect(screen.getByLabelText(/API key/)).toBeDefined() + 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..678505aa2 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'), @@ -182,13 +183,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..529deabb6 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 { @@ -27,6 +27,14 @@ export const PROVIDER_SPECS: ProviderSpec[] = [ authMode: 'apiKey', endpointLabel: 'api.openai.com', }, + { + id: 'minimax', + label: 'MiniMax', + shortLabel: 'M3 / M2.7', + description: 'MiniMax text models with the documented API endpoint.', + authMode: 'baseUrl', + endpointLabel: 'api.minimax.io/v1', + }, { 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..10af30f38 100644 --- a/src/admin/pages/ai/tabs/ProvidersTab.tsx +++ b/src/admin/pages/ai/tabs/ProvidersTab.tsx @@ -38,6 +38,7 @@ type Selection = const API_KEY_PLACEHOLDER: Partial> = { anthropic: 'sk-ant-...', openai: 'sk-...', + minimax: 'sk-... (optional)', openrouter: 'sk-or-...', 'openai-compatible': 'sk-... (optional)', } @@ -519,6 +520,8 @@ function AddCredentialForm({ const [busy, setBusy] = useState(false) const baseUrlPlaceholder = provider.id === 'ollama' ? 'http://localhost:11434' + : provider.id === 'minimax' + ? 'https://api.minimax.io/v1' : 'https://api.example.com/v1' async function handleSubmit(event: React.FormEvent) { From f9fe50bda3a39b4b03116fef2af6dc14fc0c7813 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:13:37 +0800 Subject: [PATCH 2/6] feat(ai): expose MiniMax video input capability --- server/ai/drivers/minimax.test.ts | 15 +++++++++++++-- server/ai/drivers/minimax.ts | 5 ++++- server/ai/drivers/modelCapabilities.ts | 6 +++--- server/ai/drivers/types.ts | 2 ++ src/__tests__/ai/modelCapabilities.test.ts | 2 ++ src/admin/ai/api.ts | 1 + 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/server/ai/drivers/minimax.test.ts b/server/ai/drivers/minimax.test.ts index 35060e08f..47a313b13 100644 --- a/server/ai/drivers/minimax.test.ts +++ b/server/ai/drivers/minimax.test.ts @@ -32,7 +32,13 @@ describe('minimax driver', () => { expect(models.map((model) => model.id)).toEqual(['MiniMax-M3', 'MiniMax-M2.7']) expect(models[0]).toMatchObject({ label: 'MiniMax M3', - capabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true }, + capabilities: { + toolCalling: true, + visionInput: true, + videoInput: true, + promptCache: false, + streaming: true, + }, contextWindow: 1000000, }) }) @@ -41,13 +47,18 @@ describe('minimax driver', () => { expect(await minimaxDriver.listModels(creds(null))).toEqual([]) }) - it('reports the MiniMax M3 vision capability without enabling prompt cache', () => { + 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, + }) }) }) diff --git a/server/ai/drivers/minimax.ts b/server/ai/drivers/minimax.ts index 0f52153d3..257180c08 100644 --- a/server/ai/drivers/minimax.ts +++ b/server/ai/drivers/minimax.ts @@ -31,6 +31,7 @@ const MINIMAX_MODELS: AiProviderModel[] = [ capabilities: { toolCalling: true, visionInput: true, + videoInput: true, toolResultImages: false, promptCache: false, streaming: true, @@ -45,6 +46,7 @@ const MINIMAX_MODELS: AiProviderModel[] = [ capabilities: { toolCalling: true, visionInput: false, + videoInput: false, toolResultImages: false, promptCache: false, streaming: true, @@ -58,6 +60,7 @@ const MINIMAX_MODELS: AiProviderModel[] = [ const DEFAULT_CAPABILITIES: AiProviderCapabilities = { toolCalling: true, visionInput: false, + videoInput: false, toolResultImages: false, promptCache: false, streaming: true, @@ -65,7 +68,7 @@ const DEFAULT_CAPABILITIES: AiProviderCapabilities = { function staticCapabilities(modelId: string): AiProviderCapabilities { if (modelId === 'MiniMax-M3') { - return { ...DEFAULT_CAPABILITIES, visionInput: true } + return { ...DEFAULT_CAPABILITIES, visionInput: true, videoInput: true } } return { ...DEFAULT_CAPABILITIES } } 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/types.ts b/server/ai/drivers/types.ts index 0561b6d11..d6cc2d37a 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. */ 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/admin/ai/api.ts b/src/admin/ai/api.ts index 678505aa2..af16636c6 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -87,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(), From 90f30dceb6ee5085d1891c31162112aa03b38917 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:02:04 +0800 Subject: [PATCH 3/6] feat(ai): add MiniMax regional protocol endpoints --- docs/features/agent.md | 3 + server/ai/drivers/anthropic.ts | 91 +++++++----- server/ai/drivers/minimax.test.ts | 154 +++++++++++++++++++- server/ai/drivers/minimax.ts | 111 ++++++++++++-- server/ai/drivers/types.ts | 2 + server/ai/handlers/credentials.ts | 6 + src/__tests__/ai/credentialsHandler.test.ts | 19 +++ src/__tests__/ai/providersTab.test.tsx | 13 +- src/admin/ai/api.ts | 2 + src/admin/pages/ai/providerCatalog.ts | 11 +- src/admin/pages/ai/tabs/ProvidersTab.tsx | 42 ++++-- 11 files changed, 378 insertions(+), 76 deletions(-) diff --git a/docs/features/agent.md b/docs/features/agent.md index cc3c90be2..aed55a653 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 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. --- diff --git a/server/ai/drivers/anthropic.ts b/server/ai/drivers/anthropic.ts index 57176251e..057205e74 100644 --- a/server/ai/drivers/anthropic.ts +++ b/server/ai/drivers/anthropic.ts @@ -216,10 +216,54 @@ export interface AnthropicMessage { // Adapter // --------------------------------------------------------------------------- -const anthropicAdapter: ProviderAdapter = { +export function makeAnthropicMessagesAdapter(opts: { + label: string + endpoint: string + buildHeaders: (req: AiStreamRequest) => Record +}): ProviderAdapter { + const { label, endpoint, buildHeaders } = 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, + })) + } + 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!, @@ -227,38 +271,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 @@ -476,6 +489,12 @@ interface MutableUsage { } export class AnthropicTurnTranslator implements TurnTranslator { + 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[] = [] @@ -557,8 +576,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.`, }] } diff --git a/server/ai/drivers/minimax.test.ts b/server/ai/drivers/minimax.test.ts index 47a313b13..acbf74a96 100644 --- a/server/ai/drivers/minimax.test.ts +++ b/server/ai/drivers/minimax.test.ts @@ -1,5 +1,7 @@ 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 @@ -7,8 +9,37 @@ afterEach(() => { globalThis.fetch = originalFetch }) -function creds(baseUrl: string | null) { - return { id: 'c1', providerId: 'minimax', authMode: 'baseUrl', apiKey: 'sk-test', baseUrl } +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', () => { @@ -17,9 +48,10 @@ describe('minimax driver', () => { }) it('returns the MiniMax model catalogue when the live endpoint is reachable', async () => { - globalThis.fetch = (async (input: RequestInfo | URL) => { + 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' }], }), { @@ -40,13 +72,49 @@ describe('minimax driver', () => { 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, @@ -61,4 +129,84 @@ describe('minimax driver', () => { 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' }]) + }) }) diff --git a/server/ai/drivers/minimax.ts b/server/ai/drivers/minimax.ts index 257180c08..c245681f7 100644 --- a/server/ai/drivers/minimax.ts +++ b/server/ai/drivers/minimax.ts @@ -6,6 +6,8 @@ * catalogue and request fields. */ +import { isAbortError } from '@core/http' +import { Type, parseValue } from '@core/utils/typeboxHelpers' import type { AiAuthMode, AiProviderId, @@ -19,8 +21,8 @@ import type { AiStreamRequest, } from './types' import { runToolLoop } from './http/toolLoop' -import { makeChatCompletionsAdapter } from './http/chatCompletions' -import { openaiCompatibleDriver } from './openaiCompatible' +import { makeAnthropicMessagesAdapter } from './anthropic' +import { makeChatCompletionsAdapter, normalizeOpenAiBaseUrl, trimSlash } from './http/chatCompletions' const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['baseUrl'] @@ -36,7 +38,12 @@ const MINIMAX_MODELS: AiProviderModel[] = [ promptCache: false, streaming: true, }, - pricing: { inputPerMTok: 0.6, outputPerMTok: 2.4 }, + pricing: { + inputPerMTok: 0.6, + outputPerMTok: 2.4, + cacheReadPerMTok: 0.12, + cacheWritePerMTok: null, + }, contextWindow: 1_000_000, catalogueSource: 'live', }, @@ -51,7 +58,12 @@ const MINIMAX_MODELS: AiProviderModel[] = [ promptCache: false, streaming: true, }, - pricing: { inputPerMTok: 0.3, outputPerMTok: 1.2 }, + pricing: { + inputPerMTok: 0.3, + outputPerMTok: 1.2, + cacheReadPerMTok: 0.06, + cacheWritePerMTok: 0.375, + }, contextWindow: 204_800, catalogueSource: 'live', }, @@ -73,20 +85,76 @@ function staticCapabilities(modelId: string): AiProviderCapabilities { return { ...DEFAULT_CAPABILITIES } } -function minimaxAdapter(baseUrl: string, apiKey: string | null) { +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', - requestBodyExtras() { + requestBodyExtras(req) { + return req.modelId === 'MiniMax-M3' + ? { reasoning_split: true, thinking: { type: 'adaptive' } } + : { reasoning_split: true } + }, + }) +} + +function minimaxAnthropicAdapter(baseUrl: string, apiKey: string) { + return makeAnthropicMessagesAdapter({ + label: 'MiniMax', + endpoint: `${normalizeAnthropicBaseUrl(baseUrl)}/v1/messages`, + buildHeaders() { return { - reasoning_split: true, - thinking: { type: 'adaptive' }, + 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', @@ -97,22 +165,33 @@ export const minimaxDriver: AiProvider = { }, async listModels(creds: AiResolvedCredential, signal?: AbortSignal) { - if (creds.authMode !== 'baseUrl' || !creds.baseUrl) return [] - const liveModels = await openaiCompatibleDriver.listModels(creds, signal) - const liveIds = new Set(liveModels.map((model) => model.id)) - const models = MINIMAX_MODELS.filter((model) => liveIds.has(model.id)) - return models.length > 0 ? models : [] + 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) { + if ( + req.credentials.authMode !== 'baseUrl' + || !req.credentials.baseUrl + || !req.credentials.apiKey + ) { yield { type: 'error', message: - 'MiniMax requires a base URL. Add a base-URL credential in /admin/ai/providers and pick it for the site default.', + 'MiniMax requires a base URL and API key. Add the credential in /admin/ai/providers and pick it for the site default.', } return } - yield* runToolLoop(minimaxAdapter(req.credentials.baseUrl, req.credentials.apiKey), req) + 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/types.ts b/server/ai/drivers/types.ts index d6cc2d37a..e0f90e20b 100644 --- a/server/ai/drivers/types.ts +++ b/server/ai/drivers/types.ts @@ -81,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 e69d75912..99f1712a6 100644 --- a/server/ai/handlers/credentials.ts +++ b/server/ai/handlers/credentials.ts @@ -110,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/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/providersTab.test.tsx b/src/__tests__/ai/providersTab.test.tsx index 9e7702116..df83f421d 100644 --- a/src/__tests__/ai/providersTab.test.tsx +++ b/src/__tests__/ai/providersTab.test.tsx @@ -54,7 +54,7 @@ describe('ProvidersTab', () => { expect(screen.queryByLabelText('API key')).toBeNull() }) - it('shows MiniMax as a base-url provider with the documented endpoint placeholder', async () => { + it('shows MiniMax with the documented regional protocol endpoints and a required API key', async () => { mockEmptyCredentials() render( {}} />) @@ -63,9 +63,14 @@ describe('ProvidersTab', () => { fireEvent.click(screen.getByRole('button', { name: 'MiniMax M3 / M2.7' })) expect(screen.getByRole('heading', { name: 'Connect MiniMax' })).toBeDefined() - expect(screen.getByLabelText('Base URL')).toBeDefined() - expect(screen.getByLabelText('Base URL').getAttribute('placeholder')).toBe('https://api.minimax.io/v1') - expect(screen.getByLabelText(/API key/)).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() }) diff --git a/src/admin/ai/api.ts b/src/admin/ai/api.ts index af16636c6..f6c715c2d 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -97,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()), diff --git a/src/admin/pages/ai/providerCatalog.ts b/src/admin/pages/ai/providerCatalog.ts index 529deabb6..7a8c4fd43 100644 --- a/src/admin/pages/ai/providerCatalog.ts +++ b/src/admin/pages/ai/providerCatalog.ts @@ -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[] = [ @@ -33,7 +35,14 @@ export const PROVIDER_SPECS: ProviderSpec[] = [ shortLabel: 'M3 / M2.7', description: 'MiniMax text models with the documented API endpoint.', authMode: 'baseUrl', - endpointLabel: 'api.minimax.io/v1', + 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', diff --git a/src/admin/pages/ai/tabs/ProvidersTab.tsx b/src/admin/pages/ai/tabs/ProvidersTab.tsx index 10af30f38..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,7 +39,7 @@ type Selection = const API_KEY_PLACEHOLDER: Partial> = { anthropic: 'sk-ant-...', openai: 'sk-...', - minimax: 'sk-... (optional)', + minimax: 'sk-...', openrouter: 'sk-or-...', 'openai-compatible': 'sk-... (optional)', } @@ -496,7 +497,7 @@ function ProviderSetupPanel({

Instatic validates and encrypts the credential before storing it.

- + @@ -516,12 +517,11 @@ 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' - : provider.id === 'minimax' - ? 'https://api.minimax.io/v1' : 'https://api.example.com/v1' async function handleSubmit(event: React.FormEvent) { @@ -576,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.'}

)} @@ -593,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.'}

From 095712739529e86dbb6ac55a4702930b4d880f68 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Tue, 20 Oct 2026 18:33:13 +0800 Subject: [PATCH 4/6] feat(ai): prefer MiniMax Flash and preserve tool reasoning --- docs/features/agent.md | 4 +- server/ai/drivers/anthropic.ts | 39 ++- server/ai/drivers/http/chatCompletions.ts | 22 +- server/ai/drivers/minimax.test.ts | 316 ++++++++++------------ server/ai/drivers/minimax.ts | 33 +-- server/ai/drivers/openrouter.ts | 12 +- src/__tests__/ai/providersTab.test.tsx | 2 +- src/admin/pages/ai/providerCatalog.ts | 2 +- 8 files changed, 227 insertions(+), 203 deletions(-) diff --git a/docs/features/agent.md b/docs/features/agent.md index aed55a653..8afc3dafa 100644 --- a/docs/features/agent.md +++ b/docs/features/agent.md @@ -185,14 +185,14 @@ 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 | +| `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. +**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 057205e74..4794229a4 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 @@ -220,8 +226,9 @@ export function makeAnthropicMessagesAdapter(opts: { label: string endpoint: string buildHeaders: (req: AiStreamRequest) => Record + preserveReasoning?: boolean }): ProviderAdapter { - const { label, endpoint, buildHeaders } = opts + const { label, endpoint, buildHeaders, preserveReasoning } = opts return { label, endpoint, @@ -256,7 +263,7 @@ export function makeAnthropicMessagesAdapter(opts: { }, createTurnTranslator() { - return new AnthropicTurnTranslator(label) + return new AnthropicTurnTranslator(label, preserveReasoning) }, } } @@ -445,6 +452,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 }, ), @@ -454,6 +463,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()])), }, @@ -490,15 +501,18 @@ interface MutableUsage { export class AnthropicTurnTranslator implements TurnTranslator { private readonly label: string + private readonly preserveReasoning: boolean - constructor(label = 'Anthropic') { + 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 = {} @@ -529,6 +543,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, '') @@ -539,6 +560,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 }] @@ -590,6 +618,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 b137cec7b..e571d69c7 100644 --- a/server/ai/drivers/http/chatCompletions.ts +++ b/server/ai/drivers/http/chatCompletions.ts @@ -45,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 @@ -193,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 }, @@ -225,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() @@ -255,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 }) @@ -313,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, } } @@ -331,8 +344,9 @@ export function makeChatCompletionsAdapter(opts: { apiKey: string | null label: string requestBodyExtras?: (req: AiStreamRequest) => Record + preserveReasoning?: boolean }): ProviderAdapter { - const { baseUrl, apiKey, label, requestBodyExtras } = opts + const { baseUrl, apiKey, label, requestBodyExtras, preserveReasoning } = opts return { label, endpoint: `${normalizeOpenAiBaseUrl(baseUrl)}/v1/chat/completions`, @@ -369,7 +383,7 @@ export function makeChatCompletionsAdapter(opts: { })) }, createTurnTranslator() { - return new ChatCompletionsTurnTranslator() + return new ChatCompletionsTurnTranslator(preserveReasoning) }, } } diff --git a/server/ai/drivers/minimax.test.ts b/server/ai/drivers/minimax.test.ts index acbf74a96..497c80830 100644 --- a/server/ai/drivers/minimax.test.ts +++ b/server/ai/drivers/minimax.test.ts @@ -1,212 +1,178 @@ import { afterEach, describe, expect, it } from 'bun:test' -import type { AiBrowserBridge, AiStreamEvent } from '../runtime/types' +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 }) -afterEach(() => { - globalThis.fetch = originalFetch -}) - -function creds(baseUrl: string | null, apiKey: string | null = 'sk-test'): AiResolvedCredential { +function creds(baseUrl: string | null, apiKey: string | null = 'test-key'): 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 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'): AiStreamRequest { - const bridge: AiBrowserBridge = { - async callBrowser() { - return { ok: true } - }, - } +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, + tools: [], modelId, modelCapabilities: minimaxDriver.capabilities(modelId), credentials: creds(baseUrl), signal: new AbortController().signal, - bridge, + bridge: { async callBrowser() { return { ok: true } } }, toolContextBase: { - db: {} as never, - userId: 'u1', - scope: 'site', - conversationId: 'c1', - snapshot: {}, + db: {} as never, userId: 'u1', capabilities: [], 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 +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 models = await minimaxDriver.listModels(creds('https://api.minimaxi.com/anthropic')) - expect(models.map((model) => model.id)).toEqual(['MiniMax-M3']) - }) +const endpoints = [ + 'https://api.minimax.io/v1', + 'https://api.minimaxi.com/v1', + 'https://api.minimax.io/anthropic', + 'https://api.minimaxi.com/anthropic', +] - it('returns [] when no base URL is configured', async () => { +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([]) }) - 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, + 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 }, + }) }) - }) - 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' }, + 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) + 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) }) - 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 + 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') + }) + } - 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('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) }) +}) - 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' }]) +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 index c245681f7..ed8ab974c 100644 --- a/server/ai/drivers/minimax.ts +++ b/server/ai/drivers/minimax.ts @@ -28,8 +28,8 @@ const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['baseUrl'] const MINIMAX_MODELS: AiProviderModel[] = [ { - id: 'MiniMax-M3', - label: 'MiniMax M3', + id: 'MiniMax-M3.1-Flash', + label: 'MiniMax M3.1 Flash', capabilities: { toolCalling: true, visionInput: true, @@ -39,21 +39,21 @@ const MINIMAX_MODELS: AiProviderModel[] = [ streaming: true, }, pricing: { - inputPerMTok: 0.6, - outputPerMTok: 2.4, - cacheReadPerMTok: 0.12, + inputPerMTok: 0.15, + outputPerMTok: 0.6, + cacheReadPerMTok: 0.015, cacheWritePerMTok: null, }, contextWindow: 1_000_000, catalogueSource: 'live', }, { - id: 'MiniMax-M2.7', - label: 'MiniMax M2.7', + id: 'MiniMax-M3', + label: 'MiniMax M3', capabilities: { toolCalling: true, - visionInput: false, - videoInput: false, + visionInput: true, + videoInput: true, toolResultImages: false, promptCache: false, streaming: true, @@ -62,9 +62,9 @@ const MINIMAX_MODELS: AiProviderModel[] = [ inputPerMTok: 0.3, outputPerMTok: 1.2, cacheReadPerMTok: 0.06, - cacheWritePerMTok: 0.375, + cacheWritePerMTok: null, }, - contextWindow: 204_800, + contextWindow: 1_000_000, catalogueSource: 'live', }, ] @@ -79,7 +79,7 @@ const DEFAULT_CAPABILITIES: AiProviderCapabilities = { } function staticCapabilities(modelId: string): AiProviderCapabilities { - if (modelId === 'MiniMax-M3') { + if (modelId === 'MiniMax-M3.1-Flash' || modelId === 'MiniMax-M3') { return { ...DEFAULT_CAPABILITIES, visionInput: true, videoInput: true } } return { ...DEFAULT_CAPABILITIES } @@ -102,10 +102,10 @@ function minimaxOpenAiAdapter(baseUrl: string, apiKey: string) { baseUrl, apiKey, label: 'MiniMax', - requestBodyExtras(req) { - return req.modelId === 'MiniMax-M3' - ? { reasoning_split: true, thinking: { type: 'adaptive' } } - : { reasoning_split: true } + preserveReasoning: true, + requestBodyExtras() { + // Use the API defaults: adaptive thinking, and max effort for Flash. + return { reasoning_split: true } }, }) } @@ -113,6 +113,7 @@ function minimaxOpenAiAdapter(baseUrl: string, apiKey: string) { function minimaxAnthropicAdapter(baseUrl: string, apiKey: string) { return makeAnthropicMessagesAdapter({ label: 'MiniMax', + preserveReasoning: true, endpoint: `${normalizeAnthropicBaseUrl(baseUrl)}/v1/messages`, buildHeaders() { return { 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/src/__tests__/ai/providersTab.test.tsx b/src/__tests__/ai/providersTab.test.tsx index df83f421d..3255dfe51 100644 --- a/src/__tests__/ai/providersTab.test.tsx +++ b/src/__tests__/ai/providersTab.test.tsx @@ -60,7 +60,7 @@ describe('ProvidersTab', () => { render( {}} />) await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined()) - fireEvent.click(screen.getByRole('button', { name: 'MiniMax M3 / M2.7' })) + 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 diff --git a/src/admin/pages/ai/providerCatalog.ts b/src/admin/pages/ai/providerCatalog.ts index 7a8c4fd43..e9973bb6a 100644 --- a/src/admin/pages/ai/providerCatalog.ts +++ b/src/admin/pages/ai/providerCatalog.ts @@ -32,7 +32,7 @@ export const PROVIDER_SPECS: ProviderSpec[] = [ { id: 'minimax', label: 'MiniMax', - shortLabel: 'M3 / M2.7', + shortLabel: 'M3.1 Flash / M3', description: 'MiniMax text models with the documented API endpoint.', authMode: 'baseUrl', endpointLabel: 'Global / China endpoints', From dbda218b8baaa36e5422127cd055b49b286e3c65 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Tue, 20 Oct 2026 18:37:16 +0800 Subject: [PATCH 5/6] test(ai): cover discovered MiniMax default at HTTP boundary --- server/ai/drivers/minimax.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/ai/drivers/minimax.test.ts b/server/ai/drivers/minimax.test.ts index 497c80830..b84c5a103 100644 --- a/server/ai/drivers/minimax.test.ts +++ b/server/ai/drivers/minimax.test.ts @@ -147,6 +147,21 @@ describe('minimax driver', () => { }) } + 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']) From dab973fb4c4a8408f6cf6895f915ec91099068d9 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:41:06 +0800 Subject: [PATCH 6/6] fix(ai): honor MiniMax M3 thinking defaults --- server/ai/drivers/anthropic.ts | 5 ++++- server/ai/drivers/minimax.test.ts | 6 +++++- server/ai/drivers/minimax.ts | 17 ++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/server/ai/drivers/anthropic.ts b/server/ai/drivers/anthropic.ts index 4794229a4..ef62ed91e 100644 --- a/server/ai/drivers/anthropic.ts +++ b/server/ai/drivers/anthropic.ts @@ -226,9 +226,10 @@ export function makeAnthropicMessagesAdapter(opts: { label: string endpoint: string buildHeaders: (req: AiStreamRequest) => Record + requestBodyExtras?: (req: AiStreamRequest) => Record preserveReasoning?: boolean }): ProviderAdapter { - const { label, endpoint, buildHeaders, preserveReasoning } = opts + const { label, endpoint, buildHeaders, requestBodyExtras, preserveReasoning } = opts return { label, endpoint, @@ -255,6 +256,8 @@ export function makeAnthropicMessagesAdapter(opts: { input_schema: t.inputSchema, })) } + const extra = requestBodyExtras?.(req) + if (extra) Object.assign(body, extra) return body }, diff --git a/server/ai/drivers/minimax.test.ts b/server/ai/drivers/minimax.test.ts index b84c5a103..570f61a7a 100644 --- a/server/ai/drivers/minimax.test.ts +++ b/server/ai/drivers/minimax.test.ts @@ -96,7 +96,11 @@ describe('minimax driver', () => { expect(calledUrl).toBe(`${baseUrl}${anthropic ? '/v1/messages' : '/chat/completions'}`) expect(body.model).toBe(modelId) expect(body.stream).toBe(true) - expect(body.thinking).toBeUndefined() + 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() diff --git a/server/ai/drivers/minimax.ts b/server/ai/drivers/minimax.ts index ed8ab974c..cdadd47b1 100644 --- a/server/ai/drivers/minimax.ts +++ b/server/ai/drivers/minimax.ts @@ -103,9 +103,15 @@ function minimaxOpenAiAdapter(baseUrl: string, apiKey: string) { apiKey, label: 'MiniMax', preserveReasoning: true, - requestBodyExtras() { - // Use the API defaults: adaptive thinking, and max effort for Flash. - return { reasoning_split: 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' } } : {}), + } }, }) } @@ -114,6 +120,11 @@ 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 {