diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index ff766f2431..273bc7190e 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -310,12 +310,24 @@ export const MODEL_SPECS: Record = { supportsTools: true, }, + // ── MiniMax M3 (1M context, 512K max output) ───────────────────── + // max output verified against MiniMax docs / OpenRouter / Artificial + // Analysis (Nov 2025 launch): 1,048,576-token context, up to 512K output. + "minimax-m3": { + maxOutputTokens: 512000, + contextWindow: 1048576, + supportsThinking: true, + supportsTools: true, + aliases: ["MiniMax-M3", "MiniMaxAI/MiniMax-M3"], + }, + // ── MiniMax M2.x (200K context family) ─────────────────────────── "minimax-m2.7": { maxOutputTokens: 131072, contextWindow: 204800, supportsThinking: true, supportsTools: true, + aliases: ["MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.7"], }, "minimax-m2.5": { maxOutputTokens: 131072, @@ -356,14 +368,23 @@ export const MODEL_SPECS: Record = { export function getModelSpec(modelId: string): ModelSpec | undefined { if (MODEL_SPECS[modelId]) return MODEL_SPECS[modelId]; - // Buscas por alias + // Case-insensitive lookups: upstream model ids are often capitalized + // (e.g. "MiniMax-M2.7") while specs/aliases use lowercase ids (#3141). + const lower = modelId.toLowerCase(); + + // Exact match (case-insensitive) for (const [canonical, spec] of Object.entries(MODEL_SPECS)) { - if (spec.aliases?.includes(modelId)) return spec; + if (canonical.toLowerCase() === lower) return spec; } - // Prefix matching + // Buscas por alias (case-insensitive) + for (const [, spec] of Object.entries(MODEL_SPECS)) { + if (spec.aliases?.some((alias) => alias.toLowerCase() === lower)) return spec; + } + + // Prefix matching (case-insensitive) for (const [key, spec] of Object.entries(MODEL_SPECS)) { - if (key !== "__default__" && modelId.startsWith(key)) return spec; + if (key !== "__default__" && lower.startsWith(key.toLowerCase())) return spec; } return undefined; diff --git a/tests/unit/minimax-m3-maxtokens.test.ts b/tests/unit/minimax-m3-maxtokens.test.ts new file mode 100644 index 0000000000..93a63d9b40 --- /dev/null +++ b/tests/unit/minimax-m3-maxtokens.test.ts @@ -0,0 +1,80 @@ +/** + * tests/unit/minimax-m3-maxtokens.test.ts + * + * Regression for issue #3141 — "Omniroute sets max_tokens = 8192 for + * minimax/MiniMax-M3" (reporter @totaltube). + * + * minimax-coding / minimax route through the Claude translator, which calls + * fitThinkingToMaxTokens(model, callerMaxTokens, …) → capMaxOutputTokens(). + * The caller's larger max_tokens was silently capped to the 8192 + * MODEL_SPECS.__default__ ceiling because: + * (a) MiniMax-M3 had no MODEL_SPECS entry at all, and the registry models + * declare no maxOutputTokens, so resolution fell through to __default__; + * (b) MiniMax-M2.7 (the upstream, capitalized id) never matched its existing + * lowercase "minimax-m2.7" spec because getModelSpec was case-SENSITIVE. + * + * Both lookups must now resolve to the real family ceiling (well above 8192). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-minimax-m3-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelCapabilities = await import("../../src/lib/modelCapabilities.ts"); +const { getModelSpec } = await import("../../src/shared/constants/modelSpecs.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const DEFAULT_CAP = 8192; + +test("#3141 MiniMax-M3 max_tokens is not capped to the 8192 default", () => { + const cap = modelCapabilities.capMaxOutputTokens({ provider: "minimax", model: "MiniMax-M3" }); + assert.ok( + cap > DEFAULT_CAP, + `expected MiniMax-M3 maxOutputTokens > ${DEFAULT_CAP}, got ${cap}` + ); +}); + +test("#3141 MiniMaxAI/MiniMax-M3 (prefixed id) resolves above the 8192 default", () => { + const cap = modelCapabilities.capMaxOutputTokens({ + provider: "minimax", + model: "MiniMaxAI/MiniMax-M3", + }); + assert.ok( + cap > DEFAULT_CAP, + `expected MiniMaxAI/MiniMax-M3 maxOutputTokens > ${DEFAULT_CAP}, got ${cap}` + ); +}); + +test("#3141 capitalized MiniMax-M2.7 resolves to its lowercase spec (case-insensitive)", () => { + const cap = modelCapabilities.capMaxOutputTokens({ provider: "minimax", model: "MiniMax-M2.7" }); + assert.ok( + cap > DEFAULT_CAP, + `expected MiniMax-M2.7 maxOutputTokens > ${DEFAULT_CAP}, got ${cap}` + ); +}); + +test("#3141 lowercase minimax-m2.7 spec is unchanged", () => { + const cap = modelCapabilities.capMaxOutputTokens({ provider: "minimax", model: "minimax-m2.7" }); + assert.ok( + cap > DEFAULT_CAP, + `expected minimax-m2.7 maxOutputTokens > ${DEFAULT_CAP}, got ${cap}` + ); +}); + +test("#3141 getModelSpec resolves MiniMax-M3 and capitalized M2.7 to family specs", () => { + assert.ok(getModelSpec("MiniMax-M3"), "MiniMax-M3 should have a spec"); + assert.ok( + getModelSpec("MiniMax-M2.7"), + "capitalized MiniMax-M2.7 should resolve to the minimax-m2.7 spec" + ); +});