diff --git a/README.md b/README.md index 18022b6f2..c6ecbf3d8 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,7 @@ Advanced and source-build guides: | Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely | | Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials | | Twigpine Opengateway | Startup default, `/provider`, or env vars | Smart gateway at `https://opengateway.gitlawb.com/v1`; requires an API key from https://gitlawb.com/opengateway/keys and routes Xiaomi MiMo and other partner models by `OPENAI_MODEL` | -| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway (48 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go | +| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway; uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go | | OpenCode Go | `/provider` or env vars | $10/mo subscription for open models (13 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/go/v1`; shared key with OpenCode Zen | | Xiaomi MiMo | `/provider` or env vars | OpenAI-compatible API at `https://mimo.mi.com`; uses `MIMO_API_KEY` and defaults to `mimo-v2.5-pro` | | NEAR AI | `/provider` or env vars | Unified gateway (Claude, GPT, Gemini + TEE open models); uses `NEARAI_API_KEY` at `https://cloud-api.near.ai/v1` | diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index f96765a4a..99c9a97af 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -256,8 +256,8 @@ export OPENAI_MODEL=gpt-5.4 openclaude ``` -OpenCode Zen is a pay-as-you-go AI gateway with 48 models (GPT, Claude, Gemini, -Qwen, MiniMax, GLM, Kimi, Grok, Big Pickle, DeepSeek, Nemotron). Uses the same +OpenCode Zen is a pay-as-you-go AI gateway with models including GPT, Claude, Gemini, +Qwen, MiniMax, GLM, Kimi, Grok, Big Pickle, DeepSeek, and Nemotron. Uses the same `OPENCODE_API_KEY` as OpenCode Go. Get your key from https://opencode.ai. ### OpenCode Go (subscription) @@ -853,6 +853,12 @@ source provider's dedicated key into the new launch. When a saved provider profile is active, `/model` can either show the provider's catalog/discovered models or only the models explicitly listed in the profile. +MiniMax text-model retirement applies to the direct MiniMax route; other +providers retain the MiniMax IDs their own catalogs or discovery advertise. +OpenCode Zen excludes its obsolete `minimax-m3-free` and +`minimax-m3.1-flash-free` suggestions. Saved or explicitly configured selections +remain available. + Configure this in `~/.openclaude.json`: ```json diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index 37edda846..c01c0c898 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -1,4 +1,5 @@ import { PassThrough } from 'node:stream' +import { snapshotEnv } from '../../test/envSnapshot.js' import { afterAll, afterEach, beforeEach, expect, mock, setSystemTime, test } from 'bun:test' import React from 'react' @@ -698,6 +699,70 @@ test('/model still applies a Claude alias on a non-Command-Code OpenAI route', a expect(messages[0]).not.toContain('requires the Anthropic Messages protocol') }) +test.each([ + ['MiniMax-M2.5', true], + ['MiniMax-M2.7', true], + ['MiniMax-Text-01', true], + ['abab6.5s-chat', true], + ['abab6.5-chat', true], + ['abab6.5g-chat', true], + ['abab6-chat', true], + ['MiniMax-M3', true], + ['MiniMax-M3.1-Flash', true], + ['custom-minimax-deployment', false], +])('/model preserves explicit MiniMax selection validity for %s', async (model, valid) => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.minimax.io/v1' + process.env.OPENAI_API_KEY = 'minimax-test-key' + process.env.OPENAI_MODEL = 'MiniMax-M3.1-Flash' + for (const key of [ + 'OPENAI_API_BASE', 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_MISTRAL', 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + ]) delete process.env[key] + mockProviderProfiles() + + const { call } = await importFreshModelModule(`minimax-explicit-${model}`) + const { AppStateProvider, getDefaultAppState, useAppState } = await import('../../state/AppState.js') + const { render } = await import('../../ink.js') + const messages: string[] = [] + let selectedModel: ModelSetting | undefined + function CaptureModel() { + selectedModel = useAppState(state => state.mainLoopModel) + return null + } + const stdout = new PassThrough() + ;(stdout as unknown as { columns: number }).columns = 120 + const originalFetch = globalThis.fetch + const fetch = mock(async () => { throw new Error('Known model validation must not call the API') }) + globalThis.fetch = fetch as unknown as typeof globalThis.fetch + let instance: Awaited> | undefined + try { + const element = await call(result => { if (result) messages.push(result) }, {} as never, model) + instance = await render( + + {element} + + , + stdout as unknown as NodeJS.WriteStream, + ) + await waitForCondition(() => messages.length > 0) + if (valid) { + expect(messages[0]).toContain('Set model to') + await waitForCondition(() => selectedModel === model) + expect(selectedModel).toBe(model) + } else { + expect(messages[0]).toContain(`Model '${model}' not found in MiniMax catalog`) + expect(selectedModel).toBe('MiniMax-M3.1-Flash') + } + expect(fetch).not.toHaveBeenCalled() + } finally { + instance?.unmount() + stdout.end() + globalThis.fetch = originalFetch + } +}) + test('opens the model picker without awaiting descriptor-backed route refresh', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' @@ -3302,6 +3367,54 @@ test('/model refresh treats empty legacy OpenAI-compatible discovery as failed n ) }) +test.each([ + ['ollama', 'http://localhost:11434/v1'], + ['lmstudio', 'http://localhost:1234/v1'], + ['atomic-chat', 'http://127.0.0.1:1337/v1'], + ['openrouter', 'https://openrouter.ai/api/v1'], +])('%s picker retains advertised MiniMax models before and after refresh', async (provider, baseUrl) => { + const restoreEnv = snapshotEnv([ + 'CLAUDE_CODE_USE_OPENAI', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', + 'OPENAI_API_KEY', 'OPENAI_MODEL', 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + ], { clear: true }) + let rendered: Awaited> | undefined + try { + const activeProfile = { + id: 'local-profile', name: provider, provider, + baseUrl, model: 'qwen3:latest', apiKey: '', + } + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = activeProfile.baseUrl + process.env.OPENAI_MODEL = activeProfile.model + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id + mockProviderProfiles({ getActiveProviderProfile: () => activeProfile }) + const models = provider === 'openrouter' + ? ['minimax/minimax-m2.5', 'minimax/minimax-m2.7'] + : ['minimax-m3.1-flash:cloud', 'minimax-m3:cloud', 'minimax-m2.7:cloud', 'minimaxai/minimax-m2.7', 'minimax-m3.1-flash-q4_k_m'] + mockDescriptorDiscovery({ + routeId: provider, + cachedModels: [{ id: models[0]!, apiName: models[0]! }], + discoveredModels: models.map(id => ({ id, apiName: id })), + }) + rendered = await renderModelCommandWithCapturedPicker(`${provider}-minimax-discovery`) + const pickerValues = () => (rendered!.getCapturedProps().optionsOverride as ModelOption[]).map(option => option.value) + expect(pickerValues()).toContain(models[0]) + expect(pickerValues()).not.toContain(models[1]) + expect(rendered.getCapturedProps().onRefresh).toEqual(expect.any(Function)) + rendered.getCapturedProps().onRefresh!() + await waitForCondition(() => pickerValues().includes(models[1]!)) + for (const model of models) expect(pickerValues()).toContain(model) + } finally { + rendered?.instance.unmount() + rendered?.stdout.end() + restoreEnv() + } +}) + test('interactive model picker refresh keeps descriptor options allowlist-filtered', async () => { useSettings({ availableModels: ['allowed-route'] } as SettingsJson) const activeProfile = { diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index c1dc23ad6..4875b9ef9 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -497,6 +497,7 @@ async function loadDescriptorDiscoveryContext( routeLabel, staticEntries, routeDefaultModel, + routeId, ) return { @@ -553,6 +554,7 @@ async function loadDescriptorDiscoveryContext( routeLabel, mergedEntries, routeDefaultModel, + routeId, ) return { @@ -933,6 +935,7 @@ function ModelPickerWrapper({ discoveryContext.routeLabel, result?.models ?? [], discoveryContext.routeDefaultModel, + discoveryContext.routeId, ), { profileModelSurface: discoveryContext.profileModelSurface, @@ -1285,6 +1288,7 @@ async function refreshModelsAndSummarize(): Promise { discoveryContext.routeLabel, result?.models ?? [], discoveryContext.routeDefaultModel, + discoveryContext.routeId, ), { profileModelSurface: discoveryContext.profileModelSurface, diff --git a/src/integrations/brands/minimax.ts b/src/integrations/brands/minimax.ts index 04b573dda..19efbaf8e 100644 --- a/src/integrations/brands/minimax.ts +++ b/src/integrations/brands/minimax.ts @@ -13,6 +13,7 @@ export default defineBrand({ supportsPreciseTokenCount: false, }, modelIds: [ + 'minimax-m3.1-flash', 'minimax-m3', 'minimax-m2', 'minimax-m2.1', diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index 11f56c747..f53a64f0a 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -75,6 +75,9 @@ const zenModels: OpenCodeCatalogSpec[] = [ { id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' }, { id: 'glm-5.1', label: 'GLM 5.1', zaiGlm: true }, { id: 'glm-5', label: 'GLM 5', zaiGlm: true }, + // Retain historical identity for saved selections; suggestion policy hides it. + { id: 'minimax-m3.1-flash-free', label: 'MiniMax M3.1 Flash Free' }, + { id: 'minimax-m3', label: 'MiniMax M3' }, { id: 'minimax-m2.7', label: 'MiniMax M2.7' }, { id: 'minimax-m2.5', label: 'MiniMax M2.5' }, { id: 'kimi-k2.6', label: 'Kimi K2.6' }, @@ -110,7 +113,7 @@ export default defineGateway({ preset: { id: 'opencode', vendorId: 'openai', - description: 'OpenCode Zen - pay-as-you-go AI gateway (48 models)', + description: 'OpenCode Zen - pay-as-you-go AI gateway', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], }, diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index 00df795cb..3259ff19a 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -474,7 +474,7 @@ export const PROVIDER_PRESET_MANIFEST = [ "routeId": "opencode", "vendorId": "openai", "gatewayId": "opencode", - "description": "OpenCode Zen - pay-as-you-go AI gateway (48 models)", + "description": "OpenCode Zen - pay-as-you-go AI gateway", "apiKeyEnvVars": [ "OPENCODE_API_KEY" ], diff --git a/src/integrations/models/minimax.ts b/src/integrations/models/minimax.ts index e7930b1bc..f92fecccc 100644 --- a/src/integrations/models/minimax.ts +++ b/src/integrations/models/minimax.ts @@ -10,6 +10,17 @@ const minimaxM2Capabilities = { } export default [ + defineModel({ + id: 'minimax-m3.1-flash', + label: 'MiniMax M3.1 Flash', + brandId: 'minimax', + vendorId: 'minimax', + classification: ['chat', 'reasoning', 'vision', 'coding'], + defaultModel: 'MiniMax-M3.1-Flash', + capabilities: minimaxM2Capabilities, + contextWindow: 1_000_000, + maxOutputTokens: 524_288, + }), defineModel({ id: 'minimax-m2', label: 'MiniMax M2', @@ -99,7 +110,7 @@ export default [ classification: ['chat', 'reasoning', 'coding'], defaultModel: 'MiniMax-M3', capabilities: minimaxM2Capabilities, - contextWindow: 1_048_576, + contextWindow: 1_000_000, maxOutputTokens: 131_072, }), defineModel({ diff --git a/src/integrations/models/opencode.ts b/src/integrations/models/opencode.ts index 57806a850..4d2ef684c 100644 --- a/src/integrations/models/opencode.ts +++ b/src/integrations/models/opencode.ts @@ -11,8 +11,8 @@ type OpenCodeModelSpec = { id: string label: string defaultModel: string - contextWindow: number - maxOutputTokens: number + contextWindow?: number + maxOutputTokens?: number reasoning?: boolean vision?: boolean coding?: boolean @@ -45,6 +45,9 @@ function openCodeModel(spec: OpenCodeModelSpec) { } const zenModels: OpenCodeModelSpec[] = [ + // Zen publishes no token limits. Historical descriptors do not imply availability. + { id: 'opencode-minimax-m3.1-flash-free', label: 'MiniMax M3.1 Flash Free', defaultModel: 'minimax-m3.1-flash-free', reasoning: true, coding: true }, + { id: 'opencode-minimax-m3', label: 'MiniMax M3', defaultModel: 'minimax-m3', reasoning: true, coding: true }, { id: 'opencode-claude-fable-5', label: 'Claude Fable 5', defaultModel: 'claude-fable-5', contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true, vision: true }, { id: 'opencode-claude-opus-4-8', label: 'Claude Opus 4.8', defaultModel: 'claude-opus-4-8', contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true, vision: true }, { id: 'opencode-claude-opus-4-7', label: 'Claude Opus 4.7', defaultModel: 'claude-opus-4-7', contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true, vision: true }, diff --git a/src/integrations/vendors/minimax.ts b/src/integrations/vendors/minimax.ts index 712061d64..512766490 100644 --- a/src/integrations/vendors/minimax.ts +++ b/src/integrations/vendors/minimax.ts @@ -8,7 +8,7 @@ export default defineVendor({ label: 'MiniMax', classification: 'native', defaultBaseUrl: MINIMAX_GLOBAL_ANTHROPIC_BASE_URL, - defaultModel: 'MiniMax-M3', + defaultModel: 'MiniMax-M3.1-Flash', requiredEnvVars: ['MINIMAX_API_KEY'], setup: { requiresAuth: true, @@ -37,6 +37,7 @@ export default defineVendor({ catalog: { source: 'static', models: [ + { id: 'minimax-m3.1-flash', apiName: 'MiniMax-M3.1-Flash', label: 'MiniMax M3.1 Flash', modelDescriptorId: 'minimax-m3.1-flash', contextWindow: 1_000_000 }, { id: 'minimax-m2', apiName: 'MiniMax-M2', label: 'MiniMax M2', modelDescriptorId: 'minimax-m2', contextWindow: 204_800 }, { id: 'minimax-m2.1', apiName: 'MiniMax-M2.1', label: 'MiniMax M2.1', modelDescriptorId: 'minimax-m2.1', contextWindow: 204_800 }, { id: 'minimax-m2.1-highspeed', apiName: 'MiniMax-M2.1-highspeed', label: 'MiniMax M2.1 Highspeed', modelDescriptorId: 'minimax-m2.1-highspeed', contextWindow: 204_800 }, @@ -44,7 +45,7 @@ export default defineVendor({ { id: 'minimax-m2.5-highspeed', apiName: 'MiniMax-M2.5-highspeed', label: 'MiniMax M2.5 Highspeed', modelDescriptorId: 'minimax-m2.5-highspeed', contextWindow: 204_800 }, { id: 'minimax-m2.7', apiName: 'MiniMax-M2.7', label: 'MiniMax M2.7', modelDescriptorId: 'minimax-m2.7', contextWindow: 204_800 }, { id: 'minimax-m2.7-highspeed', apiName: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7 Highspeed', modelDescriptorId: 'minimax-m2.7-highspeed', contextWindow: 204_800 }, - { id: 'minimax-m3', apiName: 'MiniMax-M3', label: 'MiniMax M3', modelDescriptorId: 'minimax-m3', contextWindow: 1_048_576 }, + { id: 'minimax-m3', apiName: 'MiniMax-M3', label: 'MiniMax M3', modelDescriptorId: 'minimax-m3', contextWindow: 1_000_000 }, { id: 'minimax-text-01', apiName: 'MiniMax-Text-01', label: 'MiniMax Text 01', modelDescriptorId: 'minimax-text-01' }, { id: 'minimax-text-01-preview', apiName: 'MiniMax-Text-01-Preview', label: 'MiniMax Text 01 Preview', modelDescriptorId: 'minimax-text-01-preview' }, { id: 'minimax-vision-01', apiName: 'MiniMax-Vision-01', label: 'MiniMax Vision 01', modelDescriptorId: 'minimax-vision-01' }, diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index ba1a54844..a0ada6e98 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -761,6 +761,66 @@ test('routes env-only MiniMax requests through the Anthropic-compatible API', as }) }) +for (const host of ['api.minimax.io', 'api.minimaxi.com']) { + for (const protocol of ['anthropic', 'openai'] as const) { + for (const savedModel of [undefined, 'MiniMax-M3', 'MiniMax-M2.7', 'custom-minimax-deployment']) { + test(`MiniMax ${host} ${protocol} serializes ${savedModel ?? 'Flash default'} unchanged`, async () => { + clearEnvForMiniMaxOnlyTest() + process.env.MINIMAX_API_KEY = 'minimax-test-key' + if (protocol === 'anthropic') { + process.env.ANTHROPIC_BASE_URL = `https://${host}/anthropic` + if (savedModel) process.env.ANTHROPIC_MODEL = savedModel + } else { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = `https://${host}/v1` + if (savedModel) process.env.OPENAI_MODEL = savedModel + } + const { getDefaultMainLoopModel } = await import('../../utils/model/model.js') + const model = getDefaultMainLoopModel() + expect(model).toBe(savedModel ?? 'MiniMax-M3.1-Flash') + let requestURL = '' + let requestHeaders = new Headers() + let requestBody: Record = {} + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + requestURL = request.url + requestHeaders = request.headers + requestBody = await request.json() as Record + const response = protocol === 'anthropic' + ? { id: 'msg-minimax', type: 'message', role: 'assistant', model, content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 } } + : { id: 'chat-minimax', object: 'chat.completion', model, choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } } + return new Response(JSON.stringify(response), { headers: { 'content-type': 'application/json' } }) + }) as FetchType + const client = await getAnthropicClient({ maxRetries: 0, model }) + await client.beta.messages.create({ + model, max_tokens: 16, + messages: [{ role: 'user', content: savedModel ? 'hello' : [ + { type: 'text', text: 'describe this image' }, + { type: 'image', source: { type: 'url', url: 'https://example.com/fixture.png' } }, + ] }], + }) + if (!savedModel) { + expect(requestBody.messages).toEqual([{ + role: 'user', content: [ + { type: 'text', text: 'describe this image' }, + protocol === 'anthropic' + ? { type: 'image', source: { type: 'url', url: 'https://example.com/fixture.png' } } + : { type: 'image_url', image_url: { url: 'https://example.com/fixture.png' } }, + ], + }]) + } + expect(requestURL).toContain(`https://${host}/${protocol === 'anthropic' ? 'anthropic/v1/messages' : 'v1/chat/completions'}`) + expect(requestHeaders.get(protocol === 'anthropic' ? 'x-api-key' : 'authorization')).toBe(protocol === 'anthropic' ? 'minimax-test-key' : 'Bearer minimax-test-key') + expect(requestBody.model).toBe(model) + expect(requestBody.service_tier).toBeUndefined() + expect(requestBody.thinking).not.toEqual({ type: 'disabled' }) + expect(requestBody.reasoning_effort).not.toBe('none') + expect(requestBody.reasoning_split).not.toBe(false) + }) + } + } +} + for (const credentialEnv of ['ANTHROPIC_API_KEY', 'MINIMAX_API_KEY']) { for (const dormantBaseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) { for (const host of ['api.minimax.io', 'api.minimaxi.com', 'api.minimax.chat']) { diff --git a/src/services/api/openaiShim/requestExecutor.integration.test.ts b/src/services/api/openaiShim/requestExecutor.integration.test.ts index 0ea2c2536..9776787b9 100644 --- a/src/services/api/openaiShim/requestExecutor.integration.test.ts +++ b/src/services/api/openaiShim/requestExecutor.integration.test.ts @@ -2048,6 +2048,30 @@ test('xiaomi mimo token plan uses raw api-key and OpenAI-compatible reasoning_ef expect(capturedBody).not.toHaveProperty('stream_options') }) +test.each([ + ['https://opencode.ai/zen/v1', 'minimax-m3.1-flash-free'], + ['https://opencode.ai/zen/v1', 'minimax-m3'], + ['https://openrouter.ai/api/v1', 'minimax/minimax-m3.1-flash:free'], +])('MiniMax gateway %s preserves the explicit model ID %s at HTTP boundary', async (baseURL, model) => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = baseURL + process.env.OPENAI_MODEL = model + process.env.OPENAI_API_KEY = 'gateway-fixture-key' + let capturedURL = '' + let body: Record = {} + globalThis.fetch = (async (input, init) => { + capturedURL = String(input) + body = JSON.parse(String(init?.body)) + return makeChatCompletionResponse(model) + }) as unknown as FetchType + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ model, messages: [{ role: 'user', content: 'hello' }], max_tokens: 32, stream: false }) + expect(capturedURL).toBe(`${baseURL}/chat/completions`) + expect(body.model).toBe(model) + expect(body.thinking).not.toEqual({ type: 'disabled' }) + expect(body.reasoning_effort).not.toBe('none') +}) + test.each([ 'minimax-m3', 'minimax-m2.7', diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 343278fab..cea741829 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -510,7 +510,9 @@ test('MiniMax China uses the M3 context limit before client setup', () => { try { process.env.ANTHROPIC_BASE_URL = 'https://api.minimaxi.com/anthropic' process.env.ANTHROPIC_API_KEY = 'china-test-key' - expect(getContextWindowForModel('MiniMax-M3')).toBe(1_048_576) + expect(getContextWindowForModel('MiniMax-M3')).toBe(1_000_000) + expect(getContextWindowForModel('MiniMax-M3.1-Flash')).toBe(1_000_000) + expect(getMaxOutputTokensForModel('MiniMax-M3.1-Flash')).toBe(524_288) } finally { if (savedBaseUrl === undefined) delete process.env.ANTHROPIC_BASE_URL else process.env.ANTHROPIC_BASE_URL = savedBaseUrl diff --git a/src/utils/model/minimaxCatalog.test.ts b/src/utils/model/minimaxCatalog.test.ts new file mode 100644 index 000000000..6ec087f33 --- /dev/null +++ b/src/utils/model/minimaxCatalog.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'bun:test' +import { getCatalogEntriesForRoute, getModel } from '../../integrations/index.js' +import { isCurrentMiniMaxModelOption } from './minimaxCatalog.js' +import { buildRouteCatalogModelOptions, mergeRouteCatalogEntries } from './routeCatalogOptions.js' + +for (const model of ['MiniMax-M2', 'MiniMax-M2.7-highspeed', 'minimax/minimax-m2.5:free', 'MiniMaxAI/MiniMax-M1', 'minimax-text-01', 'abab6.5-chat', 'minimax-m3-free', 'minimax-m3.1-flash-free']) { + test(`retired text suggestion is filtered: ${model}`, () => { + expect(isCurrentMiniMaxModelOption(model)).toBe(false) + }) +} + +for (const model of ['MiniMax-M3.1-Flash', 'minimax/minimax-m3.1-flash:free', 'minimaxai/minimax-m3', 'minimax/minimax-m3:free', 'MiniMax-Vision-01', 'speech-02-hd', 'music-2.6', 'image-01', 'MiniMax-Hailuo-2.3', 'gpt-4o', 'my-custom-model']) { + test(`current, custom, or nontext suggestion is preserved: ${model}`, () => { + expect(isCurrentMiniMaxModelOption(model)).toBe(true) + }) +} + +test('Zen catalogs retain available gateway models but exclude obsolete free suggestions', () => { + const entries = mergeRouteCatalogEntries(getCatalogEntriesForRoute('opencode'), [ + { id: 'minimax/minimax-m2.7', apiName: 'minimax/minimax-m2.7' }, + { id: 'minimax-m3-free', apiName: 'minimax-m3-free' }, + { id: 'minimax-m3.1-flash-free', apiName: 'minimax-m3.1-flash-free' }, + { id: 'minimax/minimax-m3.1-flash:free', apiName: 'minimax/minimax-m3.1-flash:free' }, + ]) + const values = buildRouteCatalogModelOptions('Gateway', entries, undefined, 'opencode').map(option => option.value) + expect(values).not.toContain('minimax-m3.1-flash-free') + expect(values).not.toContain('minimax-m3-free') + expect(values).toContain('minimax-m3') + expect(values).toContain('minimax/minimax-m3.1-flash:free') + expect(values).toContain('minimax-m2.5') + expect(values).toContain('minimax-m2.7') + expect(values).toContain('minimax/minimax-m2.7') + // Filtering suggestions must not delete metadata used by explicit requests. + expect(getModel('minimax-m2.7')?.capabilities.supportsVision).toBe(false) + expect(getCatalogEntriesForRoute('opencode').some(entry => entry.apiName === 'minimax-m2.7')).toBe(true) + expect(getCatalogEntriesForRoute('opencode').some(entry => entry.apiName === 'minimax-m3.1-flash-free')).toBe(true) + expect(getModel('opencode-minimax-m3.1-flash-free')?.defaultModel).toBe('minimax-m3.1-flash-free') +}) + +test.each(['openrouter', 'opencode-go', 'custom'])('%s discovery owns its MiniMax availability', routeId => { + const models = ['minimax-m2.5', 'minimax/minimax-m2.7'] + expect(buildRouteCatalogModelOptions( + 'Gateway', models.map(apiName => ({ id: apiName, apiName })), undefined, routeId, + ).map(option => option.value)).toEqual(models) +}) + +test.each(['ollama', 'lmstudio', 'atomic-chat'])('%s catalog options retain provider-owned MiniMax model identities', routeId => { + const models = ['minimax-m3.1-flash:cloud', 'minimax-m3:cloud', 'minimax-m2.7:cloud', 'minimaxai/minimax-m2.7', 'minimax-m3.1-flash-q4_k_m'] + const options = buildRouteCatalogModelOptions( + 'Custom display label', + models.map(apiName => ({ id: apiName, apiName })), + undefined, + routeId, + ) + expect(options.map(option => option.value)).toEqual(models) +}) diff --git a/src/utils/model/minimaxCatalog.ts b/src/utils/model/minimaxCatalog.ts new file mode 100644 index 000000000..07cb391d2 --- /dev/null +++ b/src/utils/model/minimaxCatalog.ts @@ -0,0 +1,23 @@ +import { getTransportKindForRoute } from '../../integrations/routeMetadata.js' + +/** Filter ordinary suggestions only; explicit saved/custom model IDs stay valid. */ +export function isCurrentMiniMaxModelOption( + model: unknown, + routeId?: string | null, + localEndpoint = false, +): boolean { + if (model === null) return true + // Persisted bootstrap options reach this boundary without normalization. + if (typeof model !== 'string') return false + const name = model.trim().toLowerCase().split('/').at(-1)!.split('?', 1)[0]! + if (!/^(minimax-(?:m\d|text-|\d)|abab\d)/.test(name)) return true + // Independently hosted servers own their installed/discovered model names. + if (localEndpoint || (routeId && getTransportKindForRoute(routeId) === 'local')) return true + // Direct MiniMax retirement does not retire independently served gateway models. + if (routeId === 'opencode') { + return name !== 'minimax-m3-free' && name !== 'minimax-m3.1-flash-free' + } + if (routeId && routeId !== 'minimax') return true + // No route is the conservative precheck used before resolving picker context. + return /^(minimax-m3(?:\.1-flash)?)(?::free)?$/.test(name) +} diff --git a/src/utils/model/minimaxModels.test.ts b/src/utils/model/minimaxModels.test.ts index a1ef2b622..6bc1dce45 100644 --- a/src/utils/model/minimaxModels.test.ts +++ b/src/utils/model/minimaxModels.test.ts @@ -25,9 +25,13 @@ afterEach(() => { } }) -test('MiniMax picker catalog includes current M2.7 models', () => { +test('MiniMax picker offers Flash first, M3, and unchanged vision models', () => { const values = getCachedMiniMaxModelOptions().map(option => option.value) - expect(values).toContain('MiniMax-M2.7') - expect(values).toContain('MiniMax-M2.7-highspeed') + expect(values).toEqual([ + 'MiniMax-M3.1-Flash', + 'MiniMax-M3', + 'MiniMax-Vision-01', + 'MiniMax-Vision-01-Fast', + ]) }) diff --git a/src/utils/model/minimaxModels.ts b/src/utils/model/minimaxModels.ts index 0629bc3a9..64781507c 100644 --- a/src/utils/model/minimaxModels.ts +++ b/src/utils/model/minimaxModels.ts @@ -1,40 +1,32 @@ -/** - * MiniMax model list for the /model picker. - * Full model catalog from MiniMax API. - */ - +/** MiniMax suggestions share the route catalog used by provider profiles. */ +import { getCatalogEntriesForRoute, getRouteDefaultModel } from '../../integrations/index.js' import type { ModelOption } from './modelOptions.js' import { getAPIProvider } from './providers.js' +import { buildRouteCatalogModelOptions } from './routeCatalogOptions.js' + export function isMiniMaxProvider(): boolean { return getAPIProvider() === 'minimax' } -function getMiniMaxModels(): ModelOption[] { - return [ - // Latest Generation Models - use correct MiniMax naming with M prefix - { value: 'MiniMax-M2', label: 'MiniMax M2', description: 'MoE model - 131K context - Chat/Code/Reasoning' }, - { value: 'MiniMax-M2.1', label: 'MiniMax M2.1', description: 'Enhanced - 200K context - Vision' }, - { value: 'MiniMax-M2.5', label: 'MiniMax M2.5', description: 'Flagship - 256K context - Vision/Function-calling' }, - { value: 'MiniMax-M2.7', label: 'MiniMax M2.7', description: 'Flagship - 256K context - Chat/Code/Reasoning' }, - { value: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7 Highspeed', description: 'Fast flagship - 256K context - Chat/Code/Reasoning' }, - { value: 'MiniMax-M3', label: 'MiniMax M3', description: 'Next-gen - 1M context - Coding/Agentic/Reasoning' }, - { value: 'MiniMax-Text-01', label: 'MiniMax Text 01', description: 'Text-focused - 512K context - FREE' }, - { value: 'MiniMax-Text-01-Preview', label: 'MiniMax Text 01 Preview', description: 'Preview - 256K context - FREE' }, - { value: 'MiniMax-Vision-01', label: 'MiniMax Vision 01', description: 'Vision model - 32K context' }, - { value: 'MiniMax-Vision-01-Fast', label: 'MiniMax Vision 01 Fast', description: 'Fast vision - 16K context - FREE' }, - // Legacy free tier models - { value: 'abab6.5s-chat', label: 'ABAB 6.5S Chat', description: 'Legacy free - 16K context' }, - { value: 'abab6.5-chat', label: 'ABAB 6.5 Chat', description: 'Legacy free - 32K context' }, - { value: 'abab6.5g-chat', label: 'ABAB 6.5G Chat', description: 'Generation 6.5 - 32K context' }, - { value: 'abab6-chat', label: 'ABAB 6 Chat', description: 'Legacy - 8K context' }, - ] -} +// These legacy IDs were accepted before the descriptor-backed picker, but +// have no shared descriptor. Retirement from suggestions must not revoke them. +const legacyMiniMaxModelIds = new Set([ + 'abab6.5s-chat', + 'abab6.5-chat', + 'abab6.5g-chat', + 'abab6-chat', +]) -let cachedMiniMaxOptions: ModelOption[] | null = null +export function isKnownMiniMaxModel(model: string): boolean { + return legacyMiniMaxModelIds.has(model) || + getCatalogEntriesForRoute('minimax').some(entry => entry.apiName === model) +} export function getCachedMiniMaxModelOptions(): ModelOption[] { - if (!cachedMiniMaxOptions) { - cachedMiniMaxOptions = getMiniMaxModels() - } - return cachedMiniMaxOptions + return buildRouteCatalogModelOptions( + 'MiniMax', + getCatalogEntriesForRoute('minimax'), + getRouteDefaultModel('minimax'), + 'minimax', + ) } diff --git a/src/utils/model/model.openai-shim-providers.test.ts b/src/utils/model/model.openai-shim-providers.test.ts index 4809c1127..b08fb163b 100644 --- a/src/utils/model/model.openai-shim-providers.test.ts +++ b/src/utils/model/model.openai-shim-providers.test.ts @@ -342,15 +342,23 @@ test('getDefaultOpusModel returns OPENAI_MODEL for MiniMax', async () => { expect(getDefaultOpusModel()).toBe('MiniMax-M2.7') }) -test('getDefaultMainLoopModelSetting defaults MiniMax to M3', async () => { +test('MiniMax default tiers share the Flash route default', async () => { process.env.MINIMAX_API_KEY = 'minimax-test' const { getDefaultMainLoopModel, getDefaultMainLoopModelSetting, + getDefaultOpusModel, + getDefaultSonnetModel, + getDefaultHaikuModel, + getSmallFastModel, } = await importFreshModelModule() - expect(getDefaultMainLoopModelSetting()).toBe('MiniMax-M3') - expect(getDefaultMainLoopModel()).toBe('MiniMax-M3') + expect(getDefaultSonnetModel()).toBe('MiniMax-M3.1-Flash') + expect(getDefaultHaikuModel()).toBe('MiniMax-M3.1-Flash') + expect(getSmallFastModel()).toBe('MiniMax-M3.1-Flash') + expect(getDefaultMainLoopModelSetting()).toBe('MiniMax-M3.1-Flash') + expect(getDefaultMainLoopModel()).toBe('MiniMax-M3.1-Flash') + expect(getDefaultOpusModel()).toBe('MiniMax-M3.1-Flash') }) test('Concentrate selects its dedicated model before client normalization', async () => { diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 860f8eed4..12aee917d 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -112,10 +112,9 @@ export function getSmallFastModel(): ModelName { if (getAPIProvider() === 'nvidia-nim') { return process.env.OPENAI_MODEL || 'meta/llama-3.1-8b-instruct' } - // MiniMax — OPENAI_MODEL carries the active MiniMax model; fall back to - // the fastest tier (M2.5-highspeed) when missing. + // MiniMax uses the configured model or the shared Flash route default. if (getAPIProvider() === 'minimax') { - return getMiniMaxModelEnv() || 'MiniMax-M2.5-highspeed' + return getMiniMaxModelEnv() || getRouteDefaultModel('minimax')! } // Xiaomi MiMo — OPENAI_MODEL carries the active MiMo model; fall back to // the fast tier when missing. @@ -260,7 +259,7 @@ export function getDefaultOpusModel(): ModelName { } // MiniMax — flagship tier for "opus"-equivalent. if (getAPIProvider() === 'minimax') { - return getMiniMaxModelEnv() || 'MiniMax-M2.7' + return getMiniMaxModelEnv() || getRouteDefaultModel('minimax')! } // Xiaomi MiMo — flagship tier for "opus"-equivalent. if (getAPIProvider() === 'xiaomi-mimo') { @@ -310,7 +309,7 @@ export function getDefaultSonnetModel(): ModelName { } // MiniMax — mid tier for "sonnet"-equivalent. if (getAPIProvider() === 'minimax') { - return getMiniMaxModelEnv() || 'MiniMax-M2.5' + return getMiniMaxModelEnv() || getRouteDefaultModel('minimax')! } // Xiaomi MiMo — flagship model for "sonnet"-equivalent. if (getAPIProvider() === 'xiaomi-mimo') { @@ -358,7 +357,7 @@ export function getDefaultHaikuModel(): ModelName { } // MiniMax — fastest tier for "haiku"-equivalent. if (getAPIProvider() === 'minimax') { - return getMiniMaxModelEnv() || 'MiniMax-M2.5-highspeed' + return getMiniMaxModelEnv() || getRouteDefaultModel('minimax')! } // Xiaomi MiMo — fast tier for "haiku"-equivalent. if (getAPIProvider() === 'xiaomi-mimo') { @@ -470,11 +469,9 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { return process.env.OPENAI_MODEL || getRouteDefaultModel('xai') || 'grok-4.6' } // MiniMax provider: always use the configured MiniMax model. - // Keep the env-only fallback aligned with the MiniMax descriptor default - // (MiniMax-M3) so a session with only MINIMAX_API_KEY / a MiniMax base URL - // defaults to the same model as --provider minimax and saved profiles. + // Share the route default across env-only sessions, --provider, and profiles. if (getAPIProvider() === 'minimax') { - return getMiniMaxModelEnv() || 'MiniMax-M3' + return getMiniMaxModelEnv() || getRouteDefaultModel('minimax')! } // Xiaomi MiMo provider: always use the configured MiMo model if (getAPIProvider() === 'xiaomi-mimo') { diff --git a/src/utils/model/modelOptions.crossProfile.test.ts b/src/utils/model/modelOptions.crossProfile.test.ts index 643b825f0..9c49f7eb8 100644 --- a/src/utils/model/modelOptions.crossProfile.test.ts +++ b/src/utils/model/modelOptions.crossProfile.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, beforeEach, expect, test } from 'bun:test' import { mock } from 'bun:test' +import { snapshotEnv } from '../../test/envSnapshot.js' import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js' import { @@ -498,7 +499,7 @@ test('getModelOptionsBase: local OpenAI-compatible scope still appends inactive } }) -test('getModelOptionsBase: active Ollama profile still surfaces inactive profile options', async () => { +test.each(['llama3.2', 'minimax-m3.1-flash:cloud', 'minimax-m3:cloud', 'minimax-m2.7:cloud'])('getModelOptionsBase: active Ollama profile offers discovered %s and inactive profiles', async discoveredModel => { // Regression for #1164: the `isOllamaProvider()` early return ran before the // inactive-profile compute, so a user whose active profile is a local Ollama // endpoint only saw the Ollama models and lost the cross-profile switcher, @@ -508,7 +509,7 @@ test('getModelOptionsBase: active Ollama profile still surfaces inactive profile id: 'profile_ollama', name: 'Local Ollama', baseUrl: 'http://localhost:11434/v1', - model: 'llama3.2', + model: 'qwen3:latest', }) const inactive = buildProviderProfileFixture({ id: 'profile_remote', @@ -523,7 +524,7 @@ test('getModelOptionsBase: active Ollama profile still surfaces inactive profile // `[default, ...ollamaModels, ...inactiveProfileOptions]` return path. activeOllamaOverride = { cachedModels: [ - { value: 'llama3.2', label: 'llama3.2', description: 'Local Ollama' }, + { value: discoveredModel, label: discoveredModel, description: 'Local Ollama' }, ], } try { @@ -538,7 +539,7 @@ test('getModelOptionsBase: active Ollama profile still surfaces inactive profile const options = getModelOptions(false) // The Ollama model is still present... - expect(options.some(o => o.value === 'llama3.2')).toBe(true) + expect(options.some(o => o.value === discoveredModel)).toBe(true) // ...and the inactive profile now appears as a switch option. const switchOptions = options.filter(o => o.switchToProfileId !== undefined) expect(switchOptions.length).toBeGreaterThan(0) @@ -554,6 +555,75 @@ test('getModelOptionsBase: active Ollama profile still surfaces inactive profile } }) +test.each([ + ['ollama', 'http://localhost:11434/v1'], + ['lmstudio', 'http://localhost:1234/v1'], + ['atomic-chat', 'http://127.0.0.1:1337/v1'], + ['custom', 'http://localhost:8080/v1'], +])('getModelOptions appends scoped %s discoveries when the prefetch cache is empty', async (provider, baseUrl) => { + const restoreEnv = snapshotEnv(['CLAUDE_CODE_USE_OPENAI', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_MODEL', 'OLLAMA_BASE_URL']) + const { getGlobalConfig, saveGlobalConfig } = await import('../config.js') + const previousOptions = getGlobalConfig().additionalModelOptionsCache + const previousScope = getGlobalConfig().additionalModelOptionsCacheScope + const models = ['minimax-m3.1-flash:cloud', 'minimax-m3:cloud', 'minimax-m2.7:cloud', 'minimaxai/minimax-m2.7', 'minimax-m3.1-flash-q4_k_m'] + if (provider === 'ollama') activeOllamaOverride = { cachedModels: [] } + activeCacheScopeOverride = `openai:${baseUrl}:fixture` + try { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = baseUrl + process.env.OPENAI_MODEL = 'qwen3:latest' + delete process.env.OPENAI_API_BASE + delete process.env.OLLAMA_BASE_URL + saveGlobalConfig(current => ({ + ...current, + additionalModelOptionsCache: models.map(value => ({ value, label: value, description: `Discovered from ${provider}` })), + additionalModelOptionsCacheScope: activeCacheScopeOverride!, + })) + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [], + getActiveProviderProfile: () => undefined, + }) + const values = getModelOptions(false).map(option => option.value) + for (const model of models) expect(values).toContain(model) + } finally { + saveGlobalConfig(current => ({ + ...current, + additionalModelOptionsCache: previousOptions, + additionalModelOptionsCacheScope: previousScope, + })) + restoreEnv() + } +}) + +test.each(['firstParty', 'openai:http://localhost:11434/v1:fixture'])('getModelOptions ignores malformed bootstrap values in %s scope', async scope => { + const { getGlobalConfig, saveGlobalConfig } = await import('../config.js') + const previousOptions = getGlobalConfig().additionalModelOptionsCache + const previousScope = getGlobalConfig().additionalModelOptionsCacheScope + activeCacheScopeOverride = scope + activeApiProviderOverride = scope === 'firstParty' ? 'firstParty' : 'openai' + saveGlobalConfig(current => ({ + ...current, + additionalModelOptionsCacheScope: scope, + additionalModelOptionsCache: JSON.parse('[{},{"value":42},{"value":false},{"value":{}},{"value":[]},{"value":null},{"value":"valid-cache-control","label":"control","description":"control"}]'), + })) + try { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [], + getActiveProviderProfile: () => undefined, + }) + const values = getModelOptions(false).map(option => option.value) + expect(values).toContain(null) + expect(values).toContain('valid-cache-control') + expect(values.every(value => value === null || typeof value === 'string')).toBe(true) + } finally { + saveGlobalConfig(current => ({ + ...current, + additionalModelOptionsCache: previousOptions, + additionalModelOptionsCacheScope: previousScope, + })) + } +}) + test('getModelOptionsBase: 3P path omits inactive profile options when env NOT applied', async () => { // If the user hasn't gone through `/provider` yet (profile env not applied), // surfacing cross-profile switching would be confusing — they haven't opted @@ -742,8 +812,11 @@ test('getModelOptionsBase: Claude subscriber branch appends inactive profile opt test('getModelOptionsBase: MiniMax catalog branch appends inactive profile options', async () => { const { active, inactive } = activeAndInactivePair() + active.provider = 'minimax' + active.baseUrl = 'https://api.minimax.io/anthropic' activeMiniMaxOverride = { cachedModels: [ + { value: 'MiniMax-M3.1-Flash', label: 'MiniMax-M3.1-Flash', description: 'MiniMax' }, { value: 'MiniMax-M2.7', label: 'MiniMax-M2.7', description: 'MiniMax' }, ], } @@ -756,7 +829,8 @@ test('getModelOptionsBase: MiniMax catalog branch appends inactive profile optio ], }) const options = getModelOptions(false) - expect(options.some(o => o.value === 'MiniMax-M2.7')).toBe(true) + expect(options.some(o => o.value === 'MiniMax-M3.1-Flash')).toBe(true) + expect(options.some(o => o.value === 'MiniMax-M2.7')).toBe(false) expect( options .filter(o => o.switchToProfileId !== undefined) @@ -765,6 +839,59 @@ test('getModelOptionsBase: MiniMax catalog branch appends inactive profile optio }) }) +test.each(['MiniMax-M2.7', 'minimax-m3-free', 'minimax-m3.1-flash-free'])('MiniMax picker keeps saved %s without restoring other retired suggestions', async savedModel => { + const { active, inactive } = activeAndInactivePair() + active.provider = 'minimax' + active.baseUrl = 'https://api.minimax.io/anthropic' + active.model = savedModel + activeMiniMaxOverride = { + cachedModels: ['MiniMax-M3.1-Flash', 'MiniMax-M3', 'MiniMax-M2.5', 'minimax-m3-free', 'minimax-m3.1-flash-free'].map(value => ({ value, label: value, description: 'MiniMax' })), + } + await withProfileEnvApplied(async () => { + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [active, inactive], + getActiveProviderProfile: () => active, + getProfileModelOptions: profile => [ + { value: profile.model, label: profile.model, description: profile.name }, + ], + }) + const values = getModelOptions(false).map(option => option.value) + expect(values).toContain('MiniMax-M3.1-Flash') + expect(values).toContain('MiniMax-M3') + expect(values).toContain(savedModel) + expect(values).not.toContain('MiniMax-M2.5') + for (const retired of ['minimax-m3-free', 'minimax-m3.1-flash-free']) { + if (retired !== savedModel) expect(values).not.toContain(retired) + } + expect(active.model).toBe(savedModel) + }) +}) + +test.each([ + ['https://opencode.ai/zen/v1', 'gpt-5.4'], + ['https://opencode.ai/zen/go/v1', 'glm-5.1'], +])('getModelOptions retains available MiniMax gateway choices on %s', async (baseUrl, model) => { + const restoreEnv = snapshotEnv([ + 'CLAUDE_CODE_USE_OPENAI', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_MODEL', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + ], { clear: true }) + try { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = baseUrl + process.env.OPENAI_MODEL = model + const { getModelOptions } = await importFreshModelOptionsModule({ + getProviderProfiles: () => [], + getActiveProviderProfile: () => undefined, + }) + const values = getModelOptions(false).map(option => option.value) + expect(values).toContain('minimax-m2.7') + expect(values).not.toContain('minimax-m3-free') + expect(values).not.toContain('minimax-m3.1-flash-free') + } finally { + restoreEnv() + } +}) + test('getModelOptionsBase: Xiaomi MiMo catalog branch appends inactive profile options', async () => { const { active, inactive } = activeAndInactivePair() activeXiaomiOverride = { diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 259f544a4..0b68477aa 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -8,6 +8,7 @@ import { } from '../../integrations/routeMetadata.js' import { getAdditionalModelOptionsCacheScope, + isLocalProviderUrl, resolveProviderRequest, } from '../../services/api/providerConfig.js' import { @@ -45,14 +46,17 @@ import { getGlobalConfig } from '../config.js' import { getActiveOpenAIModelOptionsCache, getActiveProviderProfile, + getConfiguredProfileModelOptions, getProfileModelOptions, getProviderProfiles, + resolveProfileCapabilityRouteId, } from '../providerProfiles.js' import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js' import { getCachedNvidiaNimModelOptions, isNvidiaNimProvider } from './nvidiaNimModels.js' import { getCachedMiniMaxModelOptions, isMiniMaxProvider } from './minimaxModels.js' import { getCachedXiaomiMimoModelOptions, isXiaomiMimoProvider } from './xiaomi-mimoModels.js' import { getAntModels } from './antModels.js' +import { isCurrentMiniMaxModelOption } from './minimaxCatalog.js' // @[MODEL LAUNCH]: Update all the available and default model option strings below. @@ -1071,7 +1075,42 @@ export function getModelOptions(fastMode = false): ModelOption[] { return filterModelOptionsByAllowlist(getModelOptionsBase(fastMode)) } - const options = getModelOptionsBase(fastMode) + const activeProfile = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1' + ? getActiveProviderProfile() + : undefined + const configuredOptions = activeProfile + ? getConfiguredProfileModelOptions(activeProfile) + : [] + // Most models need no retirement decision. Resolve the route only for a + // filtered MiniMax name, and share that metadata lookup across this build. + let suggestionRouteId: string | null | undefined + let suggestionUsesLocalEndpoint = false + const isSuggestedModel = (model: ModelSetting): boolean => { + if (isCurrentMiniMaxModelOption(model)) return true + if (suggestionRouteId === undefined) { + const provider = getAPIProvider() + suggestionRouteId = provider === 'openai' && isOllamaProvider() + ? 'ollama' + : activeProfile + ? resolveProfileCapabilityRouteId(activeProfile.provider, activeProfile.baseUrl) + : getActiveOpenAIRouteId() + suggestionUsesLocalEndpoint = provider === 'openai' && + isLocalProviderUrl(resolveProviderRequest().baseUrl) + } + return isCurrentMiniMaxModelOption(model, suggestionRouteId, suggestionUsesLocalEndpoint) + } + const options = getModelOptionsBase(fastMode).filter(option => + option.switchToProfileId || + isSuggestedModel(option.value) || + configuredOptions.some(configured => configured.value === option.value), + ) + + for (const configured of configuredOptions) { + if (!isSuggestedModel(configured.value) && + !options.some(option => option.value === configured.value)) { + options.push(configured) + } + } // Route-catalog lookup state is built once per options build (on first use) // and shared by every catalog check below, so repeated lookups — the env @@ -1104,6 +1143,7 @@ export function getModelOptions(fastMode = false): ModelOption[] { // Append additional model options fetched during bootstrap for (const opt of getScopedAdditionalModelOptions()) { + if (!isSuggestedModel(opt.value)) continue const catalogOption = findRouteCatalogOption( getSharedRouteCatalogContext(), opt.value, diff --git a/src/utils/model/routeCatalogOptions.ts b/src/utils/model/routeCatalogOptions.ts index 84009a436..a17af5883 100644 --- a/src/utils/model/routeCatalogOptions.ts +++ b/src/utils/model/routeCatalogOptions.ts @@ -1,5 +1,6 @@ import type { ModelCatalogEntry } from '../../integrations/descriptors.js' import type { ModelOption } from './modelOptions.js' +import { isCurrentMiniMaxModelOption } from './minimaxCatalog.js' function toDescription( entry: ModelCatalogEntry, @@ -45,7 +46,11 @@ export function buildRouteCatalogModelOptions( routeLabel: string, entries: ModelCatalogEntry[], routeDefaultModel?: string, + routeId?: string, ): ModelOption[] { + // Preserve raw catalog metadata for explicit legacy IDs, but do not offer + // retired MiniMax text models as ordinary suggestions (including discovery). + entries = entries.filter(entry => isCurrentMiniMaxModelOption(entry.apiName, routeId)) const seen = new Set() const options: ModelOption[] = [] const apiNameCounts = new Map() diff --git a/src/utils/model/validateModel.ts b/src/utils/model/validateModel.ts index 155a2c7bb..ef59d6519 100644 --- a/src/utils/model/validateModel.ts +++ b/src/utils/model/validateModel.ts @@ -16,7 +16,7 @@ import { getDiscoveredNvidiaNimModelIds, isNvidiaNimProvider, } from './nvidiaNimModels.js' -import { getCachedMiniMaxModelOptions, isMiniMaxProvider } from './minimaxModels.js' +import { getCachedMiniMaxModelOptions, isKnownMiniMaxModel, isMiniMaxProvider } from './minimaxModels.js' // Cache valid models to avoid repeated API calls const validModelCache = new Map() @@ -86,10 +86,10 @@ export async function validateModel( } } - // For MiniMax provider, validate against cached model list + // Known explicit IDs are independent of the filtered picker suggestions. if (isMiniMaxProvider()) { const minimaxModels = getCachedMiniMaxModelOptions() - const found = minimaxModels.some(m => m.value === normalizedModel) + const found = isKnownMiniMaxModel(normalizedModel) if (found) { validModelCache.set(normalizedModel, true) return { valid: true } diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index c841e349e..203f8e172 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -1006,7 +1006,7 @@ describe('applyProviderFlag - minimax', () => { expect(result.error).toBeUndefined() expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.minimax.io/anthropic') - expect(process.env.ANTHROPIC_MODEL).toBe('MiniMax-M3') + expect(process.env.ANTHROPIC_MODEL).toBe('MiniMax-M3.1-Flash') expect(process.env.OPENAI_BASE_URL).toBeUndefined() expect(process.env.OPENAI_MODEL).toBeUndefined() }) diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 811e601a6..f82f174b0 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -610,7 +610,7 @@ export function applyProviderFlag( delete process.env.OPENAI_AUTH_SCHEME delete process.env.OPENAI_AUTH_HEADER_VALUE process.env.ANTHROPIC_BASE_URL = defaultBaseUrl ?? 'https://api.minimax.io/anthropic' - process.env.ANTHROPIC_MODEL = defaultModel ?? 'MiniMax-M3' + process.env.ANTHROPIC_MODEL = defaultModel ?? 'MiniMax-M3.1-Flash' if (model) process.env.ANTHROPIC_MODEL = model if (process.env.MINIMAX_API_KEY && !process.env.ANTHROPIC_API_KEY) { process.env.ANTHROPIC_API_KEY = process.env.MINIMAX_API_KEY diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 85439aaf1..6ac1a6469 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -3448,6 +3448,30 @@ describe('applyActiveProviderProfileFromConfig', () => { expect(process.env.OPENAI_MODEL).toBe('gpt-4o') }) + test.each(['minimax-m3-free', 'minimax-m3.1-flash-free'])('applies saved retired Zen model %s without rewriting the configured model', async savedModel => { + const { _setSavedModelOverrideForTesting, applyActiveProviderProfileFromConfig } = + await importFreshProviderProfileModules() + const profile = buildProfile({ + id: 'saved_zen', + provider: 'opencode', + baseUrl: 'https://opencode.ai/zen/v1', + model: 'gpt-5.4', + }) + _setSavedModelOverrideForTesting(savedModel) + try { + const applied = applyActiveProviderProfileFromConfig({ + ...createMockConfigState(), + providerProfiles: [profile], + activeProviderProfileId: profile.id, + }) + expect(applied?.id).toBe(profile.id) + expect(process.env.OPENAI_MODEL).toBe(savedModel) + expect(profile.model).toBe('gpt-5.4') + } finally { + _setSavedModelOverrideForTesting(undefined) + } + }) + test.each(['', '?reasoning=high', '?thinking=disabled&reasoning=high'])( 'preserves context limits for configured and saved models across profile rehydration (%j)', async query => { @@ -3941,7 +3965,7 @@ describe('getProviderPresetDefaults', () => { expect(defaults.requiresApiKey).toBe(true) }) - test('minimax preset defaults to MiniMax M3', async () => { + test('minimax preset defaults to MiniMax M3.1 Flash', async () => { const { getProviderPresetDefaults } = await importFreshProviderProfileModules() const defaults = getProviderPresetDefaults('minimax') @@ -3949,7 +3973,7 @@ describe('getProviderPresetDefaults', () => { expect(defaults.provider).toBe('minimax') expect(defaults.name).toBe('MiniMax') expect(defaults.baseUrl).toBe('https://api.minimax.io/anthropic') - expect(defaults.model).toBe('MiniMax-M3') + expect(defaults.model).toBe('MiniMax-M3.1-Flash') expect(defaults.requiresApiKey).toBe(true) }) @@ -6124,6 +6148,31 @@ describe('getProfileModelOptions', () => { ]) }) + test.each([ + ['minimax', 'https://api.minimax.io/anthropic', 'MiniMax-M2.7'], + ['opencode', 'https://opencode.ai/zen/v1', 'minimax-m3-free'], + ['opencode', 'https://opencode.ai/zen/v1', 'minimax-m3.1-flash-free'], + ])('%s at %s keeps saved %s while filtering its retired suggestions', async (provider, baseUrl, savedModel) => { + const { getProfileModelOptions } = await importFreshProviderProfileModules() + mockConfigState = { + ...createMockConfigState(), + openaiAdditionalModelOptionsCacheByProfile: { + provider_test: ['MiniMax-M2.5', 'MiniMax-M2.7', 'minimax-m3-free', 'minimax-m3.1-flash-free', 'MiniMax-M3.1-Flash', 'MiniMax-M3', 'MiniMax-Vision-01', 'gpt-4o'].map(value => ({ value, label: value, description: 'Discovered from API' })), + }, + } + const profile = buildProfile({ + id: 'provider_test', provider, baseUrl, + model: `${savedModel}, custom-minimax`, + }) + expect(getProfileModelOptions(profile).map(option => option.value)).toEqual([ + savedModel, 'custom-minimax', + ...(provider === 'opencode' ? ['MiniMax-M2.5', 'MiniMax-M2.7'] : []), + 'MiniMax-M3.1-Flash', 'MiniMax-M3', 'MiniMax-Vision-01', 'gpt-4o', + ]) + expect(profile.model).toBe(`${savedModel}, custom-minimax`) + expect(mockConfigState.openaiAdditionalModelOptionsCacheByProfile?.provider_test?.[0]).toMatchObject({ value: 'MiniMax-M2.5' }) + }) + test('returns empty array for empty model field', async () => { const { getProfileModelOptions } = await importFreshProviderProfileModules() @@ -6140,6 +6189,50 @@ describe('getProfileModelOptions', () => { }) describe('setActiveProviderProfile model cache', () => { + test.each([ + ['ollama', 'http://localhost:11434/v1'], + ['ollama', 'https://ollama-proxy.example/v1'], + ['lmstudio', 'http://localhost:1234/v1'], + ['lmstudio', 'https://lmstudio-proxy.example/v1'], + ['atomic-chat', 'http://127.0.0.1:1337/v1'], + ['custom', 'http://localhost:8080/v1'], + ['opencode', 'https://opencode.ai/zen/v1'], + ['opencode-go', 'https://opencode.ai/zen/go/v1'], + ['openrouter', 'https://openrouter.ai/api/v1'], + ['custom', 'https://models.example/v1'], + ] as const)('activation preserves advertised models for %s at %s', async (provider, baseUrl) => { + const { setActiveProviderProfile, getActiveOpenAIModelOptionsCache } = await importFreshProviderProfileModules() + const models = ['minimax-m3.1-flash:cloud', 'minimax-m3:cloud', 'minimax-m2.7:cloud', 'minimaxai/minimax-m2.7', 'minimax-m3.1-flash-q4_k_m', 'minimax-m2.5', 'minimax-m2.7'] + const profile = buildProfile({ id: 'local_models', provider, baseUrl, model: 'qwen3:latest' }) + mockConfigState = { + ...createMockConfigState(), + providerProfiles: [profile], + openaiAdditionalModelOptionsCacheByProfile: { + [profile.id]: models.map(value => ({ value, label: value, description: 'Discovered from Ollama' })), + }, + } + expect(setActiveProviderProfile(profile.id, { configDir: testConfigDir ?? undefined })?.id).toBe(profile.id) + expect(getActiveOpenAIModelOptionsCache().map(option => option.value)).toEqual(['qwen3:latest', ...models]) + expect(mockConfigState.openaiAdditionalModelOptionsCacheByProfile[profile.id]?.map(option => (option as { value: string }).value)).toEqual(['qwen3:latest', ...models]) + expect(profile.model).toBe('qwen3:latest') + }) + + test('activation ignores malformed persisted option values before filtering', async () => { + const { setActiveProviderProfile, getActiveOpenAIModelOptionsCache } = await importFreshProviderProfileModules() + const profile = buildProfile({ id: 'malformed_options', model: 'gpt-4o' }) + mockConfigState = { + ...createMockConfigState(), + providerProfiles: [profile], + // Persisted JSON bypasses the ModelOption type; retain the existing + // non-string guard, including records whose value is absent. + openaiAdditionalModelOptionsCacheByProfile: { + [profile.id]: JSON.parse('[{},{"value":42},{"value":true},{"value":{}},{"value":[]},{"value":null},{"value":" "},{"value":"gpt-4.1","label":"gpt-4.1","description":"Discovered"}]'), + }, + } + expect(setActiveProviderProfile(profile.id, { configDir: testConfigDir ?? undefined })?.id).toBe(profile.id) + expect(getActiveOpenAIModelOptionsCache().map(option => option.value)).toEqual(['gpt-4o', 'gpt-4.1']) + }) + test('populates model cache with all models from multi-model profile on activation', async () => { const { setActiveProviderProfile, diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index be5a1cf75..acba982c1 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'crypto' import { getAdditionalModelOptionsCacheScope, + isLocalProviderUrl, isCodexAlias, isCodexBaseUrl, isCodexEligibleGpt5Model, @@ -12,6 +13,7 @@ import { type ProviderProfile, } from './config.js' import type { ModelOption } from './model/modelOptions.js' +import { isCurrentMiniMaxModelOption } from './model/minimaxCatalog.js' import { getPrimaryModel, parseModelList } from './providerModels.js' import { buildCompatibilityProcessEnv, @@ -1786,9 +1788,14 @@ export function getProfileModelOptions( config = getGlobalConfig(), ): ModelOption[] { const configuredOptions = getConfiguredProfileModelOptions(profile) + const routeId = resolveProfileCapabilityRouteId(profile.provider, profile.baseUrl) + const localEndpoint = isLocalProviderUrl(profile.baseUrl) return mergeModelOptionsByValue( configuredOptions, - getModelCacheByProfile(profile.id, config), + getModelCacheByProfile(profile.id, config).filter(option => + typeof option.value === 'string' && + isCurrentMiniMaxModelOption(option.value, routeId, localEndpoint), + ), ) } diff --git a/web/src/data/providers.ts b/web/src/data/providers.ts index 1dfe68b8a..3add3eb35 100644 --- a/web/src/data/providers.ts +++ b/web/src/data/providers.ts @@ -170,7 +170,7 @@ export const providers: Provider[] = [ group: 'gateways', setup: '/provider or env vars', envVars: ['OPENCODE_API_KEY'], - notes: 'Pay-as-you-go gateway (Zen, 48 models) and a $10/mo subscription for open models (Go); one key for both.', + notes: 'Pay-as-you-go gateway (Zen) and a $10/mo subscription for open models (Go); one key for both.', }, { id: 'clinepass', @@ -292,7 +292,7 @@ export const providers: Provider[] = [ group: 'vendors', setup: '/provider or env vars', envVars: ['MINIMAX_API_KEY'], - notes: 'Anthropic-compatible MiniMax endpoint; defaults to MiniMax-M3.', + notes: 'Anthropic-compatible MiniMax endpoint; defaults to MiniMax-M3.1-Flash.', }, { id: 'longcat',