From efd5d5eb20194609d823f8c3580f4be83a8d48fb Mon Sep 17 00:00:00 2001 From: IVY <62232838+IVY-AI-gif@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:00:33 +0800 Subject: [PATCH] fix(usage): improve MiniMax coding-plan usage parsing for model_remains array - Pick the chat model entry (MiniMax-M*) from model_remains instead of using the first BFS candidate, which could be a speech/video/image model with total_count=0. - Derive window label from start_time/end_time timestamps when window_hours/window_minutes fields are absent; fixes the hardcoded 5h default for 4h windows. - Include model name in plan label so users can distinguish free-tier coding-plan quota from paid API balance. Closes #52335 --- src/infra/provider-usage.fetch.minimax.ts | 79 +++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/src/infra/provider-usage.fetch.minimax.ts b/src/infra/provider-usage.fetch.minimax.ts index e2b0c7d7d41..8586a350548 100644 --- a/src/infra/provider-usage.fetch.minimax.ts +++ b/src/infra/provider-usage.fetch.minimax.ts @@ -267,6 +267,23 @@ function collectUsageCandidates(root: Record): Record candidate.record); } +function deriveWindowLabelFromTimestamps(record: Record): string | undefined { + const startTime = parseEpoch(record.start_time ?? record.startTime); + const endTime = parseEpoch(record.end_time ?? record.endTime); + if (startTime && endTime && endTime > startTime) { + const durationHours = (endTime - startTime) / 3_600_000; + if (durationHours >= 1 && Number.isFinite(durationHours)) { + const rounded = Math.round(durationHours); + return `${rounded}h`; + } + const durationMinutes = Math.round((endTime - startTime) / 60_000); + if (durationMinutes > 0) { + return `${durationMinutes}m`; + } + } + return undefined; +} + function deriveWindowLabel(payload: Record): string { const hours = pickNumber(payload, WINDOW_HOUR_KEYS); if (hours && Number.isFinite(hours)) { @@ -276,6 +293,10 @@ function deriveWindowLabel(payload: Record): string { if (minutes && Number.isFinite(minutes)) { return `${minutes}m`; } + const fromTimestamps = deriveWindowLabelFromTimestamps(payload); + if (fromTimestamps) { + return fromTimestamps; + } return "5h"; } @@ -315,6 +336,40 @@ function deriveUsedPercent(payload: Record): number | null { return null; } +/** + * When the API returns a `model_remains` array, prefer the entry whose + * `model_name` matches a chat/text model (e.g. "MiniMax-M*") and that has + * a non-zero `current_interval_total_count`. Models with total_count === 0 + * (speech, video, image) are not relevant to the coding-plan budget. + */ +function pickChatModelRemains( + modelRemains: unknown[], +): Record | undefined { + const records = modelRemains.filter(isRecord); + if (records.length === 0) { + return undefined; + } + + const chatRecord = records.find((r) => { + const name = typeof r.model_name === "string" ? r.model_name : ""; + const total = parseFiniteNumber(r.current_interval_total_count); + return ( + (name.toLowerCase().startsWith("minimax-m") || name === "MiniMax-M*") && + total !== undefined && + total > 0 + ); + }); + + if (chatRecord) { + return chatRecord; + } + + return records.find((r) => { + const total = parseFiniteNumber(r.current_interval_total_count); + return total !== undefined && total > 0; + }); +} + export async function fetchMinimaxUsage( apiKey: string, timeoutMs: number, @@ -362,8 +417,15 @@ export async function fetchMinimaxUsage( } const payload = isRecord(data.data) ? data.data : data; - const candidates = collectUsageCandidates(payload); - let usageRecord: Record = payload; + + // Handle the model_remains array structure returned by the coding-plan + // endpoint. Pick the chat-model entry so that speech/video/image quotas + // (which often have total_count === 0) don't shadow the relevant budget. + const modelRemains = Array.isArray(payload.model_remains) ? payload.model_remains : null; + const chatRemains = modelRemains ? pickChatModelRemains(modelRemains) : undefined; + + const candidates = collectUsageCandidates(chatRemains ?? payload); + let usageRecord: Record = chatRemains ?? payload; let usedPercent: number | null = null; for (const candidate of candidates) { const candidatePercent = deriveUsedPercent(candidate); @@ -374,7 +436,7 @@ export async function fetchMinimaxUsage( } } if (usedPercent === null) { - usedPercent = deriveUsedPercent(payload); + usedPercent = deriveUsedPercent(chatRemains ?? payload); } if (usedPercent === null) { return { @@ -398,10 +460,19 @@ export async function fetchMinimaxUsage( }, ]; + const modelName = + chatRemains && typeof chatRemains.model_name === "string" + ? chatRemains.model_name + : undefined; + const plan = + pickString(usageRecord, PLAN_KEYS) ?? + pickString(payload, PLAN_KEYS) ?? + (modelName ? `Coding Plan ยท ${modelName}` : undefined); + return { provider: "minimax", displayName: PROVIDER_LABELS.minimax, windows, - plan: pickString(usageRecord, PLAN_KEYS) ?? pickString(payload, PLAN_KEYS), + plan, }; }