From 58c402671dc046864301e27e26d877d1f8a5af54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 25 Jul 2026 19:20:42 -0300 Subject: [PATCH 1/6] feat(ai): report MiniMax Token Plan quota in usage MiniMax exposes GET /v1/token_plan/remains, so the placeholder provider that returned null can now report real quota: one bucket per plan quota, each with a rolling interval window and a weekly window. Registers both the international and China ids in the default usage provider set - the China id had no resolver entry, so it never reached a fetcher. --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth-storage.ts | 3 + packages/ai/src/usage/minimax-code.ts | 267 ++++++++++++++++-- .../ai/test/minimax-token-plan-usage.test.ts | 198 +++++++++++++ 4 files changed, 451 insertions(+), 18 deletions(-) create mode 100644 packages/ai/test/minimax-token-plan-usage.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 98cad82ab..7c682fd6b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- MiniMax Token Plan accounts now report quota in `omp usage`. `GET /v1/token_plan/remains` returns one bucket per plan quota, each carrying a rolling interval window and a weekly window, so `minimax-code` and `minimax-code-cn` surface real remaining percentages instead of empty reports. Both ids are registered in the default usage provider set — the China id previously resolved to nothing, so it never reached a fetcher at all. - OAuth logins now stamp `authorizedAt` (epoch ms of the interactive login) on the stored credential, and every refresh-persist path preserves it. Anthropic expires the whole OAuth grant family ~30 days after authorization regardless of refresh-token rotation (observed as `invalid_grant: "Refresh token expired"` on the latest rotated token, exactly 30 days after login, across four production accounts), so the login anchor is what makes re-login deadlines computable. Exported `ANTHROPIC_OAUTH_GRANT_TTL_MS` alongside the anthropic OAuth flow. - Added `GET /v1/credentials/disabled` to the auth broker and `AuthBrokerClient.listDisabledCredentials`: disabled-credential tombstones (`DisabledCredentialSummary` — identity, verbatim disable cause, disable timestamp; never token material) so auto-disabled accounts stay visible to clients instead of silently vanishing from the snapshot. `AuthStorage.listDisabledCredentials` serves the same data locally from SQLite; clients of brokers predating the endpoint get an empty list (404 mapped, no error). - Added `AuthStorage.revalidateCredentials()` and the optional `AuthCredentialStore.refreshSnapshot` hook: remote broker stores re-fetch `GET /v1/snapshot` on demand so callers pairing live per-credential data with stored identities (`omp usage`) never render against the up-to-an-hour-stale disk-cached snapshot; local SQLite stores are always current and only reload. diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 078e7dfa1..cd00041aa 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -54,6 +54,7 @@ import { googleGeminiCliUsageProvider } from "./usage/gemini"; import { githubCopilotUsageProvider } from "./usage/github-copilot"; import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity"; import { kimiUsageProvider } from "./usage/kimi"; +import { minimaxCodeCnUsageProvider, minimaxCodeUsageProvider } from "./usage/minimax-code"; import { ollamaCloudUsageProvider, ollamaUsageProvider } from "./usage/ollama"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; import { @@ -643,6 +644,8 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [ alibabaTokenPlanUsageProvider, openaiCodexUsageProvider, kimiUsageProvider, + minimaxCodeUsageProvider, + minimaxCodeCnUsageProvider, antigravityUsageProvider, googleGeminiCliUsageProvider, ollamaUsageProvider, diff --git a/packages/ai/src/usage/minimax-code.ts b/packages/ai/src/usage/minimax-code.ts index 78cdf80b9..a95a76817 100644 --- a/packages/ai/src/usage/minimax-code.ts +++ b/packages/ai/src/usage/minimax-code.ts @@ -1,30 +1,261 @@ -import type { UsageFetchContext, UsageFetchParams, UsageProvider, UsageReport } from "../usage"; +import type { + UsageFetchContext, + UsageFetchParams, + UsageLimit, + UsageProvider, + UsageReport, + UsageStatus, +} from "../usage"; +import { isRecord } from "../utils"; +import { toNumber } from "./shared"; + +const INTL_PROVIDER = "minimax-code"; +const CN_PROVIDER = "minimax-code-cn"; +const INTL_BASE_URL = "https://api.minimax.io"; +const CN_BASE_URL = "https://api.minimaxi.com"; +const REMAINS_PATH = "/v1/token_plan/remains"; +const HOUR_MS = 60 * 60 * 1000; + +/** One `model_remains[]` bucket: a plan quota tracked over a rolling interval plus a weekly window. */ +interface TokenPlanBucket { + modelName: string; + intervalStart?: number; + intervalEnd?: number; + intervalRemainingPercent?: number; + intervalTotalCount?: number; + intervalUsageCount?: number; + weeklyStart?: number; + weeklyEnd?: number; + weeklyRemainingPercent?: number; + weeklyTotalCount?: number; + weeklyUsageCount?: number; +} + +/** MiniMax reports epoch milliseconds; tolerate seconds in case a deployment differs. */ +function parseTimestamp(value: unknown): number | undefined { + const parsed = toNumber(value); + if (parsed === undefined || parsed <= 0) return undefined; + return parsed < 1_000_000_000_000 ? parsed * 1000 : parsed; +} + +/** `current_*_remaining_percent` is 0..100 remaining; usage fractions are 0..1 used. */ +function usedFractionFromRemainingPercent(value: unknown): number | undefined { + const parsed = toNumber(value); + if (parsed === undefined || !Number.isFinite(parsed)) return undefined; + // (100 - p) / 100 keeps whole percentages exact; 1 - p / 100 does not (90 → 0.09999999999999998). + return Math.min(1, Math.max(0, (100 - parsed) / 100)); +} + +function usageStatus(usedFraction: number): UsageStatus { + if (usedFraction >= 1) return "exhausted"; + if (usedFraction >= 0.9) return "warning"; + return "ok"; +} + +function parseBucket(value: unknown): TokenPlanBucket | null { + if (!isRecord(value)) return null; + const modelName = typeof value.model_name === "string" ? value.model_name.trim() : ""; + if (!modelName) return null; + return { + modelName, + intervalStart: parseTimestamp(value.start_time), + intervalEnd: parseTimestamp(value.end_time), + intervalRemainingPercent: toNumber(value.current_interval_remaining_percent), + intervalTotalCount: toNumber(value.current_interval_total_count), + intervalUsageCount: toNumber(value.current_interval_usage_count), + weeklyStart: parseTimestamp(value.weekly_start_time), + weeklyEnd: parseTimestamp(value.weekly_end_time), + weeklyRemainingPercent: toNumber(value.current_weekly_remaining_percent), + weeklyTotalCount: toNumber(value.current_weekly_total_count), + weeklyUsageCount: toNumber(value.current_weekly_usage_count), + }; +} + +/** + * Interval length varies per bucket (text quotas roll every few hours, media + * quotas daily), so the window id follows the reported span instead of a + * hardcoded tier. Spans that are not whole hours are labelled in minutes + * rather than rounded into a wrong hour count. + */ +function intervalWindowId(durationMs: number | undefined): { id: string; label: string } { + if (durationMs === undefined || durationMs <= 0) return { id: "interval", label: "Interval" }; + if (durationMs % HOUR_MS === 0) { + const hours = durationMs / HOUR_MS; + return { id: `${hours}h`, label: `${hours} Hour` }; + } + const minutes = Math.round(durationMs / 60_000); + if (minutes <= 0) return { id: "interval", label: "Interval" }; + return { id: `${minutes}m`, label: `${minutes} Minute` }; +} + +function buildLimit(args: { + provider: string; + bucket: TokenPlanBucket; + windowId: string; + windowLabel: string; + durationMs?: number; + resetsAt?: number; + usedFraction: number | undefined; + usageCount?: number; + totalCount?: number; + accountId?: string; +}): UsageLimit | undefined { + const { usedFraction } = args; + if (usedFraction === undefined) return undefined; + const totalCount = args.totalCount; + return { + id: `${args.bucket.modelName}:${args.windowId}`, + label: `${args.bucket.modelName.charAt(0).toUpperCase()}${args.bucket.modelName.slice(1)} ${args.windowLabel}`, + scope: { + provider: args.provider, + ...(args.accountId ? { accountId: args.accountId } : {}), + modelId: args.bucket.modelName, + windowId: args.windowId, + }, + window: { + id: args.windowId, + label: args.windowLabel, + ...(args.durationMs !== undefined && args.durationMs > 0 ? { durationMs: args.durationMs } : {}), + ...(args.resetsAt ? { resetsAt: args.resetsAt } : {}), + }, + amount: { + used: usedFraction * 100, + usedFraction, + remaining: 100 - usedFraction * 100, + remainingFraction: 1 - usedFraction, + unit: "percent", + }, + status: usageStatus(usedFraction), + ...(totalCount !== undefined && totalCount > 0 + ? { notes: [`Requests: ${args.usageCount ?? 0}/${totalCount}`] } + : {}), + }; +} + +function buildBucketLimits(provider: string, bucket: TokenPlanBucket, accountId: string | undefined): UsageLimit[] { + const intervalDuration = + bucket.intervalStart !== undefined && bucket.intervalEnd !== undefined + ? bucket.intervalEnd - bucket.intervalStart + : undefined; + const weeklyDuration = + bucket.weeklyStart !== undefined && bucket.weeklyEnd !== undefined + ? bucket.weeklyEnd - bucket.weeklyStart + : undefined; + const interval = intervalWindowId(intervalDuration); + return [ + buildLimit({ + provider, + bucket, + windowId: interval.id, + windowLabel: interval.label, + durationMs: intervalDuration, + resetsAt: bucket.intervalEnd, + usedFraction: usedFractionFromRemainingPercent(bucket.intervalRemainingPercent), + usageCount: bucket.intervalUsageCount, + totalCount: bucket.intervalTotalCount, + accountId, + }), + buildLimit({ + provider, + bucket, + windowId: "7d", + windowLabel: "7 Day", + durationMs: weeklyDuration, + resetsAt: bucket.weeklyEnd, + usedFraction: usedFractionFromRemainingPercent(bucket.weeklyRemainingPercent), + usageCount: bucket.weeklyUsageCount, + totalCount: bucket.weeklyTotalCount, + accountId, + }), + ].filter((limit): limit is UsageLimit => limit !== undefined); +} /** * MiniMax Token Plan usage provider. * - * MiniMax Token Plan is a subscription-based service with a rolling quota system. - * - * Currently, MiniMax does not expose a usage/quota API endpoint for the Token Plan. - * Usage is tracked via the web dashboard at https://platform.minimax.io/user-center/payment/token-plan - * - * This provider exists to register support for the minimax-code provider in the - * usage system. When MiniMax adds a usage API, this can be implemented. + * `GET /v1/token_plan/remains` returns one `model_remains[]` bucket per plan + * quota (text, media, …), each carrying a rolling interval window and a weekly + * window with the remaining percentage. MiniMax answers HTTP 200 even for + * rejected credentials, so `base_resp.status_code` is the real success signal. */ -async function fetchMiniMaxCodeUsage(params: UsageFetchParams, _ctx: UsageFetchContext): Promise { - if (params.provider !== "minimax-code" && params.provider !== "minimax-code-cn") { +async function fetchMiniMaxCodeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { + if (params.provider !== INTL_PROVIDER && params.provider !== CN_PROVIDER) return null; + const apiKey = params.credential.apiKey; + if (params.credential.type !== "api_key" || !apiKey) return null; + + try { + const configuredBaseUrl = params.baseUrl?.trim(); + const baseUrl = configuredBaseUrl + ? configuredBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "") + : params.provider === CN_PROVIDER + ? CN_BASE_URL + : INTL_BASE_URL; + const response = await ctx.fetch(`${baseUrl}${REMAINS_PATH}`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + signal: params.signal, + }); + if (!response.ok) { + ctx.logger?.warn("MiniMax Token Plan usage fetch failed", { + provider: params.provider, + status: response.status, + }); + return null; + } + const payload: unknown = await response.json(); + if (!isRecord(payload)) return null; + const statusCode = isRecord(payload.base_resp) ? toNumber(payload.base_resp.status_code) : undefined; + if (statusCode !== 0) { + ctx.logger?.warn("MiniMax Token Plan usage response rejected", { + provider: params.provider, + statusCode: statusCode ?? "missing", + }); + return null; + } + if (!Array.isArray(payload.model_remains)) return null; + + const accountId = params.credential.accountId; + const limits: UsageLimit[] = []; + const models: string[] = []; + for (const entry of payload.model_remains) { + const bucket = parseBucket(entry); + if (!bucket) continue; + models.push(bucket.modelName); + limits.push(...buildBucketLimits(params.provider, bucket, accountId)); + } + if (limits.length === 0) return null; + + return { + provider: params.provider, + fetchedAt: Date.now(), + limits, + metadata: { + source: "minimax-token-plan", + models, + ...(accountId ? { accountId } : {}), + }, + raw: payload, + }; + } catch (error) { + ctx.logger?.warn("MiniMax Token Plan usage request failed", { + provider: params.provider, + error: error instanceof Error ? error.name : "unknown", + }); return null; } - - // MiniMax Token Plan does not currently expose a usage API - // Users can check their usage via the web dashboard - return null; } +/** MiniMax Token Plan (international, `api.minimax.io`). */ export const minimaxCodeUsageProvider: UsageProvider = { - id: "minimax-code", + id: INTL_PROVIDER, fetchUsage: fetchMiniMaxCodeUsage, - supports: (params: UsageFetchParams) => - (params.provider === "minimax-code" || params.provider === "minimax-code-cn") && - params.credential.type === "api_key", + supports: params => + params.provider === INTL_PROVIDER && params.credential.type === "api_key" && Boolean(params.credential.apiKey), +}; + +/** MiniMax Token Plan (China, `api.minimaxi.com`). */ +export const minimaxCodeCnUsageProvider: UsageProvider = { + id: CN_PROVIDER, + fetchUsage: fetchMiniMaxCodeUsage, + supports: params => + params.provider === CN_PROVIDER && params.credential.type === "api_key" && Boolean(params.credential.apiKey), }; diff --git a/packages/ai/test/minimax-token-plan-usage.test.ts b/packages/ai/test/minimax-token-plan-usage.test.ts new file mode 100644 index 000000000..85c5c8923 --- /dev/null +++ b/packages/ai/test/minimax-token-plan-usage.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from "bun:test"; +import { type AuthCredentialStore, AuthStorage } from "@oh-my-pi/pi-ai/auth-storage"; +import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import type { UsageFetchParams } from "@oh-my-pi/pi-ai/usage"; +import { minimaxCodeCnUsageProvider, minimaxCodeUsageProvider } from "@oh-my-pi/pi-ai/usage/minimax-code"; + +const INTERVAL_START = 1_785_009_600_000; +const INTERVAL_END = 1_785_024_000_000; +const WEEKLY_START = 1_784_505_600_000; +const WEEKLY_END = 1_785_110_400_000; + +function params(provider: "minimax-code" | "minimax-code-cn", apiKey = "sk-cp-test"): UsageFetchParams { + return { provider, credential: { type: "api_key", apiKey }, accountKey: "account-1" }; +} + +function remainsPayload() { + return { + model_remains: [ + { + model_name: "general", + start_time: INTERVAL_START, + end_time: INTERVAL_END, + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 90, + weekly_start_time: WEEKLY_START, + weekly_end_time: WEEKLY_END, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 78, + }, + { + model_name: "video", + start_time: INTERVAL_END - 86_400_000, + end_time: INTERVAL_END, + current_interval_total_count: 3, + current_interval_usage_count: 1, + current_interval_remaining_percent: 100, + weekly_start_time: WEEKLY_START, + weekly_end_time: WEEKLY_END, + current_weekly_total_count: 21, + current_weekly_usage_count: 1, + current_weekly_remaining_percent: 100, + }, + ], + base_resp: { status_code: 0, status_msg: "success" }, + }; +} + +describe("MiniMax Token Plan usage", () => { + test("maps each quota bucket to its rolling and weekly windows", async () => { + const requests: { url: string; init?: RequestInit }[] = []; + const fetchMock: FetchImpl = (input, init) => { + requests.push({ url: String(input), init }); + return Promise.resolve(Response.json(remainsPayload())); + }; + + const report = await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://api.minimax.io/v1/token_plan/remains"); + expect(new Headers(requests[0]?.init?.headers).get("Authorization")).toBe("Bearer sk-cp-test"); + expect(report?.provider).toBe("minimax-code"); + expect(report?.metadata).toMatchObject({ source: "minimax-token-plan", models: ["general", "video"] }); + expect(report?.limits.map(limit => limit.id)).toEqual(["general:4h", "general:7d", "video:24h", "video:7d"]); + + const [intervalLimit, weeklyLimit, videoInterval, videoWeekly] = report?.limits ?? []; + expect(intervalLimit?.label).toBe("General 4 Hour"); + expect(intervalLimit?.window).toEqual({ + id: "4h", + label: "4 Hour", + durationMs: 14_400_000, + resetsAt: INTERVAL_END, + }); + expect(intervalLimit?.amount).toEqual({ + used: 10, + usedFraction: 0.1, + remaining: 90, + remainingFraction: 0.9, + unit: "percent", + }); + expect(intervalLimit?.status).toBe("ok"); + expect(intervalLimit?.notes).toBeUndefined(); + + expect(weeklyLimit?.window).toEqual({ id: "7d", label: "7 Day", durationMs: 604_800_000, resetsAt: WEEKLY_END }); + expect(weeklyLimit?.amount).toEqual({ + used: 22, + usedFraction: 0.22, + remaining: 78, + remainingFraction: 0.78, + unit: "percent", + }); + + expect(videoInterval?.label).toBe("Video 24 Hour"); + expect(videoInterval?.amount.usedFraction).toBe(0); + expect(videoInterval?.notes).toEqual(["Requests: 1/3"]); + expect(videoWeekly?.notes).toEqual(["Requests: 1/21"]); + }); + + test("routes the China provider to the mainland endpoint", async () => { + let requestedUrl = ""; + const fetchMock: FetchImpl = input => { + requestedUrl = String(input); + return Promise.resolve(Response.json(remainsPayload())); + }; + + const report = await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock }); + + expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); + expect(report?.provider).toBe("minimax-code-cn"); + }); + + test("fails closed when MiniMax rejects the key inside a 200 response", async () => { + const fetchMock: FetchImpl = () => + Promise.resolve( + Response.json({ + base_resp: { status_code: 1004, status_msg: "login fail: Please carry the API secret key" }, + }), + ); + + expect(await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock })).toBeNull(); + }); + + test("does not fetch without an API key credential", async () => { + let fetched = false; + const fetchMock: FetchImpl = () => { + fetched = true; + return Promise.resolve(Response.json(remainsPayload())); + }; + const request: UsageFetchParams = { provider: "minimax-code", credential: { type: "oauth" } }; + + expect(minimaxCodeUsageProvider.supports?.(request)).toBe(false); + expect(await minimaxCodeUsageProvider.fetchUsage(request, { fetch: fetchMock })).toBeNull(); + expect(fetched).toBe(false); + }); + + test("marks a spent window exhausted and drops buckets with no percentage", async () => { + const payload = remainsPayload(); + payload.model_remains[0].current_interval_remaining_percent = 0; + payload.model_remains[1].current_interval_remaining_percent = undefined as unknown as number; + payload.model_remains[1].current_weekly_remaining_percent = undefined as unknown as number; + const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payload)); + + const report = await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock }); + + expect(report?.limits.map(limit => limit.id)).toEqual(["general:4h", "general:7d"]); + expect(report?.limits[0]?.status).toBe("exhausted"); + expect(report?.limits[0]?.amount.usedFraction).toBe(1); + }); + + test("returns null when the plan reports no quota buckets", async () => { + const fetchMock: FetchImpl = () => + Promise.resolve(Response.json({ model_remains: [], base_resp: { status_code: 0 } })); + + expect(await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock })).toBeNull(); + }); + + test("rejects a payload with no base_resp envelope", async () => { + const fetchMock: FetchImpl = () => + Promise.resolve(Response.json({ model_remains: remainsPayload().model_remains })); + + expect(await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock })).toBeNull(); + }); + + test("registers both Token Plan ids in AuthStorage's default usage resolver", async () => { + const store: AuthCredentialStore = { + close() {}, + listAuthCredentials() { + return []; + }, + updateAuthCredential() {}, + deleteAuthCredential() {}, + tryDisableAuthCredentialIfMatches() { + return false; + }, + replaceAuthCredentialsForProvider() { + return []; + }, + upsertAuthCredentialForProvider() { + return []; + }, + deleteAuthCredentialsForProvider() {}, + getCache() { + return null; + }, + setCache() {}, + cleanExpiredCache() {}, + }; + const storage = new AuthStorage(store); + await storage.reload(); + try { + expect(storage.usageProviderFor("minimax-code")).toBe(minimaxCodeUsageProvider); + expect(storage.usageProviderFor("minimax-code-cn")).toBe(minimaxCodeCnUsageProvider); + } finally { + storage.close(); + } + }); +}); From 57fb4adbdd1081e0b031740f87b83fe942fb62d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 26 Jul 2026 01:25:17 -0300 Subject: [PATCH 2/6] fix(ai): keep MiniMax plan status and drop not-in-plan quota buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser discarded `current_interval_status` / `current_weekly_status`, so a model that the plan does not include — reported as zero totals, status 3 on both windows and 100% remaining — surfaced as a pristine quota while generation is rejected. Both statuses are parsed now, and a bucket matching that exact shape is kept out of `limits` and named in `metadata.unavailableModels` instead. Zero totals alone stay valid: a live plan answers `0/0` with status 1 and a real remaining percentage, and that path is covered by the fixtures. Tests: the not-in-plan payload as a regression case (single bucket and whole plan), a request-level assertion that a configured `baseUrl` produces the exact `/v1/token_plan/remains` URL, and typed fixtures that drop the double type assertions in favour of buckets that genuinely omit the percentage keys. --- packages/ai/src/usage/minimax-code.ts | 30 ++++ .../ai/test/minimax-token-plan-usage.test.ts | 165 ++++++++++++++---- 2 files changed, 160 insertions(+), 35 deletions(-) diff --git a/packages/ai/src/usage/minimax-code.ts b/packages/ai/src/usage/minimax-code.ts index a95a76817..a0be28b64 100644 --- a/packages/ai/src/usage/minimax-code.ts +++ b/packages/ai/src/usage/minimax-code.ts @@ -15,6 +15,8 @@ const INTL_BASE_URL = "https://api.minimax.io"; const CN_BASE_URL = "https://api.minimaxi.com"; const REMAINS_PATH = "/v1/token_plan/remains"; const HOUR_MS = 60 * 60 * 1000; +/** `current_*_status` enum reported per window: 1 normal, 2 exhausted, 3 unlimited. */ +const STATUS_UNLIMITED = 3; /** One `model_remains[]` bucket: a plan quota tracked over a rolling interval plus a weekly window. */ interface TokenPlanBucket { @@ -24,11 +26,13 @@ interface TokenPlanBucket { intervalRemainingPercent?: number; intervalTotalCount?: number; intervalUsageCount?: number; + intervalStatus?: number; weeklyStart?: number; weeklyEnd?: number; weeklyRemainingPercent?: number; weeklyTotalCount?: number; weeklyUsageCount?: number; + weeklyStatus?: number; } /** MiniMax reports epoch milliseconds; tolerate seconds in case a deployment differs. */ @@ -63,14 +67,34 @@ function parseBucket(value: unknown): TokenPlanBucket | null { intervalRemainingPercent: toNumber(value.current_interval_remaining_percent), intervalTotalCount: toNumber(value.current_interval_total_count), intervalUsageCount: toNumber(value.current_interval_usage_count), + intervalStatus: toNumber(value.current_interval_status), weeklyStart: parseTimestamp(value.weekly_start_time), weeklyEnd: parseTimestamp(value.weekly_end_time), weeklyRemainingPercent: toNumber(value.current_weekly_remaining_percent), weeklyTotalCount: toNumber(value.current_weekly_total_count), weeklyUsageCount: toNumber(value.current_weekly_usage_count), + weeklyStatus: toNumber(value.current_weekly_status), }; } +/** + * A model outside the current plan is reported as both windows "unlimited" + * with zero totals and 100% remaining, which would otherwise read as a pristine + * quota. MiniMax's own CLI treats exactly this shape as "not in plan" + * ([MiniMax-AI/cli#173](https://github.com/MiniMax-AI/cli/issues/173)), so the + * bucket is kept out of the limits and named in `metadata.unavailableModels`. + * Zero totals alone are not enough: a live plan reports `0/0` with status 1 and + * a real remaining percentage. + */ +function isUnavailablePlan(bucket: TokenPlanBucket): boolean { + return ( + bucket.intervalTotalCount === 0 && + bucket.weeklyTotalCount === 0 && + bucket.intervalStatus === STATUS_UNLIMITED && + bucket.weeklyStatus === STATUS_UNLIMITED + ); +} + /** * Interval length varies per bucket (text quotas roll every few hours, media * quotas daily), so the window id follows the reported span instead of a @@ -216,10 +240,15 @@ async function fetchMiniMaxCodeUsage(params: UsageFetchParams, ctx: UsageFetchCo const accountId = params.credential.accountId; const limits: UsageLimit[] = []; const models: string[] = []; + const unavailableModels: string[] = []; for (const entry of payload.model_remains) { const bucket = parseBucket(entry); if (!bucket) continue; models.push(bucket.modelName); + if (isUnavailablePlan(bucket)) { + unavailableModels.push(bucket.modelName); + continue; + } limits.push(...buildBucketLimits(params.provider, bucket, accountId)); } if (limits.length === 0) return null; @@ -231,6 +260,7 @@ async function fetchMiniMaxCodeUsage(params: UsageFetchParams, ctx: UsageFetchCo metadata: { source: "minimax-token-plan", models, + ...(unavailableModels.length > 0 ? { unavailableModels } : {}), ...(accountId ? { accountId } : {}), }, raw: payload, diff --git a/packages/ai/test/minimax-token-plan-usage.test.ts b/packages/ai/test/minimax-token-plan-usage.test.ts index 85c5c8923..4c9c52fad 100644 --- a/packages/ai/test/minimax-token-plan-usage.test.ts +++ b/packages/ai/test/minimax-token-plan-usage.test.ts @@ -13,37 +13,90 @@ function params(provider: "minimax-code" | "minimax-code-cn", apiKey = "sk-cp-te return { provider, credential: { type: "api_key", apiKey }, accountKey: "account-1" }; } -function remainsPayload() { +/** One `model_remains[]` entry: percentages and statuses are optional because the endpoint omits them. */ +interface RemainsBucket { + model_name: string; + start_time: number; + end_time: number; + current_interval_total_count: number; + current_interval_usage_count: number; + current_interval_remaining_percent?: number; + current_interval_status?: number; + weekly_start_time: number; + weekly_end_time: number; + current_weekly_total_count: number; + current_weekly_usage_count: number; + current_weekly_remaining_percent?: number; + current_weekly_status?: number; +} + +interface RemainsPayload { + model_remains: RemainsBucket[]; + base_resp: { status_code: number; status_msg: string }; +} + +/** A live plan bucket: zero totals with status 1 still carry a real remaining percentage. */ +function generalBucket(): RemainsBucket { return { - model_remains: [ - { - model_name: "general", - start_time: INTERVAL_START, - end_time: INTERVAL_END, - current_interval_total_count: 0, - current_interval_usage_count: 0, - current_interval_remaining_percent: 90, - weekly_start_time: WEEKLY_START, - weekly_end_time: WEEKLY_END, - current_weekly_total_count: 0, - current_weekly_usage_count: 0, - current_weekly_remaining_percent: 78, - }, - { - model_name: "video", - start_time: INTERVAL_END - 86_400_000, - end_time: INTERVAL_END, - current_interval_total_count: 3, - current_interval_usage_count: 1, - current_interval_remaining_percent: 100, - weekly_start_time: WEEKLY_START, - weekly_end_time: WEEKLY_END, - current_weekly_total_count: 21, - current_weekly_usage_count: 1, - current_weekly_remaining_percent: 100, - }, - ], - base_resp: { status_code: 0, status_msg: "success" }, + model_name: "general", + start_time: INTERVAL_START, + end_time: INTERVAL_END, + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 90, + current_interval_status: 1, + weekly_start_time: WEEKLY_START, + weekly_end_time: WEEKLY_END, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 78, + current_weekly_status: 1, + }; +} + +/** A metered bucket: request counts on both windows. */ +function videoBucket(): RemainsBucket { + return { + model_name: "video", + start_time: INTERVAL_END - 86_400_000, + end_time: INTERVAL_END, + current_interval_total_count: 3, + current_interval_usage_count: 1, + current_interval_remaining_percent: 100, + current_interval_status: 1, + weekly_start_time: WEEKLY_START, + weekly_end_time: WEEKLY_END, + current_weekly_total_count: 21, + current_weekly_usage_count: 1, + current_weekly_remaining_percent: 100, + current_weekly_status: 1, + }; +} + +function payloadOf(...buckets: RemainsBucket[]): RemainsPayload { + return { model_remains: buckets, base_resp: { status_code: 0, status_msg: "success" } }; +} + +function remainsPayload(): RemainsPayload { + return payloadOf(generalBucket(), videoBucket()); +} + +/** The bucket shape MiniMax returns for a model the plan does not include (MiniMax-AI/cli#173). */ +function notInPlanBucket(modelName: string): RemainsBucket { + return { + model_name: modelName, + start_time: INTERVAL_START, + end_time: INTERVAL_END, + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 100, + current_interval_status: 3, + weekly_start_time: WEEKLY_START, + weekly_end_time: WEEKLY_END, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 100, + current_weekly_status: 3, }; } @@ -110,6 +163,20 @@ describe("MiniMax Token Plan usage", () => { expect(report?.provider).toBe("minimax-code-cn"); }); + test("honors a configured base URL for the quota request", async () => { + let requestedUrl = ""; + const fetchMock: FetchImpl = input => { + requestedUrl = String(input); + return Promise.resolve(Response.json(remainsPayload())); + }; + const request: UsageFetchParams = { ...params("minimax-code"), baseUrl: "https://proxy.example/v1/" }; + + const report = await minimaxCodeUsageProvider.fetchUsage(request, { fetch: fetchMock }); + + expect(requestedUrl).toBe("https://proxy.example/v1/token_plan/remains"); + expect(report?.provider).toBe("minimax-code"); + }); + test("fails closed when MiniMax rejects the key inside a 200 response", async () => { const fetchMock: FetchImpl = () => Promise.resolve( @@ -135,11 +202,22 @@ describe("MiniMax Token Plan usage", () => { }); test("marks a spent window exhausted and drops buckets with no percentage", async () => { - const payload = remainsPayload(); - payload.model_remains[0].current_interval_remaining_percent = 0; - payload.model_remains[1].current_interval_remaining_percent = undefined as unknown as number; - payload.model_remains[1].current_weekly_remaining_percent = undefined as unknown as number; - const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payload)); + const spentGeneral: RemainsBucket = { ...generalBucket(), current_interval_remaining_percent: 0 }; + const videoWithoutPercentages: RemainsBucket = { + model_name: "video", + start_time: INTERVAL_END - 86_400_000, + end_time: INTERVAL_END, + current_interval_total_count: 3, + current_interval_usage_count: 1, + current_interval_status: 1, + weekly_start_time: WEEKLY_START, + weekly_end_time: WEEKLY_END, + current_weekly_total_count: 21, + current_weekly_usage_count: 1, + current_weekly_status: 1, + }; + const fetchMock: FetchImpl = () => + Promise.resolve(Response.json(payloadOf(spentGeneral, videoWithoutPercentages))); const report = await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock }); @@ -155,6 +233,23 @@ describe("MiniMax Token Plan usage", () => { expect(await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock })).toBeNull(); }); + test("keeps a model that is not in the plan out of the reported quota", async () => { + const fetchMock: FetchImpl = () => + Promise.resolve(Response.json(payloadOf(generalBucket(), notInPlanBucket("video")))); + + const report = await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock }); + + expect(report?.limits.map(limit => limit.id)).toEqual(["general:4h", "general:7d"]); + expect(report?.metadata).toMatchObject({ models: ["general", "video"], unavailableModels: ["video"] }); + }); + + test("returns null when every model is outside the plan", async () => { + const fetchMock: FetchImpl = () => + Promise.resolve(Response.json(payloadOf(notInPlanBucket("general"), notInPlanBucket("video")))); + + expect(await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock })).toBeNull(); + }); + test("rejects a payload with no base_resp envelope", async () => { const fetchMock: FetchImpl = () => Promise.resolve(Response.json({ model_remains: remainsPayload().model_remains })); From 113655b7f8b342cb791672b663f8ea794ffe9c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 26 Jul 2026 01:54:40 -0300 Subject: [PATCH 3/6] test(ai): cover mixed plan windows and every base URL trim The not-in-plan predicate reads four fields, but the suite only exercised buckets whose windows agreed, so an implementation checking a single window would still pass. Adds a bucket metered on the weekly window and another metered on the interval window: both must keep their limits and stay out of `unavailableModels`. The base URL test drove one input, which exercised the two trims at once. It now walks a bare host, a trailing slash and a trailing `/v1/`, asserting the same `/v1/token_plan/remains` URL for each. --- .../ai/test/minimax-token-plan-usage.test.ts | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/ai/test/minimax-token-plan-usage.test.ts b/packages/ai/test/minimax-token-plan-usage.test.ts index 4c9c52fad..ad2d3fcd8 100644 --- a/packages/ai/test/minimax-token-plan-usage.test.ts +++ b/packages/ai/test/minimax-token-plan-usage.test.ts @@ -164,17 +164,20 @@ describe("MiniMax Token Plan usage", () => { }); test("honors a configured base URL for the quota request", async () => { - let requestedUrl = ""; - const fetchMock: FetchImpl = input => { - requestedUrl = String(input); - return Promise.resolve(Response.json(remainsPayload())); - }; - const request: UsageFetchParams = { ...params("minimax-code"), baseUrl: "https://proxy.example/v1/" }; + // One case per trim the provider applies: trailing slash, trailing `/v1`, and both together. + for (const configured of ["https://proxy.example", "https://proxy.example/", "https://proxy.example/v1/"]) { + let requestedUrl = ""; + const fetchMock: FetchImpl = input => { + requestedUrl = String(input); + return Promise.resolve(Response.json(remainsPayload())); + }; + const request: UsageFetchParams = { ...params("minimax-code"), baseUrl: configured }; - const report = await minimaxCodeUsageProvider.fetchUsage(request, { fetch: fetchMock }); + const report = await minimaxCodeUsageProvider.fetchUsage(request, { fetch: fetchMock }); - expect(requestedUrl).toBe("https://proxy.example/v1/token_plan/remains"); - expect(report?.provider).toBe("minimax-code"); + expect(requestedUrl).toBe("https://proxy.example/v1/token_plan/remains"); + expect(report?.provider).toBe("minimax-code"); + } }); test("fails closed when MiniMax rejects the key inside a 200 response", async () => { @@ -250,6 +253,32 @@ describe("MiniMax Token Plan usage", () => { expect(await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock })).toBeNull(); }); + test("keeps a bucket whose windows disagree about being in the plan", async () => { + const meteredWeekly: RemainsBucket = { + ...notInPlanBucket("video"), + current_weekly_status: 1, + current_weekly_total_count: 21, + current_weekly_usage_count: 1, + current_weekly_remaining_percent: 40, + }; + const meteredInterval: RemainsBucket = { + ...notInPlanBucket("video"), + current_interval_status: 1, + current_interval_total_count: 3, + current_interval_usage_count: 1, + current_interval_remaining_percent: 40, + }; + + for (const bucket of [meteredWeekly, meteredInterval]) { + const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payloadOf(bucket))); + + const report = await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock }); + + expect(report?.limits.map(limit => limit.id)).toEqual(["video:4h", "video:7d"]); + expect(report?.metadata).not.toHaveProperty("unavailableModels"); + } + }); + test("rejects a payload with no base_resp envelope", async () => { const fetchMock: FetchImpl = () => Promise.resolve(Response.json({ model_remains: remainsPayload().model_remains })); From d937a4b908698001212194a61b2d7431e625db84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 26 Jul 2026 02:00:10 -0300 Subject: [PATCH 4/6] fix(ai): scope the general Token Plan quota as shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `general` is a quota category, not a catalog model id. Scoping its limits with `modelId: "general"` meant `getUsageReportingModelIds` — which, absent a MiniMax ranking strategy, accepts only `scope.shared` or an exact catalog id — matched nothing, so `/usage` dropped the models mapping for MiniMax even though the plan quota covers them. That bucket is now scoped `shared`, while category buckets such as `video` keep their own model scope. The regression test asserts both scopes and then runs the report through `AuthStorage.getUsageReportingModelIds`, which must return the catalog ids. --- packages/ai/src/usage/minimax-code.ts | 10 ++- .../ai/test/minimax-token-plan-usage.test.ts | 76 +++++++++++++------ 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/packages/ai/src/usage/minimax-code.ts b/packages/ai/src/usage/minimax-code.ts index a0be28b64..7dd9a9d02 100644 --- a/packages/ai/src/usage/minimax-code.ts +++ b/packages/ai/src/usage/minimax-code.ts @@ -17,6 +17,14 @@ const REMAINS_PATH = "/v1/token_plan/remains"; const HOUR_MS = 60 * 60 * 1000; /** `current_*_status` enum reported per window: 1 normal, 2 exhausted, 3 unlimited. */ const STATUS_UNLIMITED = 3; +/** + * The plan-wide token quota every chat model draws from. It is a quota category, + * not a catalog model id, so its limits are scoped `shared`: `AuthStorage` has no + * MiniMax ranking strategy and would otherwise match `scope.modelId` against ids + * like `MiniMax-M3` and drop the "models with usage data" mapping. Category + * buckets such as `video` meter a separate quota and keep their own model scope. + */ +const SHARED_BUCKET = "general"; /** One `model_remains[]` bucket: a plan quota tracked over a rolling interval plus a weekly window. */ interface TokenPlanBucket { @@ -133,7 +141,7 @@ function buildLimit(args: { scope: { provider: args.provider, ...(args.accountId ? { accountId: args.accountId } : {}), - modelId: args.bucket.modelName, + ...(args.bucket.modelName === SHARED_BUCKET ? { shared: true as const } : { modelId: args.bucket.modelName }), windowId: args.windowId, }, window: { diff --git a/packages/ai/test/minimax-token-plan-usage.test.ts b/packages/ai/test/minimax-token-plan-usage.test.ts index ad2d3fcd8..cd62819a9 100644 --- a/packages/ai/test/minimax-token-plan-usage.test.ts +++ b/packages/ai/test/minimax-token-plan-usage.test.ts @@ -13,6 +13,32 @@ function params(provider: "minimax-code" | "minimax-code-cn", apiKey = "sk-cp-te return { provider, credential: { type: "api_key", apiKey }, accountKey: "account-1" }; } +function emptyStore(): AuthCredentialStore { + return { + close() {}, + listAuthCredentials() { + return []; + }, + updateAuthCredential() {}, + deleteAuthCredential() {}, + tryDisableAuthCredentialIfMatches() { + return false; + }, + replaceAuthCredentialsForProvider() { + return []; + }, + upsertAuthCredentialForProvider() { + return []; + }, + deleteAuthCredentialsForProvider() {}, + getCache() { + return null; + }, + setCache() {}, + cleanExpiredCache() {}, + }; +} + /** One `model_remains[]` entry: percentages and statuses are optional because the endpoint omits them. */ interface RemainsBucket { model_name: string; @@ -287,30 +313,7 @@ describe("MiniMax Token Plan usage", () => { }); test("registers both Token Plan ids in AuthStorage's default usage resolver", async () => { - const store: AuthCredentialStore = { - close() {}, - listAuthCredentials() { - return []; - }, - updateAuthCredential() {}, - deleteAuthCredential() {}, - tryDisableAuthCredentialIfMatches() { - return false; - }, - replaceAuthCredentialsForProvider() { - return []; - }, - upsertAuthCredentialForProvider() { - return []; - }, - deleteAuthCredentialsForProvider() {}, - getCache() { - return null; - }, - setCache() {}, - cleanExpiredCache() {}, - }; - const storage = new AuthStorage(store); + const storage = new AuthStorage(emptyStore()); await storage.reload(); try { expect(storage.usageProviderFor("minimax-code")).toBe(minimaxCodeUsageProvider); @@ -319,4 +322,29 @@ describe("MiniMax Token Plan usage", () => { storage.close(); } }); + + test("reports the shared plan quota against catalog model ids", async () => { + const fetchMock: FetchImpl = () => Promise.resolve(Response.json(remainsPayload())); + + const report = await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock }); + if (!report) throw new Error("expected a usage report"); + + const general = report.limits.find(limit => limit.id === "general:4h"); + expect(general?.scope).toEqual({ provider: "minimax-code", shared: true, windowId: "4h" }); + const video = report.limits.find(limit => limit.id === "video:24h"); + expect(video?.scope).toEqual({ provider: "minimax-code", modelId: "video", windowId: "24h" }); + + // Without a MiniMax ranking strategy AuthStorage matches `shared` or an exact + // catalog id, so a bucket-name scope would report no models at all. + const storage = new AuthStorage(emptyStore()); + await storage.reload(); + try { + expect(storage.getUsageReportingModelIds("minimax-code", ["MiniMax-M3", "MiniMax-M2"], [report])).toEqual([ + "MiniMax-M3", + "MiniMax-M2", + ]); + } finally { + storage.close(); + } + }); }); From 50033beb5ac580b6962fa2e1ae95960d10b5957b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 26 Jul 2026 02:09:51 -0300 Subject: [PATCH 5/6] feat(ai): land the Token Plan usage provider for the international host only Registering a second default provider fetch was flagged as needing maintainer sign-off, and `api.minimaxi.com` cannot be exercised by anyone on this change, so the mainland id is left exactly as it was: `minimax-code-cn` is not registered, its base URL and provider export are gone, and `supports()` no longer claims an id that resolves to nothing. The parser, the not-in-plan handling and the shared scope are unchanged and keep their tests; the suite now asserts that the CN id resolves to no usage provider, so wiring it later is a deliberate change rather than a silent one. --- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/auth-storage.ts | 3 +-- packages/ai/src/usage/minimax-code.ts | 20 +++----------- .../ai/test/minimax-token-plan-usage.test.ts | 27 +++++-------------- 4 files changed, 12 insertions(+), 40 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7c682fd6b..f722d397c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- MiniMax Token Plan accounts now report quota in `omp usage`. `GET /v1/token_plan/remains` returns one bucket per plan quota, each carrying a rolling interval window and a weekly window, so `minimax-code` and `minimax-code-cn` surface real remaining percentages instead of empty reports. Both ids are registered in the default usage provider set — the China id previously resolved to nothing, so it never reached a fetcher at all. +- MiniMax Token Plan accounts now report quota in `omp usage`. `GET /v1/token_plan/remains` returns one bucket per plan quota, each carrying a rolling interval window and a weekly window, so `minimax-code` surfaces real remaining percentages instead of an empty report. A model the plan does not include comes back looking like an untouched quota; those buckets are dropped from the report and named in its metadata. The mainland id `minimax-code-cn` is untouched. - OAuth logins now stamp `authorizedAt` (epoch ms of the interactive login) on the stored credential, and every refresh-persist path preserves it. Anthropic expires the whole OAuth grant family ~30 days after authorization regardless of refresh-token rotation (observed as `invalid_grant: "Refresh token expired"` on the latest rotated token, exactly 30 days after login, across four production accounts), so the login anchor is what makes re-login deadlines computable. Exported `ANTHROPIC_OAUTH_GRANT_TTL_MS` alongside the anthropic OAuth flow. - Added `GET /v1/credentials/disabled` to the auth broker and `AuthBrokerClient.listDisabledCredentials`: disabled-credential tombstones (`DisabledCredentialSummary` — identity, verbatim disable cause, disable timestamp; never token material) so auto-disabled accounts stay visible to clients instead of silently vanishing from the snapshot. `AuthStorage.listDisabledCredentials` serves the same data locally from SQLite; clients of brokers predating the endpoint get an empty list (404 mapped, no error). - Added `AuthStorage.revalidateCredentials()` and the optional `AuthCredentialStore.refreshSnapshot` hook: remote broker stores re-fetch `GET /v1/snapshot` on demand so callers pairing live per-credential data with stored identities (`omp usage`) never render against the up-to-an-hour-stale disk-cached snapshot; local SQLite stores are always current and only reload. diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index cd00041aa..2886e5f4b 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -54,7 +54,7 @@ import { googleGeminiCliUsageProvider } from "./usage/gemini"; import { githubCopilotUsageProvider } from "./usage/github-copilot"; import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity"; import { kimiUsageProvider } from "./usage/kimi"; -import { minimaxCodeCnUsageProvider, minimaxCodeUsageProvider } from "./usage/minimax-code"; +import { minimaxCodeUsageProvider } from "./usage/minimax-code"; import { ollamaCloudUsageProvider, ollamaUsageProvider } from "./usage/ollama"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; import { @@ -645,7 +645,6 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [ openaiCodexUsageProvider, kimiUsageProvider, minimaxCodeUsageProvider, - minimaxCodeCnUsageProvider, antigravityUsageProvider, googleGeminiCliUsageProvider, ollamaUsageProvider, diff --git a/packages/ai/src/usage/minimax-code.ts b/packages/ai/src/usage/minimax-code.ts index 7dd9a9d02..90f68ac70 100644 --- a/packages/ai/src/usage/minimax-code.ts +++ b/packages/ai/src/usage/minimax-code.ts @@ -10,9 +10,7 @@ import { isRecord } from "../utils"; import { toNumber } from "./shared"; const INTL_PROVIDER = "minimax-code"; -const CN_PROVIDER = "minimax-code-cn"; const INTL_BASE_URL = "https://api.minimax.io"; -const CN_BASE_URL = "https://api.minimaxi.com"; const REMAINS_PATH = "/v1/token_plan/remains"; const HOUR_MS = 60 * 60 * 1000; /** `current_*_status` enum reported per window: 1 normal, 2 exhausted, 3 unlimited. */ @@ -203,7 +201,7 @@ function buildBucketLimits(provider: string, bucket: TokenPlanBucket, accountId: } /** - * MiniMax Token Plan usage provider. + * MiniMax Token Plan usage provider (international, `api.minimax.io`). * * `GET /v1/token_plan/remains` returns one `model_remains[]` bucket per plan * quota (text, media, …), each carrying a rolling interval window and a weekly @@ -211,17 +209,13 @@ function buildBucketLimits(provider: string, bucket: TokenPlanBucket, accountId: * rejected credentials, so `base_resp.status_code` is the real success signal. */ async function fetchMiniMaxCodeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { - if (params.provider !== INTL_PROVIDER && params.provider !== CN_PROVIDER) return null; + if (params.provider !== INTL_PROVIDER) return null; const apiKey = params.credential.apiKey; if (params.credential.type !== "api_key" || !apiKey) return null; try { const configuredBaseUrl = params.baseUrl?.trim(); - const baseUrl = configuredBaseUrl - ? configuredBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "") - : params.provider === CN_PROVIDER - ? CN_BASE_URL - : INTL_BASE_URL; + const baseUrl = configuredBaseUrl ? configuredBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "") : INTL_BASE_URL; const response = await ctx.fetch(`${baseUrl}${REMAINS_PATH}`, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, signal: params.signal, @@ -289,11 +283,3 @@ export const minimaxCodeUsageProvider: UsageProvider = { supports: params => params.provider === INTL_PROVIDER && params.credential.type === "api_key" && Boolean(params.credential.apiKey), }; - -/** MiniMax Token Plan (China, `api.minimaxi.com`). */ -export const minimaxCodeCnUsageProvider: UsageProvider = { - id: CN_PROVIDER, - fetchUsage: fetchMiniMaxCodeUsage, - supports: params => - params.provider === CN_PROVIDER && params.credential.type === "api_key" && Boolean(params.credential.apiKey), -}; diff --git a/packages/ai/test/minimax-token-plan-usage.test.ts b/packages/ai/test/minimax-token-plan-usage.test.ts index cd62819a9..463729b0d 100644 --- a/packages/ai/test/minimax-token-plan-usage.test.ts +++ b/packages/ai/test/minimax-token-plan-usage.test.ts @@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test"; import { type AuthCredentialStore, AuthStorage } from "@oh-my-pi/pi-ai/auth-storage"; import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; import type { UsageFetchParams } from "@oh-my-pi/pi-ai/usage"; -import { minimaxCodeCnUsageProvider, minimaxCodeUsageProvider } from "@oh-my-pi/pi-ai/usage/minimax-code"; +import { minimaxCodeUsageProvider } from "@oh-my-pi/pi-ai/usage/minimax-code"; const INTERVAL_START = 1_785_009_600_000; const INTERVAL_END = 1_785_024_000_000; const WEEKLY_START = 1_784_505_600_000; const WEEKLY_END = 1_785_110_400_000; -function params(provider: "minimax-code" | "minimax-code-cn", apiKey = "sk-cp-test"): UsageFetchParams { +function params(provider: "minimax-code" = "minimax-code", apiKey = "sk-cp-test"): UsageFetchParams { return { provider, credential: { type: "api_key", apiKey }, accountKey: "account-1" }; } @@ -176,19 +176,6 @@ describe("MiniMax Token Plan usage", () => { expect(videoWeekly?.notes).toEqual(["Requests: 1/21"]); }); - test("routes the China provider to the mainland endpoint", async () => { - let requestedUrl = ""; - const fetchMock: FetchImpl = input => { - requestedUrl = String(input); - return Promise.resolve(Response.json(remainsPayload())); - }; - - const report = await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock }); - - expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); - expect(report?.provider).toBe("minimax-code-cn"); - }); - test("honors a configured base URL for the quota request", async () => { // One case per trim the provider applies: trailing slash, trailing `/v1`, and both together. for (const configured of ["https://proxy.example", "https://proxy.example/", "https://proxy.example/v1/"]) { @@ -266,7 +253,7 @@ describe("MiniMax Token Plan usage", () => { const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payloadOf(generalBucket(), notInPlanBucket("video")))); - const report = await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock }); + const report = await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock }); expect(report?.limits.map(limit => limit.id)).toEqual(["general:4h", "general:7d"]); expect(report?.metadata).toMatchObject({ models: ["general", "video"], unavailableModels: ["video"] }); @@ -276,7 +263,7 @@ describe("MiniMax Token Plan usage", () => { const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payloadOf(notInPlanBucket("general"), notInPlanBucket("video")))); - expect(await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock })).toBeNull(); + expect(await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock })).toBeNull(); }); test("keeps a bucket whose windows disagree about being in the plan", async () => { @@ -298,7 +285,7 @@ describe("MiniMax Token Plan usage", () => { for (const bucket of [meteredWeekly, meteredInterval]) { const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payloadOf(bucket))); - const report = await minimaxCodeCnUsageProvider.fetchUsage(params("minimax-code-cn"), { fetch: fetchMock }); + const report = await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock }); expect(report?.limits.map(limit => limit.id)).toEqual(["video:4h", "video:7d"]); expect(report?.metadata).not.toHaveProperty("unavailableModels"); @@ -312,12 +299,12 @@ describe("MiniMax Token Plan usage", () => { expect(await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock })).toBeNull(); }); - test("registers both Token Plan ids in AuthStorage's default usage resolver", async () => { + test("registers the Token Plan id in AuthStorage's default usage resolver", async () => { const storage = new AuthStorage(emptyStore()); await storage.reload(); try { expect(storage.usageProviderFor("minimax-code")).toBe(minimaxCodeUsageProvider); - expect(storage.usageProviderFor("minimax-code-cn")).toBe(minimaxCodeCnUsageProvider); + expect(storage.usageProviderFor("minimax-code-cn")).toBeUndefined(); } finally { storage.close(); } From 07a734d8d9c2192d670bf8db39f9a51da3d4c50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 26 Jul 2026 02:30:00 -0300 Subject: [PATCH 6/6] fix(ai): honour the window status the endpoint reports as exhausted The parser kept `current_*_status` but only read it to detect a not-in-plan bucket, so an exhausted window (`status: 2`) that omits its optional remaining percentage was dropped from the report entirely, and one that keeps a stale percentage rendered as healthy quota. The status now outranks the percentage: status 2 pins the window to a fully used fraction, which `usageStatus` maps to `exhausted`, and the window survives with no percentage at all. --- packages/ai/src/usage/minimax-code.ts | 8 +++++++- .../ai/test/minimax-token-plan-usage.test.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/usage/minimax-code.ts b/packages/ai/src/usage/minimax-code.ts index 90f68ac70..9f913332d 100644 --- a/packages/ai/src/usage/minimax-code.ts +++ b/packages/ai/src/usage/minimax-code.ts @@ -14,6 +14,7 @@ const INTL_BASE_URL = "https://api.minimax.io"; const REMAINS_PATH = "/v1/token_plan/remains"; const HOUR_MS = 60 * 60 * 1000; /** `current_*_status` enum reported per window: 1 normal, 2 exhausted, 3 unlimited. */ +const STATUS_EXHAUSTED = 2; const STATUS_UNLIMITED = 3; /** * The plan-wide token quota every chat model draws from. It is a quota category, @@ -126,11 +127,14 @@ function buildLimit(args: { durationMs?: number; resetsAt?: number; usedFraction: number | undefined; + windowStatus?: number; usageCount?: number; totalCount?: number; accountId?: string; }): UsageLimit | undefined { - const { usedFraction } = args; + // The endpoint's own status outranks the percentage: an exhausted window may + // omit it, or keep a stale one that would otherwise render as healthy quota. + const usedFraction = args.windowStatus === STATUS_EXHAUSTED ? 1 : args.usedFraction; if (usedFraction === undefined) return undefined; const totalCount = args.totalCount; return { @@ -181,6 +185,7 @@ function buildBucketLimits(provider: string, bucket: TokenPlanBucket, accountId: durationMs: intervalDuration, resetsAt: bucket.intervalEnd, usedFraction: usedFractionFromRemainingPercent(bucket.intervalRemainingPercent), + windowStatus: bucket.intervalStatus, usageCount: bucket.intervalUsageCount, totalCount: bucket.intervalTotalCount, accountId, @@ -193,6 +198,7 @@ function buildBucketLimits(provider: string, bucket: TokenPlanBucket, accountId: durationMs: weeklyDuration, resetsAt: bucket.weeklyEnd, usedFraction: usedFractionFromRemainingPercent(bucket.weeklyRemainingPercent), + windowStatus: bucket.weeklyStatus, usageCount: bucket.weeklyUsageCount, totalCount: bucket.weeklyTotalCount, accountId, diff --git a/packages/ai/test/minimax-token-plan-usage.test.ts b/packages/ai/test/minimax-token-plan-usage.test.ts index 463729b0d..056adc109 100644 --- a/packages/ai/test/minimax-token-plan-usage.test.ts +++ b/packages/ai/test/minimax-token-plan-usage.test.ts @@ -249,6 +249,26 @@ describe("MiniMax Token Plan usage", () => { expect(await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock })).toBeNull(); }); + test("reports a window the endpoint marks exhausted, percentage or not", async () => { + const noPercentage: RemainsBucket = { + ...generalBucket(), + current_interval_status: 2, + current_interval_remaining_percent: undefined, + }; + const stalePercentage: RemainsBucket = { ...generalBucket(), current_interval_status: 2 }; + + for (const bucket of [noPercentage, stalePercentage]) { + const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payloadOf(bucket))); + + const report = await minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { fetch: fetchMock }); + + const interval = report?.limits.find(limit => limit.id === "general:4h"); + expect(interval?.status).toBe("exhausted"); + expect(interval?.amount).toMatchObject({ usedFraction: 1, remainingFraction: 0 }); + expect(report?.limits.find(limit => limit.id === "general:7d")?.status).toBe("ok"); + } + }); + test("keeps a model that is not in the plan out of the reported quota", async () => { const fetchMock: FetchImpl = () => Promise.resolve(Response.json(payloadOf(generalBucket(), notInPlanBucket("video"))));