diff --git a/docs/issues/minimax-model-matching/plan.md b/docs/issues/minimax-model-matching/plan.md new file mode 100644 index 000000000..cc381ffa2 --- /dev/null +++ b/docs/issues/minimax-model-matching/plan.md @@ -0,0 +1,7 @@ +# Plan + +1. Fix provider DB model ID comparison in `ModelConfigHelper`. +2. Add a narrow MiniMax-M3 config correction for interleaved thinking and the documented context + window. +3. Add MiniMax-M3 adaptive thinking provider options for Anthropic-compatible runtime. +4. Cover the behavior with focused unit tests. diff --git a/docs/issues/minimax-model-matching/spec.md b/docs/issues/minimax-model-matching/spec.md new file mode 100644 index 000000000..9f3960894 --- /dev/null +++ b/docs/issues/minimax-model-matching/spec.md @@ -0,0 +1,27 @@ +# MiniMax model matching and thinking + +## Problem + +MiniMax provider models with mixed-case IDs such as `MiniMax-M3` and `MiniMax-M2.5` can miss +provider DB-derived configuration because model config lookup lowercases the requested model ID but +compares it with the raw DB model ID. + +MiniMax-M3 also requires explicit Anthropic-compatible `thinking: { type: "adaptive" }` to emit +thinking blocks. Current Anthropic-compatible provider options only send `thinking` for official +Anthropic adaptive reasoning or budget-based thinking. + +## Acceptance Criteria + +- Provider DB model lookup matches model IDs case-insensitively while keeping provider matching + strict. +- MiniMax mixed-case model IDs inherit provider DB context, output, multimodal, tool-call, and + reasoning defaults. +- MiniMax-M3 defaults to interleaved thinking compatibility even if the provider DB source has not + caught up. +- MiniMax-M3 sends Anthropic-compatible adaptive thinking when reasoning is enabled. +- Reasoning-disabled MiniMax-M3 requests do not send adaptive thinking. + +## Non-goals + +- Do not route MiniMax through Anthropic provider capability semantics. +- Do not change Claude or generic Anthropic proxy behavior. diff --git a/docs/issues/minimax-model-matching/tasks.md b/docs/issues/minimax-model-matching/tasks.md new file mode 100644 index 000000000..9e519f31b --- /dev/null +++ b/docs/issues/minimax-model-matching/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Add provider DB case-insensitive matching coverage. +- [x] Fix `ModelConfigHelper` matching and MiniMax-M3 defaults. +- [x] Add MiniMax-M3 provider options coverage. +- [x] Run focused tests. diff --git a/src/main/presenter/configPresenter/modelConfig.ts b/src/main/presenter/configPresenter/modelConfig.ts index 39b97aae5..6e193de5c 100644 --- a/src/main/presenter/configPresenter/modelConfig.ts +++ b/src/main/presenter/configPresenter/modelConfig.ts @@ -34,6 +34,20 @@ import type { StoreLike } from './storeLike' const SPECIAL_CONCAT_CHAR = '-_-' const MODEL_CONFIG_META_KEY = '__meta__' +const MINIMAX_M3_CONTEXT_LENGTH = 1_000_000 + +const normalizeProviderDbModelId = (modelId: string | undefined): string | undefined => { + const normalized = modelId ? modelId.toLowerCase() : modelId + return normalized ? normalized.replace(/^models\//, '') : normalized +} + +const isMiniMaxProviderId = (providerId: string | undefined): boolean => { + const normalized = providerId?.trim().toLowerCase() + return normalized === 'minimax' || normalized === 'minimax-cn' +} + +const isMiniMaxM3Model = (providerId: string | undefined, modelId: string): boolean => + isMiniMaxProviderId(providerId) && modelId.trim().toLowerCase() === 'minimax-m3' const normalizeVerbosityValue = ( portrait: ReasoningPortrait | null, @@ -154,7 +168,17 @@ export class ModelConfigHelper { return config } - return applyMoonshotKimiReasoningTemperaturePolicy(providerId, modelId, config) + const policyConfig = applyMoonshotKimiReasoningTemperaturePolicy(providerId, modelId, config) + + if (!isMiniMaxM3Model(providerId, modelId)) { + return policyConfig + } + + return { + ...policyConfig, + contextLength: Math.max(policyConfig.contextLength ?? 0, MINIMAX_M3_CONTEXT_LENGTH), + forceInterleavedThinkingCompat: true + } } private buildConfigFromProviderModel(model: ProviderModel, providerId: string): ModelConfig { @@ -178,10 +202,13 @@ export class ModelConfigHelper { portrait, portrait?.verbosity ?? model.reasoning?.verbosity ) + const contextLimit = isMiniMaxM3Model(providerId, model.id) + ? Math.max(model.limit?.context ?? 0, MINIMAX_M3_CONTEXT_LENGTH) + : model.limit?.context return this.applyProviderSpecificPolicies(providerId, model.id, { maxTokens: resolveDerivedModelMaxTokens(model.limit?.output), - contextLength: resolveModelContextLength(model.limit?.context), + contextLength: resolveModelContextLength(contextLimit), timeout: DEFAULT_MODEL_TIMEOUT, temperature: 0.6, topP: undefined, @@ -199,7 +226,9 @@ export class ModelConfigHelper { ? ApiEndpointType.AudioSpeech : ApiEndpointType.Chat, thinkingBudget, - forceInterleavedThinkingCompat, + forceInterleavedThinkingCompat: isMiniMaxM3Model(providerId, model.id) + ? true + : forceInterleavedThinkingCompat, reasoningEffort, reasoningVisibility, verbosity, @@ -455,7 +484,7 @@ export class ModelConfigHelper { ) { for (let i = 0; i < providerEntry.models.length; i += 1) { const candidate = providerEntry.models[i] - if (candidate && candidate.id === normModelId) { + if (candidate && normalizeProviderDbModelId(candidate.id) === normModelId) { finalConfig = this.buildConfigFromProviderModel(candidate, resolvedProviderId) break } @@ -472,7 +501,7 @@ export class ModelConfigHelper { for (let j = 0; j < candidateProvider.models.length; j += 1) { const candidateModel = candidateProvider.models[j] - if (candidateModel && candidateModel.id === normModelId) { + if (candidateModel && normalizeProviderDbModelId(candidateModel.id) === normModelId) { finalConfig = this.buildConfigFromProviderModel(candidateModel, candidateProvider.id) break } diff --git a/src/main/presenter/llmProviderPresenter/aiSdk/providerOptionsMapper.ts b/src/main/presenter/llmProviderPresenter/aiSdk/providerOptionsMapper.ts index c87f3b239..39fbbec8d 100644 --- a/src/main/presenter/llmProviderPresenter/aiSdk/providerOptionsMapper.ts +++ b/src/main/presenter/llmProviderPresenter/aiSdk/providerOptionsMapper.ts @@ -118,6 +118,23 @@ function supportsGrokReasoningEffort(modelId: string): boolean { ) } +function normalizeMiniMaxModelId(modelId: string): string { + const normalized = modelId.trim().toLowerCase() + return normalized.includes('/') ? normalized.slice(normalized.lastIndexOf('/') + 1) : normalized +} + +function supportsMiniMaxAdaptiveThinking( + providerId: string, + capabilityProviderId: string, + modelId: string +): boolean { + const providerIds = [providerId, capabilityProviderId].map((id) => id.trim().toLowerCase()) + return ( + providerIds.some((id) => id === 'minimax' || id === 'minimax-cn') && + normalizeMiniMaxModelId(modelId) === 'minimax-m3' + ) +} + export function buildProviderOptions( params: BuildProviderOptionsParams ): ProviderOptionsMappingResult { @@ -264,6 +281,17 @@ export function buildProviderOptions( type: 'adaptive', display: resolvedVisibility } + } else if ( + supportsMiniMaxAdaptiveThinking( + params.providerId, + params.capabilityProviderId, + params.modelId + ) && + reasoningEnabled + ) { + config.thinking = { + type: 'adaptive' + } } else if (reasoningEnabled && params.modelConfig.thinkingBudget !== undefined) { config.thinking = { type: 'enabled', diff --git a/test/main/presenter/llmProviderPresenter/aiSdkProviderOptionsMapper.test.ts b/test/main/presenter/llmProviderPresenter/aiSdkProviderOptionsMapper.test.ts index 080b583cb..cc4572e2b 100644 --- a/test/main/presenter/llmProviderPresenter/aiSdkProviderOptionsMapper.test.ts +++ b/test/main/presenter/llmProviderPresenter/aiSdkProviderOptionsMapper.test.ts @@ -313,6 +313,57 @@ describe('AI SDK provider options', () => { expect(result.providerOptions?.anthropic).not.toHaveProperty('effort') }) + it('maps MiniMax-M3 reasoning to Anthropic-compatible adaptive thinking', () => { + mockGetReasoningPortrait.mockReturnValue({ + supported: true, + defaultEnabled: true + }) + + const result = buildProviderOptions({ + providerId: 'minimax', + capabilityProviderId: 'minimax', + providerOptionsKey: 'anthropic', + apiType: 'anthropic', + modelId: 'MiniMax-M3', + modelConfig: { + reasoning: true + } as any, + tools: [], + messages: [] + }) + + expect(result.providerOptions?.anthropic).toEqual({ + toolStreaming: false, + thinking: { + type: 'adaptive' + } + }) + }) + + it('does not send MiniMax-M3 adaptive thinking when reasoning is disabled', () => { + mockGetReasoningPortrait.mockReturnValue({ + supported: true, + defaultEnabled: true + }) + + const result = buildProviderOptions({ + providerId: 'minimax', + capabilityProviderId: 'minimax', + providerOptionsKey: 'anthropic', + apiType: 'anthropic', + modelId: 'MiniMax-M3', + modelConfig: { + reasoning: false + } as any, + tools: [], + messages: [] + }) + + expect(result.providerOptions?.anthropic).toEqual({ + toolStreaming: false + }) + }) + it('keeps aws bedrock anthropic routes on the compatible reasoning dialect', () => { const result = buildProviderOptions({ providerId: 'aws-bedrock', diff --git a/test/main/presenter/providerDbModelConfig.test.ts b/test/main/presenter/providerDbModelConfig.test.ts index e76551a52..b68d01ccd 100644 --- a/test/main/presenter/providerDbModelConfig.test.ts +++ b/test/main/presenter/providerDbModelConfig.test.ts @@ -175,6 +175,42 @@ describe('Provider DB strict matching + user overrides', () => { } } ] + }, + minimax: { + id: 'minimax', + name: 'MiniMax', + models: [ + { + id: 'MiniMax-M2.5', + limit: { context: 204800, output: 131072 }, + modalities: { input: ['text'], output: ['text'] }, + tool_call: true, + reasoning: { + supported: true, + default: true + }, + extra_capabilities: { + reasoning: { + supported: true + } + } + }, + { + id: 'MiniMax-M3', + limit: { context: 512000, output: 128000 }, + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + tool_call: true, + reasoning: { + supported: true, + default: true + }, + extra_capabilities: { + reasoning: { + supported: true + } + } + } + ] } } } @@ -325,6 +361,63 @@ describe('Provider DB strict matching + user overrides', () => { expect(cfg.maxTokens).toBe(2000) }) + it('matches mixed-case provider DB model IDs case-insensitively', () => { + const helper = new ModelConfigHelper('1.0.0') + + const cfg = helper.getModelConfig('minimax-m2.5', 'minimax') + + expect(cfg.contextLength).toBe(204800) + expect(cfg.maxTokens).toBe(32000) + expect(cfg.functionCall).toBe(true) + expect(cfg.reasoning).toBe(true) + }) + + it('applies MiniMax-M3 provider defaults when the provider DB cache is stale', () => { + const helper = new ModelConfigHelper('1.0.0') + + const cfg = helper.getModelConfig('minimax-m3', 'minimax') + + expect(cfg.contextLength).toBe(1_000_000) + expect(cfg.maxTokens).toBe(32000) + expect(cfg.vision).toBe(true) + expect(cfg.functionCall).toBe(true) + expect(cfg.reasoning).toBe(true) + expect(cfg.forceInterleavedThinkingCompat).toBe(true) + }) + + it('keeps MiniMax-M3 context floor after provider cache merge', () => { + const helper = new ModelConfigHelper('1.0.0') + const helperAny = helper as any + const providerCacheKey = helperAny.generateCacheKey('minimax', 'minimax-m3') + + helper.importConfigs( + { + [providerCacheKey]: { + id: 'minimax-m3', + providerId: 'minimax', + source: 'provider', + config: { + maxTokens: 32000, + contextLength: 512000, + temperature: 0.6, + vision: true, + functionCall: true, + reasoning: true, + type: ModelType.Chat, + isUserDefined: false + } + } + }, + false + ) + + const cfg = helper.getModelConfig('minimax-m3', 'minimax') + + expect(cfg.contextLength).toBe(1_000_000) + expect(cfg.forceInterleavedThinkingCompat).toBe(true) + expect(cfg.isUserDefined).toBe(false) + }) + it('prefers portrait defaults over legacy reasoning defaults', () => { const helper = new ModelConfigHelper('1.0.0')