Skip to content
Merged
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
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions src/providers/quota/vendor-probes-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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<OcxProviderConfig, "baseUrl" | "authMode">): 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.
Expand Down Expand Up @@ -1270,9 +1288,10 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: str

export async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise<ProviderQuotaReport | null> {
// 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),
Expand Down Expand Up @@ -1435,7 +1454,7 @@ type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise<Pro
/** Same selector drives cheap capabilities and uncached reads; never resolves credentials. */
export function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): KeyQuotaReader | null {
if (provider.disabled === true || (provider.authMode ?? "key") !== "key") return null;
if (isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
if (kimiCodeQuotaBaseUrl(provider)) {
return async (id, config) => {
const bearer = await resolveKimiQuotaBearer(config);
return bearer ? fetchKimiQuota(id, config, bearer) : null;
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 48 additions & 4 deletions tests/providers/kimi-monthly-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,24 @@ 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,
providers: {
[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;
Expand Down Expand Up @@ -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);
});
});
Loading