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:
@@ -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" },
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
52
tests/unit/provider-models-minimax-m3.test.js
Normal file
52
tests/unit/provider-models-minimax-m3.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
29
tests/unit/provider-pricing-minimax-m3.test.js
Normal file
29
tests/unit/provider-pricing-minimax-m3.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user