diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index bc2e2a0e..8bd89d6a 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -337,6 +337,7 @@ export const PROVIDER_MODELS = { { id: "kimi-latest", name: "Kimi Latest" }, ], minimax: [ + { id: "MiniMax-M3", name: "MiniMax M3", targetFormat: "claude" }, { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, { id: "MiniMax-M2.1", name: "MiniMax M2.1" }, @@ -363,6 +364,7 @@ export const PROVIDER_MODELS = { { id: "qwen3-vl-plus", name: "Qwen3 VL Plus" }, ], "minimax-cn": [ + { id: "MiniMax-M3", name: "MiniMax M3", targetFormat: "claude" }, { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, { id: "MiniMax-M2.1", name: "MiniMax M2.1" }, diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 61d5cfcc..3c31e7a1 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -72,12 +72,16 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source // Claude if (targetFormat === FORMATS.CLAUDE) { - if (!responseBody.content) return responseBody; + // Always translate a Claude-format body to OpenAI, even if `content` is + // missing/null (e.g. M3 with max_tokens:1 spends the budget on thinking + // and returns `content: null`). Returning the raw body would leave the + // OpenAI client without a `choices` array and surface as a UI test error. + if (responseBody.content && !Array.isArray(responseBody.content)) return responseBody; let textContent = "", thinkingContent = ""; const toolCalls = []; - for (const block of responseBody.content) { + for (const block of (responseBody.content || [])) { if (block.type === "text") { // Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```) const raw = block.text ?? ""; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 277472b6..c0391b93 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -995,6 +995,12 @@ function formatMiniMaxQuotaName(model) { const rawName = getMiniMaxModelName(model); if (!rawName) return "MiniMax"; + // M3+ shared quota pool: MiniMax reports M-series as a single wildcard + // bucket ("MiniMax-M*"). Newer responses rename it to plain "general". + // Render both as a friendly series label rather than leaking the + // asterisk or the vague "general" word to the UI. + if (rawName === "MiniMax-M*" || rawName === "general") return "M-series"; + return rawName .replace(/[_-]+/g, " ") .replace(/\s+/g, " ") @@ -1005,6 +1011,15 @@ function formatMiniMaxQuotaName(model) { .replace(/\bHd\b/g, "HD"); } +function getMiniMaxProvidedPercent(model, snakeKey, camelKey) { + if (!model || typeof model !== "object") return null; + const raw = model[snakeKey] ?? model[camelKey]; + if (raw === null || raw === undefined) return null; + const num = Number(raw); + if (!Number.isFinite(num)) return null; + return Math.max(0, Math.min(100, num)); +} + function getMiniMaxSessionTotal(model) { return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0); } @@ -1014,7 +1029,12 @@ function getMiniMaxWeeklyTotal(model) { } function hasMiniMaxQuota(model) { - return getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0; + // Old format has real count totals; M3-era M-series buckets ship percent-only + // (count fields are 0) so accept those too. + if (getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0) return true; + if (getMiniMaxProvidedPercent(model, "current_interval_remaining_percent", "currentIntervalRemainingPercent") !== null) return true; + if (getMiniMaxProvidedPercent(model, "current_weekly_remaining_percent", "currentWeeklyRemainingPercent") !== null) return true; + return false; } function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) { @@ -1023,30 +1043,57 @@ function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endS return parseResetTime(getMiniMaxField(model, endSnake, endCamel)); } -function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining) { +function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining, providedPercent = null) { const safeTotal = Math.max(0, total); const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal); const remaining = Math.max(safeTotal - used, 0); + // M-series buckets ship percent-only (count = 0). Prefer the upstream value + // when present, otherwise fall back to the computed percentage. When the + // quota is unbounded (no count) and no upstream percent is available, surface + // the percent anyway as long as it is defined. + const remainingPercentage = providedPercentage(providedPercent, remaining, safeTotal); return { used, total: safeTotal, remaining, - remainingPercentage: safeTotal > 0 ? Math.max(0, Math.min(100, (remaining / safeTotal) * 100)) : 0, + remainingPercentage, resetAt, unlimited: false, }; } -function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, resetArgs, countMeansRemaining) { +function providedPercentage(provided, remaining, total) { + if (provided !== null && provided !== undefined && Number.isFinite(provided)) { + return Math.max(0, Math.min(100, provided)); + } + return total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0; +} + +function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, percentSnake, percentCamel, resetArgs, countMeansRemaining) { const total = getTotal(model); - if (total <= 0) return; + const providedPercent = getMiniMaxProvidedPercent(model, percentSnake, percentCamel); + if (total <= 0 && providedPercent === null) return; const count = Math.max(0, Number(getMiniMaxField(model, countSnake, countCamel)) || 0); + let effectiveTotal = total; + let effectiveCount = count; + if (total <= 0) { + // M-series bucket: API only ships *_remaining_percent (count = 0). Normalize + // to total=100. The downstream buildMiniMaxQuota treats the count as + // "used" or "remaining" depending on countMeansRemaining, so the synthetic + // count has to match that semantic — otherwise the UI flips the percentage. + effectiveTotal = 100; + const pct = providedPercent; + effectiveCount = countMeansRemaining + ? Math.round(effectiveTotal * (pct / 100)) + : Math.round(effectiveTotal * (1 - pct / 100)); + } quotas[key] = buildMiniMaxQuota( - total, - count, + effectiveTotal, + effectiveCount, getMiniMaxResetAt(model, ...resetArgs), - countMeansRemaining + countMeansRemaining, + providedPercent ); } @@ -1122,6 +1169,8 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { getMiniMaxSessionTotal, "current_interval_usage_count", "currentIntervalUsageCount", + "current_interval_remaining_percent", + "currentIntervalRemainingPercent", [capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"], countMeansRemaining ); @@ -1133,6 +1182,8 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { getMiniMaxWeeklyTotal, "current_weekly_usage_count", "currentWeeklyUsageCount", + "current_weekly_remaining_percent", + "currentWeeklyRemainingPercent", [capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"], countMeansRemaining ); diff --git a/src/shared/constants/pricing.js b/src/shared/constants/pricing.js index 55d6d955..fb87a407 100644 --- a/src/shared/constants/pricing.js +++ b/src/shared/constants/pricing.js @@ -95,6 +95,7 @@ export const MODEL_PRICING = { "glm-5": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, // === MiniMax === + "MiniMax-M3": { input: 0.30, output: 1.20, cached: 0.06, reasoning: 1.80, cache_creation: 0.30 }, "MiniMax-M2.1": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, "MiniMax-M2.5": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, "MiniMax-M2.7": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, diff --git a/tests/unit/minimax-usage.test.js b/tests/unit/minimax-usage.test.js index d2772da9..d903473d 100644 --- a/tests/unit/minimax-usage.test.js +++ b/tests/unit/minimax-usage.test.js @@ -113,4 +113,127 @@ describe("MiniMax usage", () => { expect(usage.quotas["Music 2.6 (5h)"].used).toBe(5); expect(usage.quotas["Image 01 (5h)"].used).toBe(2); }); + + it("includes M-series percent-only buckets that have no count totals", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + model_name: "general", + current_interval_remaining_percent: 70, + current_weekly_remaining_percent: 64, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax", + apiKey: "test-key", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas["M-series (5h)"]).toMatchObject({ + used: 30, + total: 100, + remaining: 70, + remainingPercentage: 70, + }); + expect(usage.quotas["M-series (7d)"]).toMatchObject({ + used: 36, + total: 100, + remaining: 64, + remainingPercentage: 64, + }); + }); + + it("normalizes M-series percent-only buckets on the coding_plan (countMeansRemaining) endpoint too", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + model_name: "general", + current_interval_remaining_percent: 80, + current_weekly_remaining_percent: 95, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax-cn", + apiKey: "test-key", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas["M-series (5h)"]).toMatchObject({ + used: 20, + total: 100, + remaining: 80, + remainingPercentage: 80, + }); + expect(usage.quotas["M-series (7d)"]).toMatchObject({ + used: 5, + total: 100, + remaining: 95, + remainingPercentage: 95, + }); + }); + + it("renders the M3-era MiniMax-M* wildcard as a friendly series label", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + modelName: "MiniMax-M*", + currentIntervalTotalCount: 4000, + currentIntervalUsageCount: 3200, + currentWeeklyTotalCount: 24000, + currentWeeklyUsageCount: 18000, + remainsTime: 1000, + weeklyRemainsTime: 2000, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax-cn", + apiKey: "test-key", + }); + + expect(usage.message).toBeUndefined(); + expect(Object.keys(usage.quotas)).toEqual([ + "M-series (5h)", + "M-series (7d)", + ]); + expect(usage.quotas["M-series (5h)"]).toMatchObject({ + used: 800, + total: 4000, + remaining: 3200, + }); + expect(usage.quotas["M-series (7d)"]).toMatchObject({ + used: 6000, + total: 24000, + remaining: 18000, + }); + }); + + it("prefers the upstream-provided remaining percent when counts are also present", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + model_name: "general", + current_interval_total_count: 4000, + current_interval_usage_count: 100, + current_interval_remaining_percent: 84, + current_weekly_total_count: 24000, + current_weekly_usage_count: 500, + current_weekly_remaining_percent: 42, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax", + apiKey: "test-key", + }); + + expect(usage.quotas["M-series (5h)"].remainingPercentage).toBe(84); + expect(usage.quotas["M-series (7d)"].remainingPercentage).toBe(42); + }); }); diff --git a/tests/unit/provider-models-minimax-m3.test.js b/tests/unit/provider-models-minimax-m3.test.js new file mode 100644 index 00000000..8ad55d1b --- /dev/null +++ b/tests/unit/provider-models-minimax-m3.test.js @@ -0,0 +1,52 @@ +/** + * Unit tests verifying MiniMax-M3 is registered as a first-class + * built-in model for both the `minimax` (international) and + * `minimax-cn` (China) providers, with `targetFormat: "claude"`. + * + * Run: cd tests && NODE_PATH=/tmp/node_modules /tmp/node_modules/.bin/vitest run tests/unit/provider-models-minimax-m3.test.js --reporter=verbose + */ + +import { describe, it, expect } from "vitest"; +import { PROVIDER_MODELS, getModelsByProviderId } from "../../open-sse/config/providerModels.js"; + +describe("MiniMax-M3 model registration", () => { + it("includes MiniMax-M3 in PROVIDER_MODELS.minimax", () => { + const models = PROVIDER_MODELS.minimax || []; + const m3 = models.find((m) => m.id === "MiniMax-M3"); + expect(m3).toBeDefined(); + expect(m3).toMatchObject({ + id: "MiniMax-M3", + name: "MiniMax M3", + targetFormat: "claude", + }); + }); + + it("includes MiniMax-M3 in PROVIDER_MODELS['minimax-cn']", () => { + const models = PROVIDER_MODELS["minimax-cn"] || []; + const m3 = models.find((m) => m.id === "MiniMax-M3"); + expect(m3).toBeDefined(); + expect(m3).toMatchObject({ + id: "MiniMax-M3", + name: "MiniMax M3", + targetFormat: "claude", + }); + }); + + it("exposes MiniMax-M3 through getModelsByProviderId for both provider IDs", () => { + const intlModels = getModelsByProviderId("minimax"); + const cnModels = getModelsByProviderId("minimax-cn"); + + expect(intlModels.some((m) => m.id === "MiniMax-M3")).toBe(true); + expect(cnModels.some((m) => m.id === "MiniMax-M3")).toBe(true); + }); + + it("does not regress the existing M2.7 / M2.5 / M2.1 entries", () => { + const intlIds = (PROVIDER_MODELS.minimax || []).map((m) => m.id); + const cnIds = (PROVIDER_MODELS["minimax-cn"] || []).map((m) => m.id); + + for (const id of ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1"]) { + expect(intlIds).toContain(id); + expect(cnIds).toContain(id); + } + }); +}); diff --git a/tests/unit/provider-pricing-minimax-m3.test.js b/tests/unit/provider-pricing-minimax-m3.test.js new file mode 100644 index 00000000..d73bd608 --- /dev/null +++ b/tests/unit/provider-pricing-minimax-m3.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { MODEL_PRICING } from "../../src/shared/constants/pricing.js"; + +describe("MiniMax-M3 pricing", () => { + it("includes MiniMax-M3 in MODEL_PRICING", () => { + expect(MODEL_PRICING["MiniMax-M3"]).toBeDefined(); + }); + + it("MiniMax-M3 pricing has numeric shape (input, output, cached)", () => { + const pricing = MODEL_PRICING["MiniMax-M3"]; + expect(pricing).toMatchObject({ + input: expect.any(Number), + output: expect.any(Number), + cached: expect.any(Number), + }); + }); + + it("MiniMax-M3 input price matches the design spec (0.30)", () => { + expect(MODEL_PRICING["MiniMax-M3"].input).toBe(0.30); + }); + + it("MiniMax-M3 output price matches the design spec (1.20)", () => { + expect(MODEL_PRICING["MiniMax-M3"].output).toBe(1.20); + }); + + it("MiniMax-M3 cached price matches the design spec (0.06)", () => { + expect(MODEL_PRICING["MiniMax-M3"].cached).toBe(0.06); + }); +}); \ No newline at end of file