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] 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) {