feat(usage): support quota fetch for kimi-coding-apikey (#4435)

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.
This commit is contained in:
janeza2
2026-06-21 18:45:56 +07:00
committed by GitHub
parent 66909a8b90
commit c3fd2b9aa1
4 changed files with 103 additions and 6 deletions

View File

@@ -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<string, string> = 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",
},
});

View File

@@ -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";

View File

@@ -3212,6 +3212,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
"claude",
"cursor",
"kimi-coding",
"kimi-coding-apikey",
"glm",
"glm-cn",
"zai",

View File

@@ -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<string, string> }> = [];
before(() => {
globalThis.fetch = (async (url: unknown, init: { headers?: Record<string, string> } = {}) => {
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<typeof getUsageForProvider>[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<typeof getUsageForProvider>[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)"
);
});