From 68360a87c1bd432b5c75b0a396e980ae54d03cfe Mon Sep 17 00:00:00 2001 From: 0xC00D07F3 Date: Sun, 19 Jul 2026 19:04:01 +0200 Subject: [PATCH] fix(minimax): fall back to remaining_percent when count fields are zero MiniMax's international Coding Plan API now returns current_interval_total_count: 0 and current_interval_usage_count: 0 for the general/video rows, with usage carried in current_interval_remaining_percent and current_weekly_remaining_percent. The previous parser dropped any record with total <= 0, surfacing "no reportable MiniMax Coding Plan quota" on /quota and /quota_status. - Add getPercentRemaining accessor to each entry in MINIMAX_WINDOW_SPECS. - Rewrite buildMiniMaxEntry to fall back to the percent field when count fields are missing or zero. The count-based path is unchanged. - Relax isMiniMaxModelRecord to accept records with current_interval_remaining_percent even when count fields are absent. Closes the follow-up to the model-name fix in v3.11.1 (general/video matching). --- src/providers/minimax-coding-plan.ts | 74 +++++++---- tests/providers.minimax-coding-plan.test.ts | 140 +++++++++++++++++++- 2 files changed, 183 insertions(+), 31 deletions(-) diff --git a/src/providers/minimax-coding-plan.ts b/src/providers/minimax-coding-plan.ts index 67a7e6ea..5105e8eb 100644 --- a/src/providers/minimax-coding-plan.ts +++ b/src/providers/minimax-coding-plan.ts @@ -16,10 +16,7 @@ import { resolveMiniMaxChinaAuthCached, type ResolvedMiniMaxAuth, } from "../lib/minimax-auth.js"; -import { - getMiniMaxQuotaEndpoint, - type MiniMaxQuotaEndpointId, -} from "../lib/minimax-endpoints.js"; +import { getMiniMaxQuotaEndpoint, type MiniMaxQuotaEndpointId } from "../lib/minimax-endpoints.js"; import { sanitizeDisplayText } from "../lib/display-sanitize.js"; import { fetchWithTimeout } from "../lib/http.js"; import { @@ -36,14 +33,19 @@ const USER_AGENT = "OpenCode-Quota-Toast/1.0"; interface MiniMaxModelRemain { model_name: string; - current_interval_total_count: number; + current_interval_total_count?: number; /** Endpoint-specific raw count: international reports remaining, China reports used. */ - current_interval_usage_count: number; + current_interval_usage_count?: number; remains_time: number; current_weekly_total_count?: number; /** Endpoint-specific raw count: international reports remaining, China reports used. */ current_weekly_usage_count?: number; weekly_remains_time?: number; + /** Percent remaining for the 5h window (0-100). Reported by some accounts when counts are zeroed. */ + current_interval_remaining_percent?: number; + current_interval_status?: number; + current_weekly_remaining_percent?: number; + current_weekly_status?: number; } interface MiniMaxApiResponse { @@ -68,6 +70,7 @@ interface MiniMaxWindowSpec { getTotal(model: MiniMaxModelRemain): number | undefined; getCount(model: MiniMaxModelRemain): number | undefined; getResetOffsetMs(model: MiniMaxModelRemain): number | undefined; + getPercentRemaining(model: MiniMaxModelRemain): number | undefined; } const MINIMAX_WINDOW_SPECS: readonly MiniMaxWindowSpec[] = [ @@ -78,6 +81,7 @@ const MINIMAX_WINDOW_SPECS: readonly MiniMaxWindowSpec[] = [ getTotal: (model) => model.current_interval_total_count, getCount: (model) => model.current_interval_usage_count, getResetOffsetMs: (model) => model.remains_time, + getPercentRemaining: (model) => model.current_interval_remaining_percent, }, { window: "weekly", @@ -86,6 +90,7 @@ const MINIMAX_WINDOW_SPECS: readonly MiniMaxWindowSpec[] = [ getTotal: (model) => model.current_weekly_total_count, getCount: (model) => model.current_weekly_usage_count, getResetOffsetMs: (model) => model.weekly_remains_time, + getPercentRemaining: (model) => model.current_weekly_remaining_percent, }, ]; @@ -96,18 +101,21 @@ function isFiniteNumber(value: unknown): value is number { /** * Type guard that validates a value is a well-formed MiniMax model record. * - * Checks for `model_name` (string) and the 5-hour/request quota numeric fields - * to prevent `NaN` arithmetic when the API response shape is unexpected. + * Accepts either a count-based record (with `current_interval_total_count` and + * `current_interval_usage_count`) or a percent-based record (with + * `current_interval_remaining_percent`). `remains_time` is always required so + * the entry has a valid reset offset. */ function isMiniMaxModelRecord(value: unknown): value is MiniMaxModelRemain { if (value === null || typeof value !== "object" || !("model_name" in value)) return false; const v = value as Record; - return ( - typeof v.model_name === "string" && + if (typeof v.model_name !== "string" || !isFiniteNumber(v.remains_time)) return false; + + const hasCounts = isFiniteNumber(v.current_interval_total_count) && - isFiniteNumber(v.current_interval_usage_count) && - isFiniteNumber(v.remains_time) - ); + isFiniteNumber(v.current_interval_usage_count); + const hasPercent = isFiniteNumber(v.current_interval_remaining_percent); + return hasCounts || hasPercent; } function roundPercent(value: number): number { @@ -158,19 +166,33 @@ function buildMiniMaxEntry( const total = spec.getTotal(model); const rawCount = spec.getCount(model); const resetOffsetMs = spec.getResetOffsetMs(model); - if (!isFiniteNumber(total) || !isFiniteNumber(rawCount) || !isFiniteNumber(resetOffsetMs)) { - return null; + if (!isFiniteNumber(resetOffsetMs)) return null; + + if (isFiniteNumber(total) && isFiniteNumber(rawCount) && total > 0) { + const { used, remaining } = normalizeMiniMaxCounts(total, rawCount, countSemantics); + const percentRemaining = roundPercent((remaining / total) * 100); + + return { + window: spec.window, + name: spec.name.replace(MINIMAX_PROVIDER_LABEL, providerLabel), + group: providerLabel, + label: spec.label, + right: `${used}/${total}`, + percentRemaining, + resetTimeIso: new Date(Date.now() + Math.max(0, resetOffsetMs)).toISOString(), + }; } - if (total <= 0) return null; - const { used, remaining } = normalizeMiniMaxCounts(total, rawCount, countSemantics); - const percentRemaining = roundPercent((remaining / total) * 100); + + const percentRaw = spec.getPercentRemaining(model); + if (!isFiniteNumber(percentRaw)) return null; + const percentRemaining = roundPercent(percentRaw); return { window: spec.window, name: spec.name.replace(MINIMAX_PROVIDER_LABEL, providerLabel), group: providerLabel, label: spec.label, - right: `${used}/${total}`, + right: `${100 - percentRemaining}%`, percentRemaining, resetTimeIso: new Date(Date.now() + Math.max(0, resetOffsetMs)).toISOString(), }; @@ -206,12 +228,14 @@ function selectCanonicalMiniMaxModel( return wildcardModel; } - return [...models].sort((left, right) => { - const percentDiff = - getWorstPercent(left, countSemantics) - getWorstPercent(right, countSemantics); - if (percentDiff !== 0) return percentDiff; - return left.model_name.localeCompare(right.model_name); - })[0] ?? null; + return ( + [...models].sort((left, right) => { + const percentDiff = + getWorstPercent(left, countSemantics) - getWorstPercent(right, countSemantics); + if (percentDiff !== 0) return percentDiff; + return left.model_name.localeCompare(right.model_name); + })[0] ?? null + ); } /** diff --git a/tests/providers.minimax-coding-plan.test.ts b/tests/providers.minimax-coding-plan.test.ts index 6eae5fbb..13bc7b2c 100644 --- a/tests/providers.minimax-coding-plan.test.ts +++ b/tests/providers.minimax-coding-plan.test.ts @@ -44,6 +44,10 @@ function createCodingPlanModel( current_weekly_total_count: number; current_weekly_usage_count: number; weekly_remains_time: number; + current_interval_remaining_percent: number; + current_interval_status: number; + current_weekly_remaining_percent: number; + current_weekly_status: number; }> = {}, ) { return { @@ -70,12 +74,19 @@ function mockMiniMaxAuthInvalid(error = "Invalid API key") { mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({ state: "invalid", error }); } -function mockMiniMaxAuthConfigured(apiKey = "test-key", endpoint: "international" | "china" = "international") { +function mockMiniMaxAuthConfigured( + apiKey = "test-key", + endpoint: "international" | "china" = "international", +) { mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({ state: "configured", apiKey, endpoint }); } function mockMiniMaxChinaAuthConfigured(apiKey = "china-key") { - mocks.resolveMiniMaxChinaAuthCached.mockResolvedValueOnce({ state: "configured", apiKey, endpoint: "china" }); + mocks.resolveMiniMaxChinaAuthCached.mockResolvedValueOnce({ + state: "configured", + apiKey, + endpoint: "china", + }); } function mockMiniMaxHttpSuccess(models: unknown[]) { @@ -303,6 +314,114 @@ describe("minimax-coding-plan provider", () => { }); }); + it("falls back to remaining_percent when international generic rows report zero counts", async () => { + mockMiniMaxAuthConfigured(); + mockMiniMaxHttpSuccess([ + createCodingPlanModel({ + model_name: "general", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + weekly_remains_time: 27_563_753, + current_interval_remaining_percent: 86, + current_interval_status: 1, + current_weekly_remaining_percent: 90, + current_weekly_status: 1, + }), + createCodingPlanModel({ + model_name: "video", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + weekly_remains_time: 27_563_753, + current_interval_remaining_percent: 100, + current_interval_status: 3, + current_weekly_remaining_percent: 100, + current_weekly_status: 3, + }), + ]); + + const out = await runProviderFetch(); + + expectAttemptedWithNoErrors(out); + expect(out.entries).toHaveLength(2); + expect(out.entries[0]).toMatchObject({ + window: "five_hour", + name: "MiniMax Coding Plan 5h", + group: "MiniMax Coding Plan", + label: "5h:", + right: "14%", + percentRemaining: 86, + }); + expect(out.entries[1]).toMatchObject({ + window: "weekly", + name: "MiniMax Coding Plan Weekly", + group: "MiniMax Coding Plan", + label: "Weekly:", + right: "10%", + percentRemaining: 90, + }); + }); + + it("uses remaining_percent when count fields are missing entirely from the API response", async () => { + mockMiniMaxAuthConfigured(); + mockMiniMaxHttpSuccess([ + { + model_name: "general", + remains_time: 13_163_753, + current_interval_remaining_percent: 75, + current_weekly_remaining_percent: 80, + weekly_remains_time: 27_563_753, + }, + ]); + + const out = await runProviderFetch(); + + expectAttemptedWithNoErrors(out); + expect(out.entries).toHaveLength(2); + expect(out.entries[0]).toMatchObject({ + window: "five_hour", + right: "25%", + percentRemaining: 75, + }); + expect(out.entries[1]).toMatchObject({ + window: "weekly", + right: "20%", + percentRemaining: 80, + }); + }); + + it("prefers the count path when both count and percent fields are populated", async () => { + mockMiniMaxAuthConfigured(); + mockMiniMaxHttpSuccess([ + createCodingPlanModel({ + model_name: "MiniMax-M2.7", + current_interval_total_count: 100, + current_interval_usage_count: 25, + current_weekly_total_count: 1000, + current_weekly_usage_count: 250, + current_interval_remaining_percent: 12, + current_weekly_remaining_percent: 13, + }), + ]); + + const out = await runProviderFetch(); + + expectAttemptedWithNoErrors(out); + expect(out.entries[0]).toMatchObject({ + window: "five_hour", + right: "75/100", + percentRemaining: 25, + }); + expect(out.entries[1]).toMatchObject({ + window: "weekly", + right: "750/1000", + percentRemaining: 25, + }); + }); + it("selects the lowest-remaining international generic row", async () => { mockMiniMaxAuthConfigured(); mockMiniMaxHttpSuccess([ @@ -523,7 +642,9 @@ describe("minimax-coding-plan provider", () => { const statusOut = await minimaxCodingPlanProvider.fetch({ config: {} } as any); expectAttemptedWithErrorLabel(statusOut, "MiniMax Coding Plan"); - expect(statusOut.errors[0]?.message).toBe(`MiniMax API error: ${`${"x".repeat(140)} retry`.slice(0, 120)}`); + expect(statusOut.errors[0]?.message).toBe( + `MiniMax API error: ${`${"x".repeat(140)} retry`.slice(0, 120)}`, + ); mockMiniMaxAuthConfigured(); mocks.fetchWithTimeout.mockRejectedValueOnce(new Error("network\nfailed")); @@ -590,15 +711,22 @@ describe("minimax-coding-plan provider", () => { mocks.isCanonicalProviderAvailable.mockResolvedValueOnce(true); mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce(authState); - const available = await minimaxCodingPlanProvider.isAvailable({ config: { enabledProviders: "auto" } } as any); + const available = await minimaxCodingPlanProvider.isAvailable({ + config: { enabledProviders: "auto" }, + } as any); expect(available).toBe(expected); }); it("returns false when auth exists but the minimax provider is not configured", async () => { mocks.isCanonicalProviderAvailable.mockResolvedValueOnce(false); - mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({ state: "configured", apiKey: "test-key" }); + mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({ + state: "configured", + apiKey: "test-key", + }); - const available = await minimaxCodingPlanProvider.isAvailable({ config: { enabledProviders: "auto" } } as any); + const available = await minimaxCodingPlanProvider.isAvailable({ + config: { enabledProviders: "auto" }, + } as any); expect(available).toBe(false); expect(mocks.resolveMiniMaxAuthCached).not.toHaveBeenCalled(); });