diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0da194a8a..caf2ad81d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,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` 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 252af90ce..51b9f25ac 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 { minimaxCodeUsageProvider } from "./usage/minimax-code"; import { ollamaCloudUsageProvider, ollamaUsageProvider } from "./usage/ollama"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; import { @@ -645,6 +646,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [ alibabaTokenPlanUsageProvider, openaiCodexUsageProvider, kimiUsageProvider, + minimaxCodeUsageProvider, antigravityUsageProvider, googleGeminiCliUsageProvider, ollamaUsageProvider, diff --git a/packages/ai/src/usage/minimax-code.ts b/packages/ai/src/usage/minimax-code.ts index 78cdf80b9..9f913332d 100644 --- a/packages/ai/src/usage/minimax-code.ts +++ b/packages/ai/src/usage/minimax-code.ts @@ -1,30 +1,291 @@ -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 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; /** - * 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. + * 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. */ -async function fetchMiniMaxCodeUsage(params: UsageFetchParams, _ctx: UsageFetchContext): Promise { - if (params.provider !== "minimax-code" && params.provider !== "minimax-code-cn") { - return null; - } +const SHARED_BUCKET = "general"; - // MiniMax Token Plan does not currently expose a usage API - // Users can check their usage via the web dashboard - return null; +/** 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; + 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. */ +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), + 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 + * 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; + windowStatus?: number; + usageCount?: number; + totalCount?: number; + accountId?: string; +}): UsageLimit | undefined { + // 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 { + 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 } : {}), + ...(args.bucket.modelName === SHARED_BUCKET ? { shared: true as const } : { 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), + windowStatus: bucket.intervalStatus, + usageCount: bucket.intervalUsageCount, + totalCount: bucket.intervalTotalCount, + accountId, + }), + buildLimit({ + provider, + bucket, + windowId: "7d", + windowLabel: "7 Day", + durationMs: weeklyDuration, + resetsAt: bucket.weeklyEnd, + usedFraction: usedFractionFromRemainingPercent(bucket.weeklyRemainingPercent), + windowStatus: bucket.weeklyStatus, + usageCount: bucket.weeklyUsageCount, + totalCount: bucket.weeklyTotalCount, + accountId, + }), + ].filter((limit): limit is UsageLimit => limit !== undefined); +} + +/** + * 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 + * 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 !== 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$/, "") : 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[] = []; + 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; + + return { + provider: params.provider, + fetchedAt: Date.now(), + limits, + metadata: { + source: "minimax-token-plan", + models, + ...(unavailableModels.length > 0 ? { unavailableModels } : {}), + ...(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 (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), }; 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..056adc109 --- /dev/null +++ b/packages/ai/test/minimax-token-plan-usage.test.ts @@ -0,0 +1,357 @@ +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 { 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", apiKey = "sk-cp-test"): UsageFetchParams { + 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; + 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_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, + }; +} + +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("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/"]) { + 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 }); + + 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( + 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 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 }); + + 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("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")))); + + 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"] }); + }); + + 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 minimaxCodeUsageProvider.fetchUsage(params("minimax-code"), { 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 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"); + } + }); + + 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 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")).toBeUndefined(); + } finally { + 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(); + } + }); +});