From 217396208871e0f47962425eba5c1bd8194d2778 Mon Sep 17 00:00:00 2001 From: Ling Li Date: Fri, 25 Sep 2026 17:13:58 +0800 Subject: [PATCH] fix: restore MiniMax Coding Plan quota reporting --- .../src/content/docs/guides/providers.md | 5 + .../content/docs/zh-cn/guides/providers.md | 4 + scripts/test-layout/layout.json | 1 + src/providers/quota/vendor-probes-key.ts | 57 ++++----- structure/providers-and-adapters.md | 5 + tests/fixtures/test-layout-expected.json | 1 + tests/providers/minimax-quota.test.ts | 110 ++++++++++++++++++ tests/providers/provider-quota.test.ts | 59 +--------- 8 files changed, 159 insertions(+), 83 deletions(-) create mode 100644 tests/providers/minimax-quota.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 5b8930f2b22..8e88eef5a98 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -498,6 +498,7 @@ region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper | Umans AI · Neuralwatt | `https://api.code.umans.ai` · `https://api.neuralwatt.com/v1` | | Mistral | `https://api.mistral.ai/v1` | | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | + | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | | Chutes | `https://llm.chutes.ai/v1` | @@ -538,6 +539,10 @@ region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | …and more | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | +The MiniMax and MiniMax (CN) provider cards can also show Coding Plan quota when the configured +key has an active plan. The dashboard reads the plan's 5-hour window and, when present, weekly +window; these are display observations and do not change model routing. + **OpenCode Go** requires a stable session identifier for routing. OpenCodex derives its Go session header from Codex thread/session headers, or from a client's `x-opencode-session` header when Codex headers are absent. This applies to direct diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 04551fd679d..5b8c902e5bf 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -211,6 +211,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Umans AI · Neuralwatt | `https://api.code.umans.ai` · `https://api.neuralwatt.com/v1` | | Mistral | `https://api.mistral.ai/v1` | | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | + | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | | Chutes | `https://llm.chutes.ai/v1` | @@ -246,6 +247,9 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | ……以及更多 | opencode zen、Vercel AI Gateway、Venice、NanoGPT、Synthetic、Qianfan、Alibaba、Parallel、ZenMux、LiteLLM | +MiniMax 和 MiniMax (CN) 的提供商卡片也会在配置的密钥有有效 Coding Plan 时显示用量。 +仪表盘读取 5 小时窗口,以及套餐提供时的每周窗口;这些仅用于展示,不会改变模型路由。 + **OpenCode Zen**(`opencode-zen`)与免密钥的 **OpenCode Free** 预设共用 `https://opencode.ai/zen/v1`。该网关上的免费模型常会触发约每分钟 15–20 次请求的短窗口限流(社区观测;OpenCode 未公布 RPM)。Zen 可能返回不带 `Retry-After` / `X-RateLimit-*` 的通用 429。这与免密钥桌面配额(`opencode-free` 上约每 5 小时 200 次 Big Pickle/免费模型请求)是分开的。当这类 429 省略 `Retry-After` 时,opencodex 会在客户端错误中补充说明并附带合成的 `Retry-After`;若上游已提供 `Retry-After`,则仍以它为准。同密钥等待重试仍可通过 [`retryOn429`](/zh-cn/reference/configuration/) 选择开启。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b436f658bb5..851c2a3509e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1175,6 +1175,7 @@ "mimo-token-plan-provider.test.ts": "providers", "mimo-v26-catalog.test.ts": "providers", "minimax-clients.test.ts": "providers", + "minimax-quota.test.ts": "providers", "minimax-reasoning-split.test.ts": "providers", "model-cache-generation-tombstone.test.ts": "codex-integration", "model-cache.test.ts": "codex-integration", diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 59347607f5e..dee902b89c2 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -40,7 +40,7 @@ const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`; const ZAI_BASE_URL = "https://api.z.ai"; const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; -const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; +const MINIMAX_REMAINS_PATH = "/v1/api/openplatform/coding_plan/remains"; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; @@ -652,20 +652,17 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi } /** - * MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's - * remaining quota as a countdown-time value (ms). The endpoint does not expose - * the plan's total duration, so no percentage is fabricated from a presumed - * window: the remaining time is reported as a duration-only window. When the - * API supplies a total (`total_time` / `plan_duration_ms`), a consumed share - * is derived from it. Region selects the host: `minimax` → www.minimax.io, - * `minimax-cn` → api.minimaxi.com. + * MiniMax Coding Plan `GET /v1/api/openplatform/coding_plan/remains` reports + * remaining percentages per model/window. Only the `general` model is the + * Coding Plan quota; video remains are unrelated. Region selects the host. */ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com"); - const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL; + const canonicalHost = cnHost ? "https://api.minimaxi.com" : "https://api.minimax.io"; + const remainsUrl = `${canonicalHost}${MINIMAX_REMAINS_PATH}`; const response = await quotaFetch(provider, config, remainsUrl, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", @@ -677,24 +674,32 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P : null; } const body = asRecord(await readQuotaJson(response)); - if (!body || body.success === false) return null; - const data = asRecord(body.data) ?? body; - const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime); - if (remainsMs === undefined || remainsMs < 0) return null; - const hours = Math.floor(remainsMs / 3_600_000); - const label = `Token Plan remaining (${hours}h)`; - // Only derive a consumed share when the API actually reports the plan total; - // a presumed window (e.g. 30 days) would fabricate utilization. A valid - // response that omits the total after a prior refresh had it is a DELIBERATE - // contract change — the old row must be dropped (terminal), not preserved as - // a transient last-good. - const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms); - if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE; - const consumed = Math.max(0, totalMs - remainsMs); - const percent = normalizePercent((consumed / totalMs) * 100); - if (percent === undefined) return null; + if (!body || asRecord(body.base_resp)?.status_code !== 0) return null; + const rows = Array.isArray(body.model_remains) ? body.model_remains : []; + const general = rows.map(asRecord).find(row => row?.model_name === "general"); + if (!general) return null; + const customWindows: NonNullable = []; + const fiveHourRemaining = toFiniteNumber(general.current_interval_remaining_percent); + if (fiveHourRemaining !== undefined) { + const percent = normalizePercent(100 - fiveHourRemaining); + if (percent !== undefined) { + const resetAt = normalizeResetAt(general.end_time); + customWindows.push({ label: "Coding Plan 5-hour", percent, ...(resetAt ? { resetAt } : {}) }); + } + } + if (general.current_weekly_status === 1) { + const weeklyRemaining = toFiniteNumber(general.current_weekly_remaining_percent); + if (weeklyRemaining !== undefined) { + const percent = normalizePercent(100 - weeklyRemaining); + if (percent !== undefined) { + const resetAt = normalizeResetAt(general.weekly_end_time); + customWindows.push({ label: "Coding Plan weekly", percent, ...(resetAt ? { resetAt } : {}) }); + } + } + } + if (customWindows.length === 0) return null; return report(provider, "minimax:token-plan-remains", { - customWindows: [{ label, percent }], + customWindows, updatedAt: Date.now(), }); } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 74cdcd198ca..a28c9554e8d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -34,6 +34,11 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest Anthropic model-scoped quota labels in `src/providers/quota/vendor-probes-oauth.ts` publish only canonical Fable, Opus, or Sonnet labels after removing terminal controls; unknown upstream display names are omitted. +MiniMax and MiniMax CN Coding Plan quota in `src/providers/quota/vendor-probes-key.ts` uses the +region-matched `/v1/api/openplatform/coding_plan/remains` endpoint. It publishes the `general` +model's consumed 5-hour percentage and, when active, weekly percentage with their reset times; +video quota rows are unrelated and omitted. + The routed identity sentence a catalog row carries is model-neutral on disk: `base_instructions`, and a native capability alias's `model_messages.instructions_template`, hold `NEUTRAL_IDENTITY_LINE` rather than a model id, because Codex stores a session's instruction block once and replays it diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7dd3d9e4a73..b5b03e2cac6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1001,6 +1001,7 @@ "mimo-token-plan-provider.test.ts": "providers", "mimo-v26-catalog.test.ts": "providers", "minimax-clients.test.ts": "providers", + "minimax-quota.test.ts": "providers", "minimax-reasoning-split.test.ts": "providers", "model-cache-generation-tombstone.test.ts": "codex-integration", "model-cache.test.ts": "codex-integration", diff --git a/tests/providers/minimax-quota.test.ts b/tests/providers/minimax-quota.test.ts new file mode 100644 index 00000000000..7bb8c7b947a --- /dev/null +++ b/tests/providers/minimax-quota.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../src/providers/quota"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; +import type { OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +const originalProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]).map(key => [key, process.env[key]])); + +function config(name: string, baseUrl: string): OcxConfig { + return { + defaultProvider: name, + providers: { [name]: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey: "test-secret" } }, + } as OcxConfig; +} + +function response(modelRemains: unknown, statusCode = 0): Response { + return Response.json({ base_resp: { status_code: statusCode }, model_remains: modelRemains }); +} + +beforeEach(() => { + for (const key of Object.keys(originalProxyEnv)) delete process.env[key]; + clearProviderQuotaCache(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + for (const [key, value] of Object.entries(originalProxyEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearProviderQuotaCache(); +}); + +describe("MiniMax Coding Plan quota", () => { + test("CN uses current endpoint and reports general 5-hour and weekly consumed percentages with resets", async () => { + let request: { url?: string; authorization?: string; redirect?: RequestRedirect } = {}; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + request = { + url: String(input), + authorization: new Headers(init?.headers).get("authorization") ?? undefined, + redirect: init?.redirect, + }; + return response([ + { model_name: "general", current_interval_remaining_percent: 62.5, end_time: 1_800_000_000, current_weekly_status: 1, current_weekly_remaining_percent: 25, weekly_end_time: 1_810_000_000 }, + { model_name: "video", current_interval_remaining_percent: 0, end_time: 1_800_000_000, current_weekly_status: 1, current_weekly_remaining_percent: 0, weekly_end_time: 1_810_000_000 }, + ]); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + + expect(request).toMatchObject({ + url: "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + authorization: "Bearer test-secret", + redirect: "error", + }); + expect(result.reports[0]?.quota.customWindows).toEqual([ + { label: "Coding Plan 5-hour", percent: 37.5, resetAt: 1_800_000_000_000 }, + { label: "Coding Plan weekly", percent: 75, resetAt: 1_810_000_000_000 }, + ]); + }); + + test("international provider uses the minimax.io host", async () => { + let url = ""; + globalThis.fetch = (async input => { + url = String(input); + return response([{ model_name: "general", current_interval_remaining_percent: 80, end_time: 1_800_000_000 }]); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax", "https://api.minimax.io/v1"), true); + + expect(url).toBe("https://api.minimax.io/v1/api/openplatform/coding_plan/remains"); + expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(20); + }); + + test("no-week plans expose only the 5-hour window", async () => { + globalThis.fetch = (async () => response([ + { model_name: "general", current_interval_remaining_percent: 44, end_time: 1_800_000_000, current_weekly_status: 0, current_weekly_remaining_percent: 0 }, + ])) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + + expect(result.reports[0]?.quota.customWindows).toEqual([ + { label: "Coding Plan 5-hour", percent: 56, resetAt: 1_800_000_000_000 }, + ]); + }); + + test.each([ + ["nonzero status", [{ model_name: "general", current_interval_remaining_percent: 20 }], 7], + ["missing general model", [{ model_name: "video", current_interval_remaining_percent: 20 }], 0], + ["invalid percentage", [{ model_name: "general", current_interval_remaining_percent: "invalid" }], 0], + ])("does not publish an empty window for %s", async (_label, rows, status) => { + globalThis.fetch = (async () => response(rows, status)) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + + expect(result.reports).toEqual([]); + }); + + test("bounds numeric percentages and ignores malformed JSON", async () => { + globalThis.fetch = (async () => response([ + { model_name: "general", current_interval_remaining_percent: -5 }, + ])) as typeof fetch; + const bounded = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + expect(bounded.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); + + clearProviderQuotaCache(); + globalThis.fetch = (async () => new Response("not json")) as typeof fetch; + const malformed = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + expect(malformed.reports).toEqual([]); + }); +}); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 228be2e729b..b1df29237a4 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -785,11 +785,11 @@ describe("fetchProviderQuotaReports", () => { }); test("routing quota scope keeps a key-bound display-only report out of model selection", async () => { - // MiniMax publishes its Token Plan countdown through the display-only path. The provider + // MiniMax publishes its Token Plan windows through the display-only path. The provider // is single-key `key` auth, so ownership alone would resolve a routing binding; without // an inference projection the exhausted row must still not rank or veto the target. globalThis.fetch = (async () => Response.json({ - success: true, data: { remains_time: 0, total_time: 1_000_000_000 }, + base_resp: { status_code: 0 }, model_remains: [{ model_name: "general", current_interval_remaining_percent: 0 }], })) as typeof fetch; const config = quotaCombo(keyQuotaConfig("minimax", "https://api.minimax.io/v1")); const reports = await fetchProviderQuotaReports(config, true); @@ -1671,61 +1671,6 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); }); - test("MiniMax quota drops the row when the API omits the plan total after having it", async () => { - // A valid row (with total) exists; a later valid response omitting the - // total is a DELIBERATE contract change — the stale row must be dropped - // (terminal), not preserved as a transient last-good. - let withTotal = true; - const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const headers = init?.headers as Record | undefined; - seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); - return new Response(JSON.stringify(withTotal - ? { success: true, data: { remains_time: 750_000_000, total_time: 1_000_000_000 } } - : { success: true, data: { remains_time: 1_000_000_000 } }), { status: 200 }); - }) as typeof fetch; - const config = keyQuotaConfig("minimax", "https://api.minimax.io/v1"); - - const valid = await fetchProviderQuotaReports(config, true); - withTotal = false; - const noTotal = await fetchProviderQuotaReports(config, true); - - expect(valid.reports).toHaveLength(1); - expect(noTotal.reports).toEqual([]); - expect(seen[0]?.url).toBe("https://www.minimax.io/v1/token_plan/remains"); - expect(seen[0]?.authorization).toBe("Bearer minimax-secret"); - expect(seen[0]?.redirect).toBe("error"); - }); - - test("MiniMax quota derives a consumed share when the API reports the plan total", async () => { - globalThis.fetch = (async () => new Response(JSON.stringify({ - success: true, - data: { remains_time: 750_000_000, total_time: 1_000_000_000 }, - }), { status: 200 })) as typeof fetch; - - const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax", "https://api.minimax.io/v1"), true); - - expect(result.reports).toHaveLength(1); - expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(25); - }); - - test("MiniMax CN quota probes the minimaxi.com host", async () => { - const seen: string[] = []; - globalThis.fetch = (async (input: RequestInfo | URL) => { - seen.push(String(input)); - return new Response(JSON.stringify({ - success: true, - data: { remains_time: 750_000_000, total_time: 1_000_000_000 }, - }), { status: 200 }); - }) as typeof fetch; - - const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax-cn", "https://api.minimaxi.com/v1"), true); - - expect(result.reports).toHaveLength(1); - expect(seen[0]).toBe("https://api.minimaxi.com/v1/token_plan/remains"); - }); - test("MiniMax quota never sends the key to a non-canonical base URL", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => {