fix(minimax): Bổ sung MiniMax-M3 + cập nhật Quota Tracker coding/CN

Squash-merge PR #1631 (decolua/9router) — chỉ lấy file code + test, bỏ docs.

- feat(minimax): add MiniMax-M3 to intl + cn provider models (targetFormat claude)
- feat(minimax): add MiniMax-M3 pricing entry
- fix(minimax): translate Claude body khi content=null (M3 thinking-only)
- fix(minimax): hiển thị quota M-series bucket "general"/"MiniMax-M*" + percent-only
- test: minimax usage / model registration / pricing

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hodtien
2026-06-06 10:01:05 +07:00
committed by decolua
parent cce8a50cac
commit 41f94ce8c8
7 changed files with 272 additions and 10 deletions

View File

@@ -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" },

View File

@@ -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 ?? "";

View File

@@ -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
);

View File

@@ -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 },

View File

@@ -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);
});
});

View File

@@ -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);
}
});
});

View File

@@ -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);
});
});