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
co-authored by Claude Cursor
parent cce8a50cac
commit 41f94ce8c8
7 changed files with 272 additions and 10 deletions
+2
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" },
@@ -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 ?? "";
+59 -8
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
);