From c3fd2b9aa1f220c6a32eaecae8bea5dc49ba89b5 Mon Sep 17 00:00:00 2001 From: janeza2 <49841619+janeza2@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:45:56 +0700 Subject: [PATCH] feat(usage): support quota fetch for kimi-coding-apikey (#4435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks @janeza2! Rebased onto release/v3.8.32 and completed the end-to-end wiring on review: kimi-coding-apikey was missing from USAGE_SUPPORTED_PROVIDERS (dashboard 400) and USAGE_FETCHER_PROVIDERS (preflight) — added both + header-selection/wiring tests. Now fully routable. --- open-sse/services/usage.ts | 27 +++++-- src/lib/usage/providerLimits.ts | 1 + src/shared/constants/providers.ts | 1 + .../kimi-coding-apikey-quota-4435.test.ts | 80 +++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 tests/unit/kimi-coding-apikey-quota-4435.test.ts diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index eddc502ee3..4e8c338c24 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1511,6 +1511,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "kiro", "amazon-q", "kimi-coding", + "kimi-coding-apikey", "qwen", "qoder", "glm", @@ -1573,6 +1574,8 @@ export async function getUsageForProvider( return await getVertexUsage(id || "", provider); case "kimi-coding": return await getKimiUsage(accessToken); + case "kimi-coding-apikey": + return await getKimiUsage(undefined, apiKey); case "qwen": return await getQwenUsage(accessToken, providerSpecificData); case "qoder": @@ -3186,7 +3189,7 @@ function getKimiPlanName(level: unknown): string { * Kimi Coding Usage - Fetch quota from Kimi API * Uses the official /v1/usages endpoint with custom X-Msh-* headers */ -async function getKimiUsage(accessToken?: string) { +async function getKimiUsage(accessToken?: string, apiKey?: string) { // Generate device info for headers (same as OAuth flow) const deviceId = "kimi-usage-" + Date.now(); const platform = "omniroute"; @@ -3194,16 +3197,28 @@ async function getKimiUsage(accessToken?: string) { const deviceModel = typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown"; - try { - const response = await fetch(KIMI_CONFIG.usageUrl, { - method: "GET", - headers: { + // API key auth takes precedence — Kimi's /usages endpoint accepts the same + // API key used for /messages (verified live: responds with + // authentication.method = METHOD_API_KEY). OAuth flow falls through to the + // Bearer + device-headers shape used by Kimi Coding OAuth. + const useApiKey = typeof apiKey === "string" && apiKey.length > 0; + + const authHeaders: Record = useApiKey + ? { "x-api-key": apiKey as string } + : { Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", "X-Msh-Platform": platform, "X-Msh-Version": version, "X-Msh-Device-Model": deviceModel, "X-Msh-Device-Id": deviceId, + }; + + try { + const response = await fetch(KIMI_CONFIG.usageUrl, { + method: "GET", + headers: { + ...authHeaders, + "Content-Type": "application/json", }, }); diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 49ffb333b5..80db7e6846 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -72,6 +72,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "xiaomi-mimo", "vertex", "vertex-partner", + "kimi-coding-apikey", ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 7d484f4e4b..aa41aee014 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -3212,6 +3212,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "claude", "cursor", "kimi-coding", + "kimi-coding-apikey", "glm", "glm-cn", "zai", diff --git a/tests/unit/kimi-coding-apikey-quota-4435.test.ts b/tests/unit/kimi-coding-apikey-quota-4435.test.ts new file mode 100644 index 0000000000..6b577cf9c3 --- /dev/null +++ b/tests/unit/kimi-coding-apikey-quota-4435.test.ts @@ -0,0 +1,80 @@ +/** + * Regression for #4435 (port: quota fetch for kimi-coding-apikey). + * + * The new `kimi-coding-apikey` connection authenticates against Kimi's + * /usages endpoint with an `x-api-key` header (the same key used for + * /messages), while the OAuth `kimi-coding` connection keeps the + * Bearer + X-Msh-* device-header shape. These tests pin that header + * selection by routing through the exported `getUsageForProvider` switch + * with a stubbed global fetch (no DB side effects on the kimi path). + * + * Also guards the wiring gap caught in review: `kimi-coding-apikey` must be + * registered in USAGE_SUPPORTED_PROVIDERS (dashboard gate) and + * USAGE_FETCHER_PROVIDERS (auto-routing preflight), otherwise the feature + * is unreachable end-to-end. + */ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; + +import { getUsageForProvider, USAGE_FETCHER_PROVIDERS } from "../../open-sse/services/usage.ts"; +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; + +const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; +const realFetch = globalThis.fetch; +let captured: Array<{ url: string; headers: Record }> = []; + +before(() => { + globalThis.fetch = (async (url: unknown, init: { headers?: Record } = {}) => { + captured.push({ url: String(url), headers: { ...(init.headers ?? {}) } }); + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ usage: { limit: "100", used: "10", remaining: "90" } }), + } as unknown as Response; + }) as typeof fetch; +}); + +after(() => { + globalThis.fetch = realFetch; +}); + +test("kimi-coding-apikey authenticates with x-api-key (not Bearer)", async () => { + captured = []; + await getUsageForProvider({ + id: "c-apikey", + provider: "kimi-coding-apikey", + apiKey: "sk-test-123", + } as Parameters[0]); + + const call = captured.find((c) => c.url === KIMI_USAGE_URL); + assert.ok(call, "kimi /usages endpoint should be called for kimi-coding-apikey"); + assert.equal(call.headers["x-api-key"], "sk-test-123"); + assert.equal(call.headers["Authorization"], undefined); +}); + +test("kimi-coding (OAuth) keeps Bearer + device headers (not x-api-key)", async () => { + captured = []; + await getUsageForProvider({ + id: "c-oauth", + provider: "kimi-coding", + accessToken: "tok-abc", + } as Parameters[0]); + + const call = captured.find((c) => c.url === KIMI_USAGE_URL); + assert.ok(call, "kimi /usages endpoint should be called for kimi-coding"); + assert.equal(call.headers["Authorization"], "Bearer tok-abc"); + assert.ok(call.headers["X-Msh-Platform"], "OAuth path keeps the device headers"); + assert.equal(call.headers["x-api-key"], undefined); +}); + +test("kimi-coding-apikey is wired into both usage source-of-truth lists", () => { + assert.ok( + USAGE_SUPPORTED_PROVIDERS.includes("kimi-coding-apikey"), + "must be in USAGE_SUPPORTED_PROVIDERS (else the dashboard gate returns 400)" + ); + assert.ok( + (USAGE_FETCHER_PROVIDERS as readonly string[]).includes("kimi-coding-apikey"), + "must be in USAGE_FETCHER_PROVIDERS (else auto-routing preflight never registers a fetcher)" + ); +});