Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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/) 选择开启。

Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 31 additions & 26 deletions src/providers/quota/vendor-probes-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ProviderQuotaProbeResult> {
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",
Expand All @@ -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<ProviderQuota["customWindows"]> = [];
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(),
});
}
Expand Down
5 changes: 5 additions & 0 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 110 additions & 0 deletions tests/providers/minimax-quota.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
59 changes: 2 additions & 57 deletions tests/providers/provider-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, string> | 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) => {
Expand Down
Loading