feat(minimax): default to M3.1 Flash and refresh text suggestions (#2297)

* feat(minimax): default to M3.1 Flash and refresh text suggestions

* fix(minimax): preserve explicit selections and retire stale Zen suggestions

* fix(minimax): preserve Ollama discovery and validate cached options

* fix(minimax): preserve local discovery and validate picker caches

* fix(minimax): preserve gateway-owned model availability

---------

Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
Co-authored-by: jatmn <the@jat.mn>
This commit is contained in:
Octopus
2026-10-25 16:38:21 -07:00
committed by GitHub
co-authored by octo-patch jatmn
parent e3c9a873dc
commit 8be7274b90
28 changed files with 653 additions and 73 deletions
+1 -1
View File
@@ -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` |
+8 -2
View File
@@ -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
+113
View File
@@ -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<ReturnType<typeof render>> | undefined
try {
const element = await call(result => { if (result) messages.push(result) }, {} as never, model)
instance = await render(
<AppStateProvider initialState={{ ...getDefaultAppState(), mainLoopModel: 'MiniMax-M3.1-Flash' }}>
{element}
<CaptureModel />
</AppStateProvider>,
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<ReturnType<typeof renderModelCommandWithCapturedPicker>> | 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 = {
+4
View File
@@ -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<string> {
discoveryContext.routeLabel,
result?.models ?? [],
discoveryContext.routeDefaultModel,
discoveryContext.routeId,
),
{
profileModelSurface: discoveryContext.profileModelSurface,
+1
View File
@@ -13,6 +13,7 @@ export default defineBrand({
supportsPreciseTokenCount: false,
},
modelIds: [
'minimax-m3.1-flash',
'minimax-m3',
'minimax-m2',
'minimax-m2.1',
+4 -1
View File
@@ -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'],
},
@@ -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"
],
+12 -1
View File
@@ -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({
+5 -2
View File
@@ -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 },
+3 -2
View File
@@ -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' },
+60
View File
@@ -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<string, unknown> = {}
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<string, unknown>
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']) {
@@ -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<string, unknown> = {}
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',
+3 -1
View File
@@ -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
+56
View File
@@ -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)
})
+23
View File
@@ -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)
}
+7 -3
View File
@@ -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',
])
})
+22 -30
View File
@@ -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',
)
}
@@ -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 () => {
+7 -10
View File
@@ -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') {
@@ -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 = {
+41 -1
View File
@@ -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,
+5
View File
@@ -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<string>()
const options: ModelOption[] = []
const apiNameCounts = new Map<string, number>()
+3 -3
View File
@@ -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<string, boolean>()
@@ -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 }
+1 -1
View File
@@ -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()
})
+1 -1
View File
@@ -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
+95 -2
View File
@@ -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,
+8 -1
View File
@@ -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),
),
)
}
+2 -2
View File
@@ -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',