diff --git a/src/main/agent/pi-model-resolution.ts b/src/main/agent/pi-model-resolution.ts index 00c7ed003..d19f263f3 100644 --- a/src/main/agent/pi-model-resolution.ts +++ b/src/main/agent/pi-model-resolution.ts @@ -8,6 +8,13 @@ const REASONING_MODEL_PATTERN = const DEEPSEEK_V4_MODEL_PATTERN = /(?:^|[/_-])deepseek[-_.]?v4(?:$|[-:_.])/i; type PiRegistryProvider = Parameters[0]; +interface KnownSyntheticModelMetadata { + reasoning: boolean; + input: Model['input']; + cost: Model['cost']; + contextWindow: number; +} + export interface PiModelStringInput { provider?: string; customProtocol?: string; @@ -120,6 +127,31 @@ const KNOWN_MODEL_SPECS: Record = { + 'minimax-m3': { + reasoning: true, + input: ['text', 'image'], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, + }, + contextWindow: 1000000, + }, + 'minimax-m2.7': { + reasoning: true, + input: ['text'], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 204800, + }, +}; + function lookupModelSpecs( modelId: string ): { contextWindow: number; maxTokens: number } | undefined { @@ -133,6 +165,10 @@ function lookupModelSpecs( return undefined; } +function lookupSyntheticModelMetadata(modelId: string): KnownSyntheticModelMetadata | undefined { + return KNOWN_SYNTHETIC_MODEL_METADATA[modelId.toLowerCase()]; +} + export function buildSyntheticPiModel( modelId: string, provider: string, @@ -144,7 +180,9 @@ export function buildSyntheticPiModel( maxTokens?: number ): Model { const api = apiOverride || inferPiApi(protocol); - const autoReasoning = reasoning ?? REASONING_MODEL_PATTERN.test(modelId); + const knownMetadata = lookupSyntheticModelMetadata(modelId); + const autoReasoning = + reasoning ?? knownMetadata?.reasoning ?? REASONING_MODEL_PATTERN.test(modelId); const knownSpecs = lookupModelSpecs(modelId); return { id: modelId, @@ -153,9 +191,10 @@ export function buildSyntheticPiModel( provider, baseUrl: baseUrl || '', reasoning: autoReasoning, - input: ['text', 'image'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: contextWindow ?? knownSpecs?.contextWindow ?? 128000, + input: knownMetadata?.input ?? ['text', 'image'], + cost: knownMetadata?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: + contextWindow ?? knownMetadata?.contextWindow ?? knownSpecs?.contextWindow ?? 128000, maxTokens: maxTokens ?? knownSpecs?.maxTokens ?? 16384, } as Model; } diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 5b9330a39..233a83224 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -270,8 +270,9 @@ "note": "Use Gemini-compatible mode and the exact model ID exposed by the endpoint." }, "minimax": { - "name": "MiniMax", - "note": "Use OpenAI-compatible mode unless your MiniMax gateway documents another protocol." + "globalName": "MiniMax (Global)", + "chinaName": "MiniMax (China)", + "note": "Choose the endpoint that matches your account region. Both compatible protocols are supported." }, "genericOpenAI": { "name": "Generic OpenAI-compatible", diff --git a/src/renderer/i18n/locales/zh.json b/src/renderer/i18n/locales/zh.json index 27f628fdd..161159796 100644 --- a/src/renderer/i18n/locales/zh.json +++ b/src/renderer/i18n/locales/zh.json @@ -270,8 +270,9 @@ "note": "推荐使用 Gemini 兼容协议,并填写该 endpoint 暴露的精确模型 ID。" }, "minimax": { - "name": "MiniMax", - "note": "除非网关文档明确说明其他协议,否则优先按 OpenAI 兼容协议配置。" + "globalName": "MiniMax (Global)", + "chinaName": "MiniMax (China)", + "note": "Choose the endpoint that matches your account region. Both compatible protocols are supported." }, "genericOpenAI": { "name": "通用 OpenAI 兼容服务", diff --git a/src/shared/api-model-presets.ts b/src/shared/api-model-presets.ts index 67cf2aaaf..af114e089 100644 --- a/src/shared/api-model-presets.ts +++ b/src/shared/api-model-presets.ts @@ -109,6 +109,8 @@ export const API_PROVIDER_PRESETS: SharedProviderPresets = { { id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, { id: 'kimi-k2-thinking', name: 'kimi-k2-thinking' }, { id: 'glm-5', name: 'glm-5' }, + { id: 'MiniMax-M3', name: 'MiniMax-M3' }, + { id: 'MiniMax-M2.7', name: 'MiniMax-M2.7' }, { id: 'MiniMax-M2.5', name: 'MiniMax-M2.5' }, { id: 'qwen-max', name: 'qwen-max' }, { id: 'grok-code-fast-1', name: 'grok-code-fast-1' }, diff --git a/src/shared/api-provider-guidance.ts b/src/shared/api-provider-guidance.ts index 2d9a74f60..2f21db8db 100644 --- a/src/shared/api-provider-guidance.ts +++ b/src/shared/api-provider-guidance.ts @@ -7,7 +7,10 @@ export type CommonProviderSetupId = | 'glm-anthropic' | 'ollama' | 'gemini-custom' - | 'minimax' + | 'minimax-global-openai' + | 'minimax-global-anthropic' + | 'minimax-cn-openai' + | 'minimax-cn-anthropic' | 'generic-openai'; export interface CommonProviderSetup { @@ -126,19 +129,57 @@ export const COMMON_PROVIDER_SETUPS: CommonProviderSetup[] = [ }, }, { - id: 'minimax', - nameKey: 'api.guidance.setups.minimax.name', + id: 'minimax-global-openai', + nameKey: 'api.guidance.setups.minimax.globalName', noteKey: 'api.guidance.setups.minimax.note', applyProvider: 'custom', recommendedProtocol: 'openai', - recommendedBaseUrl: 'https://api.minimax.chat/v1', - exampleModel: 'MiniMax-M2.5', + recommendedBaseUrl: 'https://api.minimax.io/v1', + exampleModel: 'MiniMax-M3', matcher: { - hosts: ['api.minimax.chat'], - hostContains: ['minimax'], + hosts: ['api.minimax.io'], pathPrefixes: ['/v1'], }, }, + { + id: 'minimax-global-anthropic', + nameKey: 'api.guidance.setups.minimax.globalName', + noteKey: 'api.guidance.setups.minimax.note', + applyProvider: 'custom', + recommendedProtocol: 'anthropic', + recommendedBaseUrl: 'https://api.minimax.io/anthropic', + exampleModel: 'MiniMax-M3', + matcher: { + hosts: ['api.minimax.io'], + pathPrefixes: ['/anthropic'], + }, + }, + { + id: 'minimax-cn-openai', + nameKey: 'api.guidance.setups.minimax.chinaName', + noteKey: 'api.guidance.setups.minimax.note', + applyProvider: 'custom', + recommendedProtocol: 'openai', + recommendedBaseUrl: 'https://api.minimaxi.com/v1', + exampleModel: 'MiniMax-M3', + matcher: { + hosts: ['api.minimaxi.com'], + pathPrefixes: ['/v1'], + }, + }, + { + id: 'minimax-cn-anthropic', + nameKey: 'api.guidance.setups.minimax.chinaName', + noteKey: 'api.guidance.setups.minimax.note', + applyProvider: 'custom', + recommendedProtocol: 'anthropic', + recommendedBaseUrl: 'https://api.minimaxi.com/anthropic', + exampleModel: 'MiniMax-M3', + matcher: { + hosts: ['api.minimaxi.com'], + pathPrefixes: ['/anthropic'], + }, + }, { id: 'generic-openai', nameKey: 'api.guidance.setups.genericOpenAI.name', @@ -186,8 +227,9 @@ function matchesPath(pathname: string, setup: CommonProviderSetup): boolean { const normalizedPath = pathname.replace(/\/+$/, '') || '/'; const { pathPrefixes, pathIncludes } = setup.matcher || {}; - const prefixOk = !pathPrefixes?.length - || pathPrefixes.some((prefix) => { + const prefixOk = + !pathPrefixes?.length || + pathPrefixes.some((prefix) => { const value = prefix || '/'; return normalizedPath === value || normalizedPath.startsWith(`${value}/`); }); @@ -195,8 +237,8 @@ function matchesPath(pathname: string, setup: CommonProviderSetup): boolean { return false; } - const includesOk = !pathIncludes?.length - || pathIncludes.some((fragment) => normalizedPath.includes(fragment)); + const includesOk = + !pathIncludes?.length || pathIncludes.some((fragment) => normalizedPath.includes(fragment)); return includesOk; } @@ -239,7 +281,9 @@ export function detectCommonProviderSetup(baseUrl: string | undefined): CommonPr return null; } -export function orderCommonProviderSetups(activeId?: CommonProviderSetupId | null): CommonProviderSetup[] { +export function orderCommonProviderSetups( + activeId?: CommonProviderSetupId | null +): CommonProviderSetup[] { if (!activeId) { return COMMON_PROVIDER_SETUPS; } @@ -255,7 +299,9 @@ export function getFallbackOpenAISetup(): CommonProviderSetup { return COMMON_PROVIDER_SETUPS.find((setup) => setup.id === 'generic-openai')!; } -function detectProviderGuidanceHintCode(details: string | undefined): ProviderGuidanceHintCode | null { +function detectProviderGuidanceHintCode( + details: string | undefined +): ProviderGuidanceHintCode | null { const value = details?.trim().toLowerCase(); if (!value) { return null; diff --git a/tests/anthropic-base-url.test.ts b/tests/anthropic-base-url.test.ts new file mode 100644 index 000000000..3508f9d7c --- /dev/null +++ b/tests/anthropic-base-url.test.ts @@ -0,0 +1,55 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { createServer } from 'node:http'; +import { describe, expect, it } from 'vitest'; + +describe('Anthropic-compatible base URLs', () => { + it('appends the messages path after the configured base URL', async () => { + let requestPath = ''; + const server = createServer((request, response) => { + requestPath = request.url || ''; + request.resume(); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'MiniMax-M3', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }) + ); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Expected a local TCP server address'); + } + + try { + const client = new Anthropic({ + apiKey: 'placeholder', + baseURL: `http://127.0.0.1:${address.port}/anthropic`, + }); + await client.messages.create({ + model: 'MiniMax-M3', + max_tokens: 1, + messages: [{ role: 'user', content: 'ping' }], + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + + expect(requestPath).toBe('/anthropic/v1/messages'); + }); +}); diff --git a/tests/api-config-state.test.ts b/tests/api-config-state.test.ts index e8a1057af..a39eca35c 100644 --- a/tests/api-config-state.test.ts +++ b/tests/api-config-state.test.ts @@ -261,6 +261,10 @@ describe('api config state helpers', () => { 'kimi-k2-thinking' ); expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain('glm-5'); + expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain('MiniMax-M3'); + expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain( + 'MiniMax-M2.7' + ); expect(FALLBACK_PROVIDER_PRESETS.custom.models.map((item) => item.id)).toContain( 'MiniMax-M2.5' ); diff --git a/tests/pi-model-resolution.test.ts b/tests/pi-model-resolution.test.ts index abd0115e2..1586e5fcb 100644 --- a/tests/pi-model-resolution.test.ts +++ b/tests/pi-model-resolution.test.ts @@ -76,6 +76,34 @@ describe('pi model resolution helpers', () => { expect(model.baseUrl).toBe('https://api.x.ai/v1'); }); + it('uses current MiniMax metadata for synthetic fallback models', () => { + const m3 = buildSyntheticPiModel( + 'MiniMax-M3', + 'anthropic', + 'anthropic', + 'https://api.minimax.io/anthropic' + ); + expect(m3).toMatchObject({ + reasoning: true, + input: ['text', 'image'], + cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 }, + contextWindow: 1000000, + }); + + const m27 = buildSyntheticPiModel( + 'MiniMax-M2.7', + 'openai', + 'openai', + 'https://api.minimaxi.com/v1' + ); + expect(m27).toMatchObject({ + reasoning: true, + input: ['text'], + cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }, + contextWindow: 204800, + }); + }); + it('preserves explicit provider-prefixed ids for openrouter synthetic fallbacks', () => { const fallback = resolveSyntheticPiModelFallback({ rawModel: 'z-ai/glm-5-turbo', diff --git a/tests/provider-guidance.test.ts b/tests/provider-guidance.test.ts index 90a8a1994..c2c347e88 100644 --- a/tests/provider-guidance.test.ts +++ b/tests/provider-guidance.test.ts @@ -21,6 +21,55 @@ describe('provider guidance helpers', () => { expect(setup?.recommendedProtocol).toBe('openai'); }); + it('exposes both MiniMax regions through both compatible protocols', () => { + const setups = COMMON_PROVIDER_SETUPS.filter((setup) => setup.id.startsWith('minimax-')); + + expect( + setups.map((setup) => ({ + id: setup.id, + protocol: setup.recommendedProtocol, + baseUrl: setup.recommendedBaseUrl, + model: setup.exampleModel, + })) + ).toEqual([ + { + id: 'minimax-global-openai', + protocol: 'openai', + baseUrl: 'https://api.minimax.io/v1', + model: 'MiniMax-M3', + }, + { + id: 'minimax-global-anthropic', + protocol: 'anthropic', + baseUrl: 'https://api.minimax.io/anthropic', + model: 'MiniMax-M3', + }, + { + id: 'minimax-cn-openai', + protocol: 'openai', + baseUrl: 'https://api.minimaxi.com/v1', + model: 'MiniMax-M3', + }, + { + id: 'minimax-cn-anthropic', + protocol: 'anthropic', + baseUrl: 'https://api.minimaxi.com/anthropic', + model: 'MiniMax-M3', + }, + ]); + + expect(detectCommonProviderSetup('https://api.minimax.io/v1')?.id).toBe( + 'minimax-global-openai' + ); + expect(detectCommonProviderSetup('https://api.minimax.io/anthropic')?.id).toBe( + 'minimax-global-anthropic' + ); + expect(detectCommonProviderSetup('https://api.minimaxi.com/v1')?.id).toBe('minimax-cn-openai'); + expect(detectCommonProviderSetup('https://api.minimaxi.com/anthropic')?.id).toBe( + 'minimax-cn-anthropic' + ); + }); + it('detects OpenRouter and prefers the dedicated provider tab', () => { const setup = detectCommonProviderSetup('https://openrouter.ai/api/v1'); expect(setup?.id).toBe('openrouter');