From c6b4d25a2b2f84ab8f12364bb9ea4b2f3a21f1c6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 09:37:21 +0900 Subject: [PATCH] fix(quota): read Kimi Coding Plan usage from the regional kimi.ai origin (#8399) A key-auth provider on https://api.kimi.ai/coding/v1 now probes /usages on that same origin, so its quota is no longer unsupported. The probe URL is always the configured inference origin, matched exactly against Kimi's two origins; OAuth tokens stay on api.kimi.com. Closes #8332 (cherry picked from commit 6ce65a5a449157c7bdb1fe479de84bb186302438) --- .../src/content/docs/guides/providers.md | 4 +- src/providers/quota/vendor-probes-key.ts | 27 ++++++++-- structure/providers-and-adapters.md | 2 +- tests/providers/kimi-monthly-quota.test.ts | 52 +++++++++++++++++-- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 18dd72b84e5..0a47196a510 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1900,7 +1900,9 @@ Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, Command Code, and any a6api- or Abliteration custom provider. **Kimi Coding Plan quota.** Account login and API-key providers on the canonical -`https://api.kimi.com/coding/v1` endpoint read `/usages`. Plans that report +`https://api.kimi.com/coding/v1` endpoint read `/usages`. API-key providers on the regional +`https://api.kimi.ai/coding/v1` endpoint read `/usages` from that same origin; account login +stays on `api.kimi.com`. Plans that report `usages.limit_month_total` (or `monthTotal`) show a monthly utilization bar, with `limit_month_code` (or `monthCode`) as the fallback when total usage is unavailable. The `used_ratio` / `usedRatio` fraction must be in `[0, 1]` before conversion to a consumed diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index e34d581d787..9a258c0628d 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -24,7 +24,13 @@ async function quotaFetch(providerName: string, config: OcxProviderConfig, url: } const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; -const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; +/** + * Kimi's regional (kimi.ai) Coding API. Its `/usages` answers a Coding Plan API key the same way + * the kimi.com origin does (#8332). Only key auth may use it: the probe then calls the origin that + * already receives this key for inference, so it adds no credential destination. OAuth tokens + * stay on the kimi.com origin they were issued for. + */ +const KIMI_CODE_REGIONAL_BASE_URL = "https://api.kimi.ai/coding/v1"; const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai"; const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`; const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`; @@ -1119,6 +1125,18 @@ export function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean { return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL; } +/** + * The Kimi Coding origin whose `/usages` a quota probe may call with this provider's bearer, or + * null. It is always the configured inference origin, matched exactly against Kimi's own origins, + * so a user-edited or lookalike host never receives the credential. + */ +function kimiCodeQuotaBaseUrl(config: Pick): string | null { + const normalized = normalizedBaseUrl(config.baseUrl); + if (normalized === KIMI_CODE_BASE_URL) return normalized; + if (normalized === KIMI_CODE_REGIONAL_BASE_URL && (config.authMode ?? "key") === "key") return normalized; + return null; +} + export function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean { const normalized = normalizedBaseUrl(baseUrl); // OAuth preset points at the API root; the Provider-API preset at /provider/v1. @@ -1270,9 +1288,10 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: str export async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise { // Never release credentials to a user-edited or lookalike provider host. - if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null; + const baseUrl = kimiCodeQuotaBaseUrl(config); + if (!baseUrl) return null; if (!accessToken) return null; - const response = await quotaFetch(provider, config, KIMI_CODE_USAGE_URL, { + const response = await quotaFetch(provider, config, `${baseUrl}/usages`, { headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -1435,7 +1454,7 @@ type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise { const bearer = await resolveKimiQuotaBearer(config); return bearer ? fetchKimiQuota(id, config, bearer) : null; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 2f2576e4cca..85fc49eaf63 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -91,7 +91,7 @@ Kimi Coding's Chat, API-key, and optional Responses presets consume the same mod The Responses preset shares the `kimi` OAuth account and Coding endpoint, keeps Chat as the featured default, and enables adjacent tool-result repair on its Responses wire. Its metadata alias is generated from the registry; sharing authentication does not implicitly share a usage-price namespace. The separate `moonshot` API preset defaults to `kimi-k3.1-pro`; `src/providers/model-rename-migration.ts` moves discontinued K2.5 selections on official destinations to it, drops stale no-reasoning classification, and refreshes exact old 256K/empty reasoning seeds to 1M and `low`/`high`/`max` with the API `none` omission map. Explicit successor metadata and Custom destinations keep precedence. Roster repair accepts the literal pre-K3.1 seed and its post-rename form, with metadata additions measured against the original seed. K2.6 and K2.7 Code/highspeed stay seeded for existing API users. -Kimi quota in `src/providers/quota/vendor-probes-key.ts` also reads monthly `usages` at the root, under `data`, or under `quota` (including `data.quota`): `limit_month_total` / `monthTotal` precedes the `limit_month_code` / `monthCode` fallback. Containers are tried in root, `data`, `quota`, then `data.quota` order until a monthly row parses; empty or unusable earlier containers do not mask later data, and monthly lookup retains the original envelope even when legacy windows unwrap `data`. Rows prefer limit/used or remaining arithmetic, then fractional `used_ratio` / `usedRatio` in `[0, 1]`, then direct percentages; out-of-range ratios fall through and reset times remain optional. Missing monthly data stays absent, and legacy 5-hour, weekly and total subscription-credit windows stay separate. The parser is shared by account-login and API-key probes; credential and destination guards are unchanged. +Kimi quota in `src/providers/quota/vendor-probes-key.ts` also reads monthly `usages` at the root, under `data`, or under `quota` (including `data.quota`): `limit_month_total` / `monthTotal` precedes the `limit_month_code` / `monthCode` fallback. Containers are tried in root, `data`, `quota`, then `data.quota` order until a monthly row parses; empty or unusable earlier containers do not mask later data, and monthly lookup retains the original envelope even when legacy windows unwrap `data`. Rows prefer limit/used or remaining arithmetic, then fractional `used_ratio` / `usedRatio` in `[0, 1]`, then direct percentages; out-of-range ratios fall through and reset times remain optional. Missing monthly data stays absent, and legacy 5-hour, weekly and total subscription-credit windows stay separate. The probe calls `/usages` on the configured inference origin only when it is exactly `https://api.kimi.com/coding/v1`, or `https://api.kimi.ai/coding/v1` for key auth (#8332), so the bearer never reaches a host it was not already sent to; account-login tokens stay on `api.kimi.com`. The parser is shared by account-login and API-key probes; credential and destination guards are unchanged. OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its 64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow diff --git a/tests/providers/kimi-monthly-quota.test.ts b/tests/providers/kimi-monthly-quota.test.ts index e48d75f6c4a..2c6f109beaf 100644 --- a/tests/providers/kimi-monthly-quota.test.ts +++ b/tests/providers/kimi-monthly-quota.test.ts @@ -35,7 +35,7 @@ afterEach(() => { removeTreeWithRetry(home); }); -function config(authMode: "key" | "oauth" = "key"): OcxConfig { +function config(authMode: "key" | "oauth" = "key", baseUrl = "https://api.kimi.com/coding/v1"): OcxConfig { const name = authMode === "oauth" ? "kimi" : "kimi-code"; return { defaultProvider: name, @@ -43,16 +43,16 @@ function config(authMode: "key" | "oauth" = "key"): OcxConfig { [name]: { adapter: "openai-chat", authMode, - baseUrl: "https://api.kimi.com/coding/v1", + baseUrl, ...(authMode === "key" ? { apiKey: "kimi-monthly-fixture-key" } : {}), }, }, } as OcxConfig; } -function mockUsage(payload: unknown): void { +function mockUsage(payload: unknown, url = "https://api.kimi.com/coding/v1/usages"): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - expect(String(input)).toBe("https://api.kimi.com/coding/v1/usages"); + expect(String(input)).toBe(url); expect(init?.redirect).toBe("error"); return Response.json(payload); }) as typeof fetch; @@ -316,3 +316,47 @@ describe("Kimi monthly quota", () => { expect(reports[0]?.quota.customWindows).toEqual([{ label: "Total subscription credits", percent: 60 }]); }); }); + +describe("Kimi regional (kimi.ai) Coding Plan quota (#8332)", () => { + const regional = "https://api.kimi.ai/coding/v1"; + + test("a key-auth provider on api.kimi.ai probes its own origin's /usages", async () => { + // Field shape from public API-key reports against this endpoint: legacy counters beside + // ratio windows that can contradict them. Valid counters win over a zero ratio. + mockUsage({ + usage: { limit: 100, used: 40, remaining: 60, resetTime: resetAt }, + limits: [{ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, detail: { limit: 100, used: 100, remaining: 0, resetTime: resetAt } }], + usages: { limit_5h: { used_ratio: 0 }, limit_7d: { used_ratio: 0 }, limit_month_total: { used_ratio: 0.1, reset_time: resetAt } }, + }, `${regional}/usages`); + + const { reports } = await fetchProviderQuotaReports(config("key", `${regional}/`), true); + + expect(reports).toHaveLength(1); + expect(reports[0]?.source).toBe("kimi:usages"); + expect(reports[0]?.quota).toMatchObject({ fiveHourPercent: 100, weeklyPercent: 40, monthlyPercent: 10 }); + }); + + test.each([ + { name: "an OAuth token on api.kimi.ai", authMode: "oauth" as const, baseUrl: regional }, + { name: "a lookalike host", authMode: "key" as const, baseUrl: "https://api.kimi.ai.example.test/coding/v1" }, + { name: "another path on api.kimi.ai", authMode: "key" as const, baseUrl: "https://api.kimi.ai/v1" }, + ])("$name never receives the credential", async ({ authMode, baseUrl }) => { + if (authMode === "oauth") { + await saveCredential("kimi", { + access: "kimi-regional-fixture-access", + refresh: "kimi-regional-fixture-refresh", + expires: Date.now() + 3600_000, + }); + } + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + requested.push(String(input)); + return Response.json({ usages: { limit_month_total: monthlyRow } }); + }) as typeof fetch; + + const { reports } = await fetchProviderQuotaReports(config(authMode, baseUrl), true); + + expect(requested.filter(url => url.includes("/usages"))).toEqual([]); + expect(reports.some(report => report.source === "kimi:usages")).toBe(false); + }); +});