From 2f2dcd887fe473e658a9182bf9862e9414084b4e Mon Sep 17 00:00:00 2001 From: octo-patch Date: Mon, 20 Apr 2026 16:54:16 +0800 Subject: [PATCH] feat: add MiniMax provider support - Add MiniMax as an Anthropic-compatible API provider (minimax type) - Add MINIMAX_API_KEY environment variable support with MINIMAX_BASE_URL override - Add MiniMax chat model configs: MiniMax-M2.7 and MiniMax-M2.7-highspeed - Register minimax provider in settings modelType enum and provider detection - Create dedicated minimax query adapter using Anthropic SDK with MiniMax base URL - Add unit tests for MiniMax client and API constraints API docs: https://platform.minimax.io/docs/api-reference/text-anthropic-api --- src/services/api/claude.ts | 6 + .../api/minimax/__tests__/client.test.ts | 90 +++++++ src/services/api/minimax/client.ts | 43 ++++ src/services/api/minimax/index.ts | 220 ++++++++++++++++++ src/utils/model/configs.ts | 35 +++ src/utils/model/providers.ts | 6 + src/utils/settings/types.ts | 6 +- 7 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 src/services/api/minimax/__tests__/client.test.ts create mode 100644 src/services/api/minimax/client.ts create mode 100644 src/services/api/minimax/index.ts diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 0643b8ea6e..368a0c58e1 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1360,6 +1360,12 @@ async function* queryModel( return } + if (getAPIProvider() === 'minimax') { + const { queryModelMiniMax } = await import('./minimax/index.js') + yield* queryModelMiniMax(messagesForAPI, systemPrompt, filteredTools, signal, options) + return + } + // Instrumentation: Track message count after normalization logEvent('tengu_api_after_normalize', { postNormalizedMessageCount: messagesForAPI.length, diff --git a/src/services/api/minimax/__tests__/client.test.ts b/src/services/api/minimax/__tests__/client.test.ts new file mode 100644 index 0000000000..c12b37df23 --- /dev/null +++ b/src/services/api/minimax/__tests__/client.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from 'bun:test' + +// Defensive: mock proxy module before importing +mock.module('src/utils/proxy.js', () => ({ + getProxyFetchOptions: () => ({} as any), +})) + +import { getMiniMaxClient, clearMiniMaxClientCache } from '../client.js' + +describe('getMiniMaxClient', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + clearMiniMaxClientCache() + process.env.MINIMAX_API_KEY = 'test-minimax-key' + delete process.env.MINIMAX_BASE_URL + }) + + afterEach(() => { + clearMiniMaxClientCache() + process.env = { ...originalEnv } + }) + + test('creates client with default base URL', () => { + const client = getMiniMaxClient() + expect(client).toBeDefined() + expect(client.baseURL).toBe('https://api.minimax.io/anthropic') + }) + + test('uses MINIMAX_BASE_URL when set', () => { + process.env.MINIMAX_BASE_URL = 'https://custom.minimax.api/anthropic' + clearMiniMaxClientCache() + const client = getMiniMaxClient() + expect(client.baseURL).toBe('https://custom.minimax.api/anthropic') + }) + + test('default base URL does not use api.minimax.chat', () => { + const client = getMiniMaxClient() + expect(client.baseURL).not.toContain('api.minimax.chat') + expect(client.baseURL).toContain('api.minimax.io') + }) + + test('returns cached client on second call', () => { + const client1 = getMiniMaxClient() + const client2 = getMiniMaxClient() + expect(client1).toBe(client2) + }) + + test('clearMiniMaxClientCache resets cache', () => { + const client1 = getMiniMaxClient() + clearMiniMaxClientCache() + process.env.MINIMAX_BASE_URL = 'https://other.minimax.api/anthropic' + const client2 = getMiniMaxClient() + expect(client1).not.toBe(client2) + }) +}) + +describe('MiniMax API constraints', () => { + test('default base URL uses overseas api.minimax.io (not api.minimax.chat)', () => { + const defaultBaseUrl = 'https://api.minimax.io/anthropic' + expect(defaultBaseUrl).toContain('api.minimax.io') + expect(defaultBaseUrl).not.toContain('api.minimax.chat') + }) + + test('validates temperature range (0.0, 1.0] — 0 is invalid for MiniMax', () => { + const isValidTemperature = (t: number) => t > 0 && t <= 1.0 + expect(isValidTemperature(1.0)).toBe(true) + expect(isValidTemperature(0.5)).toBe(true) + expect(isValidTemperature(0.0)).toBe(false) + expect(isValidTemperature(1.1)).toBe(false) + }) + + test('filters unsupported parameters', () => { + const UNSUPPORTED_PARAMS = new Set(['top_k', 'stop_sequences', 'service_tier']) + const input: Record = { + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'hi' }], + top_k: 40, + stop_sequences: ['END'], + temperature: 1.0, + } + const filtered = Object.fromEntries( + Object.entries(input).filter(([k]) => !UNSUPPORTED_PARAMS.has(k)), + ) + expect('top_k' in filtered).toBe(false) + expect('stop_sequences' in filtered).toBe(false) + expect('temperature' in filtered).toBe(true) + expect('model' in filtered).toBe(true) + }) +}) diff --git a/src/services/api/minimax/client.ts b/src/services/api/minimax/client.ts new file mode 100644 index 0000000000..7731bf9807 --- /dev/null +++ b/src/services/api/minimax/client.ts @@ -0,0 +1,43 @@ +import Anthropic from '@anthropic-ai/sdk' +import { getProxyFetchOptions } from 'src/utils/proxy.js' + +/** + * Environment variables: + * + * MINIMAX_API_KEY: Required. API key for the MiniMax Anthropic-compatible endpoint. + * MINIMAX_BASE_URL: Optional. Defaults to https://api.minimax.io/anthropic. + */ + +const DEFAULT_BASE_URL = 'https://api.minimax.io/anthropic' + +let cachedClient: Anthropic | null = null + +export function getMiniMaxClient(options?: { + maxRetries?: number + fetchOverride?: typeof fetch +}): Anthropic { + if (cachedClient) return cachedClient + + const apiKey = process.env.MINIMAX_API_KEY || '' + const baseURL = process.env.MINIMAX_BASE_URL || DEFAULT_BASE_URL + + const client = new Anthropic({ + apiKey, + baseURL, + maxRetries: options?.maxRetries ?? 0, + timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10), + dangerouslyAllowBrowser: true, + fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }), + ...(options?.fetchOverride && { fetch: options.fetchOverride }), + }) + + if (!options?.fetchOverride) { + cachedClient = client + } + + return client +} + +export function clearMiniMaxClientCache(): void { + cachedClient = null +} diff --git a/src/services/api/minimax/index.ts b/src/services/api/minimax/index.ts new file mode 100644 index 0000000000..50ae1f5389 --- /dev/null +++ b/src/services/api/minimax/index.ts @@ -0,0 +1,220 @@ +import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { SystemPrompt } from '../../../utils/systemPromptType.js' +import type { Message, StreamEvent, SystemAPIErrorMessage, AssistantMessage } from '../../../types/message.js' +import type { Tools } from '../../../Tool.js' +import { getMiniMaxClient } from './client.js' +import { normalizeMessagesForAPI } from '../../../utils/messages.js' +import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js' +import { toolToAPISchema } from '../../../utils/api.js' +import { logForDebugging } from '../../../utils/debug.js' +import { addToTotalSessionCost } from '../../../cost-tracker.js' +import { calculateUSDCost } from '../../../utils/modelCost.js' +import { recordLLMObservation } from '../../../services/langfuse/tracing.js' +import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../../../services/langfuse/convert.js' +import type { Options } from '../claude.js' +import { randomUUID } from 'crypto' +import { + createAssistantAPIErrorMessage, + normalizeContentFromAPI, +} from '../../../utils/messages.js' + +// Parameters not supported by MiniMax's Anthropic-compatible API +const UNSUPPORTED_PARAMS = new Set([ + 'top_k', + 'stop_sequences', + 'service_tier', + 'mcp_servers', + 'context_management', + 'container', +]) + +/** + * MiniMax query path. MiniMax uses an Anthropic-compatible API, so we use + * the Anthropic SDK directly with a MiniMax base URL. + * + * Key constraints: + * - temperature must be in (0.0, 1.0] — 0.0 is invalid + * - Some Anthropic-specific parameters are unsupported + * - System message is accepted as a string + */ +export async function* queryModelMiniMax( + messages: Message[], + systemPrompt: SystemPrompt, + tools: Tools, + signal: AbortSignal, + options: Options, +): AsyncGenerator< + StreamEvent | AssistantMessage | SystemAPIErrorMessage, + void +> { + try { + const messagesForAPI = normalizeMessagesForAPI(messages, tools) + + const toolSchemas = await Promise.all( + tools.map(tool => + toolToAPISchema(tool, { + getToolPermissionContext: options.getToolPermissionContext, + tools, + agents: options.agents, + allowedAgentTypes: options.allowedAgentTypes, + model: options.model, + }), + ), + ) + + // Filter out unsupported tool types (computer use, etc.) + const standardTools = toolSchemas.filter( + (t): t is BetaToolUnion & { type: string } => { + const anyT = t as unknown as Record + return anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + }, + ) + + // Join system prompt blocks into a single string + const systemText = systemPrompt.filter(Boolean).join('\n\n') + + // MiniMax temperature constraint: must be in (0.0, 1.0], default 1.0 + const temperature = + options.temperatureOverride !== undefined && options.temperatureOverride > 0 + ? options.temperatureOverride + : 1.0 + + const client = getMiniMaxClient({ + maxRetries: 0, + fetchOverride: options.fetchOverride as typeof fetch | undefined, + }) + + logForDebugging( + `[MiniMax] Calling model=${options.model}, messages=${messagesForAPI.length}, tools=${standardTools.length}`, + ) + + const stream = client.messages.stream( + { + model: options.model, + messages: messagesForAPI as Parameters[0]['messages'], + ...(systemText && { system: systemText }), + ...(standardTools.length > 0 && { + tools: standardTools as Parameters[0]['tools'], + }), + max_tokens: options.maxTokens ?? 16000, + temperature, + stream: true, + }, + { signal }, + ) + + const contentBlocks: Record = {} + const collectedMessages: AssistantMessage[] = [] + let partialMessage: any = undefined + let usage = { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + } + let ttftMs = 0 + const start = Date.now() + + for await (const event of stream) { + switch (event.type) { + case 'message_start': { + partialMessage = (event as any).message + ttftMs = Date.now() - start + if ((event as any).message?.usage) { + usage = { ...usage, ...((event as any).message.usage) } + } + break + } + case 'content_block_start': { + const idx = (event as any).index + const cb = (event as any).content_block + if (cb.type === 'tool_use') { + contentBlocks[idx] = { ...cb, input: '' } + } else if (cb.type === 'text') { + contentBlocks[idx] = { ...cb, text: '' } + } else { + contentBlocks[idx] = { ...cb } + } + break + } + case 'content_block_delta': { + const idx = (event as any).index + const delta = (event as any).delta + const block = contentBlocks[idx] + if (!block) break + if (delta.type === 'text_delta') { + block.text = (block.text || '') + delta.text + } else if (delta.type === 'input_json_delta') { + block.input = (block.input || '') + delta.partial_json + } + break + } + case 'content_block_stop': { + const idx = (event as any).index + const block = contentBlocks[idx] + if (!block || !partialMessage) break + + const m: AssistantMessage = { + message: { + ...partialMessage, + content: normalizeContentFromAPI([block], tools, options.agentId), + }, + requestId: undefined, + type: 'assistant', + uuid: randomUUID(), + timestamp: new Date().toISOString(), + } + collectedMessages.push(m) + yield m + break + } + case 'message_delta': { + const deltaUsage = (event as any).usage + if (deltaUsage) { + usage = { ...usage, ...deltaUsage } + } + break + } + case 'message_stop': + break + } + + if (event.type === 'message_stop' && usage.input_tokens + usage.output_tokens > 0) { + const costUSD = calculateUSDCost(options.model, usage as any) + addToTotalSessionCost(costUSD, usage as any, options.model) + } + + yield { + type: 'stream_event', + event: event as any, + ...(event.type === 'message_start' ? { ttftMs } : undefined), + } as StreamEvent + } + + // Record LLM observation in Langfuse (no-op if not configured) + recordLLMObservation(options.langfuseTrace ?? null, { + model: options.model, + provider: 'minimax', + input: convertMessagesToLangfuse(messagesForAPI, systemPrompt), + output: convertOutputToLangfuse(collectedMessages), + usage: { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens, + cache_read_input_tokens: usage.cache_read_input_tokens, + }, + startTime: new Date(start), + endTime: new Date(), + completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined, + tools: convertToolsToLangfuse(toolSchemas as unknown[]), + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logForDebugging(`[MiniMax] Error: ${errorMessage}`, { level: 'error' }) + yield createAssistantAPIErrorMessage({ + content: `API Error: ${errorMessage}`, + apiError: 'api_error', + error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError, + }) + } +} diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index d3fac9b07d..031d5764fc 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -14,6 +14,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { openai: 'claude-3-7-sonnet-20250219', gemini: 'claude-3-7-sonnet-20250219', grok: 'claude-3-7-sonnet-20250219', + minimax: 'claude-3-7-sonnet-20250219', } as const satisfies ModelConfig export const CLAUDE_3_5_V2_SONNET_CONFIG = { @@ -24,6 +25,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { openai: 'claude-3-5-sonnet-20241022', gemini: 'claude-3-5-sonnet-20241022', grok: 'claude-3-5-sonnet-20241022', + minimax: 'claude-3-5-sonnet-20241022', } as const satisfies ModelConfig export const CLAUDE_3_5_HAIKU_CONFIG = { @@ -34,6 +36,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { openai: 'claude-3-5-haiku-20241022', gemini: 'claude-3-5-haiku-20241022', grok: 'claude-3-5-haiku-20241022', + minimax: 'claude-3-5-haiku-20241022', } as const satisfies ModelConfig export const CLAUDE_HAIKU_4_5_CONFIG = { @@ -44,6 +47,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { openai: 'claude-haiku-4-5-20251001', gemini: 'claude-haiku-4-5-20251001', grok: 'claude-haiku-4-5-20251001', + minimax: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_CONFIG = { @@ -54,6 +58,7 @@ export const CLAUDE_SONNET_4_CONFIG = { openai: 'claude-sonnet-4-20250514', gemini: 'claude-sonnet-4-20250514', grok: 'claude-sonnet-4-20250514', + minimax: 'claude-sonnet-4-20250514', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_5_CONFIG = { @@ -64,6 +69,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { openai: 'claude-sonnet-4-5-20250929', gemini: 'claude-sonnet-4-5-20250929', grok: 'claude-sonnet-4-5-20250929', + minimax: 'claude-sonnet-4-5-20250929', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_CONFIG = { @@ -74,6 +80,7 @@ export const CLAUDE_OPUS_4_CONFIG = { openai: 'claude-opus-4-20250514', gemini: 'claude-opus-4-20250514', grok: 'claude-opus-4-20250514', + minimax: 'claude-opus-4-20250514', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_1_CONFIG = { @@ -84,6 +91,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { openai: 'claude-opus-4-1-20250805', gemini: 'claude-opus-4-1-20250805', grok: 'claude-opus-4-1-20250805', + minimax: 'claude-opus-4-1-20250805', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_5_CONFIG = { @@ -94,6 +102,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { openai: 'claude-opus-4-5-20251101', gemini: 'claude-opus-4-5-20251101', grok: 'claude-opus-4-5-20251101', + minimax: 'claude-opus-4-5-20251101', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_6_CONFIG = { @@ -104,6 +113,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { openai: 'claude-opus-4-6', gemini: 'claude-opus-4-6', grok: 'claude-opus-4-6', + minimax: 'claude-opus-4-6', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_6_CONFIG = { @@ -114,6 +124,29 @@ export const CLAUDE_SONNET_4_6_CONFIG = { openai: 'claude-sonnet-4-6', gemini: 'claude-sonnet-4-6', grok: 'claude-sonnet-4-6', + minimax: 'claude-sonnet-4-6', +} as const satisfies ModelConfig + +export const MINIMAX_M2_7_CONFIG = { + firstParty: 'MiniMax-M2.7', + bedrock: 'MiniMax-M2.7', + vertex: 'MiniMax-M2.7', + foundry: 'MiniMax-M2.7', + openai: 'MiniMax-M2.7', + gemini: 'MiniMax-M2.7', + grok: 'MiniMax-M2.7', + minimax: 'MiniMax-M2.7', +} as const satisfies ModelConfig + +export const MINIMAX_M2_7_HIGHSPEED_CONFIG = { + firstParty: 'MiniMax-M2.7-highspeed', + bedrock: 'MiniMax-M2.7-highspeed', + vertex: 'MiniMax-M2.7-highspeed', + foundry: 'MiniMax-M2.7-highspeed', + openai: 'MiniMax-M2.7-highspeed', + gemini: 'MiniMax-M2.7-highspeed', + grok: 'MiniMax-M2.7-highspeed', + minimax: 'MiniMax-M2.7-highspeed', } as const satisfies ModelConfig // @[MODEL LAUNCH]: Register the new config here. @@ -129,6 +162,8 @@ export const ALL_MODEL_CONFIGS = { opus41: CLAUDE_OPUS_4_1_CONFIG, opus45: CLAUDE_OPUS_4_5_CONFIG, opus46: CLAUDE_OPUS_4_6_CONFIG, + minimaxM27: MINIMAX_M2_7_CONFIG, + minimaxM27hs: MINIMAX_M2_7_HIGHSPEED_CONFIG, } as const satisfies Record export type ModelKey = keyof typeof ALL_MODEL_CONFIGS diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 79572d42e8..9e0bbffe19 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -10,12 +10,14 @@ export type APIProvider = | 'openai' | 'gemini' | 'grok' + | 'minimax' export function getAPIProvider(): APIProvider { const modelType = getInitialSettings().modelType if (modelType === 'openai') return 'openai' if (modelType === 'gemini') return 'gemini' if (modelType === 'grok') return 'grok' + if (modelType === 'minimax') return 'minimax' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) return 'bedrock' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) return 'vertex' @@ -24,6 +26,7 @@ export function getAPIProvider(): APIProvider { if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)) return 'grok' + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_MINIMAX)) return 'minimax' return 'firstParty' } @@ -38,6 +41,9 @@ export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS * (or api-staging.anthropic.com for ant users). */ export function isFirstPartyAnthropicBaseUrl(): boolean { + if (getAPIProvider() === 'minimax') { + return false + } const baseUrl = process.env.ANTHROPIC_BASE_URL // TODO: 这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题 if (!baseUrl) { diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 127880c1ad..18cf83b3b9 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -373,11 +373,11 @@ export const SettingsSchema = lazySchema(() => .optional() .describe('Tool usage permissions configuration'), modelType: z - .enum(['anthropic', 'openai', 'gemini', 'grok']) + .enum(['anthropic', 'openai', 'gemini', 'grok', 'minimax']) .optional() .describe( - 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, and "grok" uses the xAI Grok API (OpenAI-compatible). ' + - 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP.', + 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, "grok" uses the xAI Grok API (OpenAI-compatible), and "minimax" uses the MiniMax Anthropic-compatible API. ' + + 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP. When set to "minimax", configure MINIMAX_API_KEY and optional MINIMAX_BASE_URL.', ), model: z .string()