From 41ffb08e4a4a4f14e8178641c7874712789a038d Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 20 Aug 2026 00:50:35 -0300 Subject: [PATCH 01/30] chore(quality): rebaseline file-size for merge-train batch1 (#10722, #10797) + pre-existing chatBodyAdmission.ts drift Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- config/quality/file-size-baseline.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 05323c8002..96001706ef 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -442,12 +442,14 @@ "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, "src/shared/components/ModelSelectModal.tsx": 1138, - "src/shared/constants/providers/apikey/gateways.ts": 1255, + "src/shared/constants/providers/apikey/gateways.ts": 1268, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1006, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, - "open-sse/config/imageRegistry.ts": 1019 + "open-sse/config/imageRegistry.ts": 1019, + "src/sse/handlers/chatHelpers.ts": 1017, + "src/shared/middleware/chatBodyAdmission.ts": 1005 }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", @@ -612,5 +614,6 @@ "_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).", "_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.", "_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule).", - "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests)." -} + "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests).", + "_rebaseline_2026_08_20_v3850_merge_train_batch1": "Merge-train batch1 (2026-08-19/20, 30 PRs boarded onto release/v3.8.50): gateways.ts 1255->1268 = PR #10722 (Token Kiosk OpenAI-compatible provider gateway catalog entry, +13 declarative lines, same god-file no-split rationale as prior gateways.ts rebaselines); chatHelpers.ts (uncapped, not previously frozen) new 1017 = PR #10797 (relay/bifrost error normalization, +23/-2, own-PR growth, existing file already near cap from accumulated chokepoint wiring per its own rebaseline history above); chatBodyAdmission.ts (uncapped) new 1005 = pre-existing base-red on the pure release tip (1004>1000 before this train boarded anything, no PR in this batch touches this file) — frozen here at its current size, not authorizing further growth. Owner-authorized rebaseline (2026-08-19 merge-prs session)." +} \ No newline at end of file From 81b0ff46a3b4167bf64582a87e4be70ee4543c39 Mon Sep 17 00:00:00 2001 From: Ara Date: Thu, 20 Aug 2026 02:28:29 -0700 Subject: [PATCH 02/30] fix(cline): label internal health checks (#10706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution! --- src/shared/utils/clineAuth.ts | 21 +++++++++++++++++-- .../cline-workos-auth-token-shape.test.ts | 13 ++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/shared/utils/clineAuth.ts b/src/shared/utils/clineAuth.ts index c8bab6214c..b923332fa0 100644 --- a/src/shared/utils/clineAuth.ts +++ b/src/shared/utils/clineAuth.ts @@ -13,6 +13,8 @@ import { randomUUID } from "node:crypto"; import { APP_CONFIG } from "../constants/appConfig"; const APP_VERSION = APP_CONFIG.version; +const DEFAULT_CLINE_CLIENT_TYPE = "omniroute"; +const INTERNAL_HEALTH_CHECK_CLIENT_TYPE = "omniroute-internal-health-check"; export interface ClineHeaderContext { taskId?: string; @@ -44,6 +46,12 @@ export function resolveClineTaskId(clientHeaders?: Record | null return getHeaderCaseInsensitive(clientHeaders, "x-task-id") ?? randomUUID(); } +function resolveClineClientType(clientHeaders?: Record | null): string | undefined { + return getHeaderCaseInsensitive(clientHeaders, "x-internal-test") === "combo-health-check" + ? INTERNAL_HEALTH_CHECK_CLIENT_TYPE + : undefined; +} + /** * Apply the required Cline billing headers with case-insensitive replacement. * These fields are authoritative in the official client and must win over @@ -58,12 +66,18 @@ export function applyClineProtocolHeaders( getHeaderCaseInsensitive(headers, "x-task-id") ?? randomUUID(); const clientVersion = cleanHeaderValue(context.clientVersion) ?? APP_VERSION; + const existingClientType = getHeaderCaseInsensitive(headers, "x-client-type"); + const clientType = + cleanHeaderValue(context.clientType) ?? + (existingClientType === INTERNAL_HEALTH_CHECK_CLIENT_TYPE + ? INTERNAL_HEALTH_CHECK_CLIENT_TYPE + : DEFAULT_CLINE_CLIENT_TYPE); const required: Record = { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", "User-Agent": `Cline/${clientVersion}`, "X-IS-MULTIROOT": context.isMultiRoot === true ? "true" : "false", - "X-CLIENT-TYPE": cleanHeaderValue(context.clientType) ?? "omniroute", + "X-CLIENT-TYPE": clientType, "X-CLIENT-VERSION": clientVersion, "X-PLATFORM": cleanHeaderValue(context.platform) ?? process.platform ?? "unknown", "X-PLATFORM-VERSION": cleanHeaderValue(context.platformVersion) ?? process.version ?? "unknown", @@ -159,7 +173,10 @@ export function applyClineAuthHeaders( clientHeaders: Record | null | undefined, isClinepass: boolean ): Record { - const context: ClineHeaderContext = { taskId: resolveClineTaskId(clientHeaders) }; + const context: ClineHeaderContext = { + taskId: resolveClineTaskId(clientHeaders), + clientType: resolveClineClientType(clientHeaders), + }; const built = isClinepass ? buildClinepassHeaders(credentials, effectiveKey, context) : buildClineHeaders(effectiveKey || credentials?.accessToken, {}, context); diff --git a/tests/unit/cline-workos-auth-token-shape.test.ts b/tests/unit/cline-workos-auth-token-shape.test.ts index 94c73148cd..ab637ac930 100644 --- a/tests/unit/cline-workos-auth-token-shape.test.ts +++ b/tests/unit/cline-workos-auth-token-shape.test.ts @@ -110,3 +110,16 @@ test("DefaultExecutor.buildHeaders uses the cline workos auth token shape", () = assert.equal(headers["X-Title"], "Cline"); assert.equal(headers["X-Task-ID"], "task-from-client"); }); + +test("DefaultExecutor labels internal health checks separately from user traffic", () => { + const executor = new DefaultExecutor("cline"); + const headers = executor.buildHeaders({ apiKey: "tok-abc" }, true, { + "X-Internal-Test": "combo-health-check", + }); + + assert.equal(headers["X-CLIENT-TYPE"], "omniroute-internal-health-check"); + + // BaseExecutor reapplies the required protocol headers immediately before dispatch. + applyClineProtocolHeaders(headers, { taskId: headers["X-Task-ID"] }); + assert.equal(headers["X-CLIENT-TYPE"], "omniroute-internal-health-check"); +}); From 1accabeb4ea1aaf52ec708fbc3228b375840069f Mon Sep 17 00:00:00 2001 From: Webman Date: Thu, 20 Aug 2026 04:28:33 -0500 Subject: [PATCH 03/30] fix(db): prevent Windows native-driver hang from stalling requests (#10627) (#10709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution! --- src/lib/db/adapters/driverFactory.ts | 79 ++++++++++++- src/proxy.ts | 19 +++ tests/unit/db-adapters/driverFactory.test.ts | 118 +++++++++++++++++++ 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index d696d1388d..134e984c28 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -1,5 +1,6 @@ import { runtimeRequire as _require } from "./runtimeRequire"; import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { createBetterSqliteAdapter } from "./betterSqliteAdapter"; import { createBunSqliteAdapter, type BunSqliteDatabaseLike } from "./bunSqliteAdapter"; import { @@ -11,6 +12,70 @@ import type { SqliteAdapter } from "./types"; type DriverLoader = (moduleName: string) => unknown; +type SpawnSyncLike = ( + command: string, + args: string[], + options: { timeout: number; stdio: "ignore"; cwd: string; windowsHide: boolean } +) => { status: number | null }; + +/** Returns whether better-sqlite3 may be loaded in this process. */ +export type DriverProbe = () => boolean; + +/** + * #10627 — Windows driver-hang guard. + * + * The sync cascade's try/catch only covers drivers that THROW on load + * (ERR_DLOPEN_FAILED, "Module did not self-register", ...). On Windows, a + * mismatched-ABI native addon can HANG inside DllMain (loader lock) instead of + * throwing — a hang never reaches the catch, so the fallback to node:sqlite / + * sql.js never runs and the first DB touch in a runtime stalls forever at ~0% + * CPU (the exact #10627 symptom: every request hangs, 0 bytes, no logs). + * + * The probe answers "can better-sqlite3 load AND open a database?" by loading + * it in a CHILD PROCESS with a bounded timeout, so a hang becomes a timed-out + * probe (verdict "bad") instead of a process-level deadlock. The verdict is + * cached per process — the child spawn happens at most once. + * + * On POSIX this is a no-op returning true: broken addons throw there, which + * the existing cascade already handles, and we don't want to pay a subprocess + * spawn on every Linux/CI boot. + */ +export function createBetterSqliteProbe(options: { + platform?: string; + execPath?: string; + spawn?: SpawnSyncLike; + timeoutMs?: number; +}): DriverProbe { + const { + platform = process.platform, + execPath = process.execPath, + spawn = spawnSync as unknown as SpawnSyncLike, + timeoutMs = 5_000, + } = options; + + let verdict: boolean | null = null; + return () => { + if (verdict !== null) return verdict; + if (platform !== "win32") { + verdict = true; + return verdict; + } + try { + const result = spawn(execPath, ["-e", "require('better-sqlite3')(':memory:')"], { + timeout: timeoutMs, + stdio: "ignore", + cwd: process.cwd(), + windowsHide: true, + }); + // status === null means the child was killed by the timeout — a hang. + verdict = result.status === 0; + } catch { + verdict = false; + } + return verdict; + }; +} + /** * The production loader for the sync driver cascade. * @@ -137,7 +202,12 @@ function getSqlJsPendingCache(): Map> { * Builds the synchronous driver cascade. Keeping the loader injectable makes * the real node:sqlite branch testable without changing the public adapter API. */ -export function createSyncDriverFactory(load: DriverLoader) { +export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?: DriverProbe) { + // #10627: when a probe is supplied, the better-sqlite3 branch is gated on it + // so a Windows DllMain hang (which never throws, so never hits the catch) + // cannot stall the request path. Default: no probe — existing callers/tests + // keep the historical throw-only behavior. + const mayLoadBetterSqlite = betterSqliteProbe ?? (() => true); return function tryOpenSync( filePath: string, options?: Record @@ -164,7 +234,7 @@ export function createSyncDriverFactory(load: DriverLoader) { } // better-sqlite3: rápido, nativo — skip em Bun - if (!process.versions.bun) { + if (!process.versions.bun && mayLoadBetterSqlite()) { try { const BetterSqlite = load("better-sqlite3") as { new (p: string, o?: object): import("better-sqlite3").Database; @@ -204,7 +274,10 @@ export function createSyncDriverFactory(load: DriverLoader) { }; } -const openSyncDriver = createSyncDriverFactory(requireSqliteDriver); +// Production wiring: the real probe (child-process, timed, cached) guards the +// better-sqlite3 branch so a hang on Windows degrades to a failover instead of +// a request-path deadlock (#10627). +const openSyncDriver = createSyncDriverFactory(requireSqliteDriver, createBetterSqliteProbe({})); /** * The installed-tarball smoke uses this paired marker to exercise the sql.js tier diff --git a/src/proxy.ts b/src/proxy.ts index 93de5e149f..153785efdf 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,25 @@ import type { NextRequest } from "next/server"; import { runAuthzPipeline } from "./server/authz/pipeline"; +// #10627: the proxy runs in its own Next.js runtime and never executes +// instrumentation-node.ts's startup warm-ups, so its FIRST request used to +// trigger a cold `import("@/lib/db/settings")` → native SQLite driver load ON +// the request path. If that addon hangs (see driverFactory's #10627 probe), +// every proxied request stalled indefinitely with 0 bytes and no logs. +// Warm the settings cache here at boot instead: a driver failure now surfaces +// as a logged startup error, and real requests start with a hot cache. +// Fire-and-forget — never blocks proxy initialization, never rejects the +// module (mirrors the `void warmModelCatalogCache()` pattern in +// instrumentation-node.ts). +void import("./lib/db/readCache") + .then(({ getCachedSettings }) => getCachedSettings()) + .catch((err: unknown) => { + console.error( + "[proxy] DB settings warm failed; requests will use default limits:", + err instanceof Error ? err.message : err + ); + }); + export async function proxy(request: NextRequest) { return runAuthzPipeline(request, { enforce: true }); } diff --git a/tests/unit/db-adapters/driverFactory.test.ts b/tests/unit/db-adapters/driverFactory.test.ts index f5fc774e3f..b73ea87a6f 100644 --- a/tests/unit/db-adapters/driverFactory.test.ts +++ b/tests/unit/db-adapters/driverFactory.test.ts @@ -10,6 +10,7 @@ import { runtimeRequire } from "../../../src/lib/db/adapters/runtimeRequire.ts"; const { createSyncDriverFactory, + createBetterSqliteProbe, isPackBootForcedSqlJsSmoke, tryOpenSync, openDatabaseAsync, @@ -81,6 +82,55 @@ describe("driverFactory", () => { } ); + test("rejected probe skips better-sqlite3 and falls through to node:sqlite", (t) => { + const databasePath = createTempDatabasePath(t); + const openWithoutBrokenAddon = createSyncDriverFactory( + (moduleName: string) => { + if (moduleName === "better-sqlite3") { + throw new Error("better-sqlite3 must not load when the probe rejects it"); + } + return require(moduleName); + }, + () => false + ); + + const adapter = openWithoutBrokenAddon(databasePath); + assert.ok(adapter); + assert.equal(adapter.driver, "node:sqlite"); + adapter.exec("CREATE TABLE items (value TEXT)"); + adapter.prepare("INSERT INTO items VALUES (?)").run("ok"); + assert.equal( + (adapter.prepare("SELECT value FROM items").get() as { value: string }).value, + "ok" + ); + adapter.close(); + }); + + test("passed probe still prefers better-sqlite3 in the cascade", () => { + let betterSqliteRequested = false; + const openWithPassedProbe = createSyncDriverFactory( + (moduleName: string) => { + if (moduleName === "better-sqlite3") { + betterSqliteRequested = true; + return function FakeBetterSqlite() { + return { close() {}, name: ":memory:", open: true }; + }; + } + if (moduleName === "node:sqlite") { + throw new Error("node:sqlite must not load when better-sqlite3 passes the probe"); + } + throw new Error(`unexpected driver load: ${moduleName}`); + }, + () => true + ); + + const adapter = openWithPassedProbe(":memory:"); + assert.ok(adapter); + assert.equal(adapter.driver, "better-sqlite3"); + assert.equal(betterSqliteRequested, true); + adapter.close(); + }); + test("prefers better-sqlite3 before node:sqlite in the driver cascade", () => { const fakeBetterSqlite = { close() {}, @@ -337,6 +387,74 @@ describe("driverFactory", () => { }); } + // #10627 — the Windows driver-hang guard. On Windows a mismatched-ABI + // better-sqlite3 addon can HANG inside DllMain instead of throwing, so the + // cascade's try/catch never fires and the fallback never runs. The probe + // loads the addon in a child process with a bounded timeout, turning a hang + // into a cached "bad" verdict that skips the branch. + test("probe: non-Windows platforms skip the child probe and report ok", () => { + let spawned = 0; + const probe = createBetterSqliteProbe({ + platform: "linux", + execPath: "node", + spawn: () => { + spawned += 1; + return { status: 0 }; + }, + }); + assert.equal(probe(), true); + assert.equal(probe(), true); + assert.equal(spawned, 0, "POSIX must not spawn a probe child process"); + }); + + test("probe: successful child probe is cached (spawned at most once)", () => { + let spawned = 0; + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => { + spawned += 1; + return { status: 0 }; + }, + }); + assert.equal(probe(), true); + assert.equal(probe(), true); + assert.equal(probe(), true); + assert.equal(spawned, 1, "verdict must be cached per process"); + }); + + test("probe: non-zero child exit rejects better-sqlite3", () => { + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => ({ status: 1 }), + }); + assert.equal(probe(), false); + assert.equal(probe(), false); + }); + + test("probe: child spawn throw rejects better-sqlite3", () => { + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => { + throw new Error("spawn failed"); + }, + }); + assert.equal(probe(), false); + }); + + test("probe: timed-out child (status null) rejects better-sqlite3 — the #10627 hang case", () => { + // status === null is exactly what spawnSync returns when the child is + // killed by the timeout — i.e. the DllMain hang that never throws. + const probe = createBetterSqliteProbe({ + platform: "win32", + execPath: "node", + spawn: () => ({ status: null }), + }); + assert.equal(probe(), false); + }); + test("retains the existing cascade when native drivers are unavailable", () => { const openWithoutNativeDrivers = createSyncDriverFactory(() => { throw new Error("forced driver load failure"); From 82e5afed6b528acfda29841c01b8e05ee593591e Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:28:37 +0800 Subject: [PATCH 04/30] feat(usage): show Kimi Coding Extra Usage (#10712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution! --- .../features/kimi-coding-extra-usage.md | 1 + open-sse/services/usage/kimi.ts | 211 +++++++-- .../components/ProviderLimits/QuotaCard.tsx | 13 +- .../parts/QuotaCardExpanded.tsx | 34 +- .../components/ProviderLimits/quotaParsing.ts | 21 +- .../usage/components/ProviderLimits/utils.tsx | 4 +- src/i18n/messages/en.json | 10 + src/i18n/messages/pt-BR.json | 10 + src/i18n/messages/vi.json | 10 + src/i18n/messages/zh-CN.json | 10 + src/i18n/messages/zh-TW.json | 10 + src/lib/db/providerLimits.ts | 11 +- src/lib/usage/providerLimitsCache.ts | 18 +- src/shared/utils/kimiBilling.ts | 199 +++++++++ src/shared/utils/providerBilling.ts | 36 ++ tests/unit/kimi-coding-billing-ui.test.ts | 159 +++++++ tests/unit/kimi-coding-billing.test.ts | 419 ++++++++++++++++++ ...ota-card-expanded-fixed-order-6687.test.ts | 53 +++ .../repro-7764-collapsed-quota-order.test.ts | 18 + tests/unit/usage-service-hardening.test.ts | 5 +- 20 files changed, 1181 insertions(+), 71 deletions(-) create mode 100644 changelog.d/features/kimi-coding-extra-usage.md create mode 100644 src/shared/utils/kimiBilling.ts create mode 100644 src/shared/utils/providerBilling.ts create mode 100644 tests/unit/kimi-coding-billing-ui.test.ts create mode 100644 tests/unit/kimi-coding-billing.test.ts diff --git a/changelog.d/features/kimi-coding-extra-usage.md b/changelog.d/features/kimi-coding-extra-usage.md new file mode 100644 index 0000000000..766ec1020c --- /dev/null +++ b/changelog.d/features/kimi-coding-extra-usage.md @@ -0,0 +1 @@ +- **feat(usage):** show Kimi Coding's fixed-order Code 5-hour/7-day quota windows plus Extra Usage status, balance, monthly spend/limit, and the official Additional Credits link on Dashboard → Quota cards. diff --git a/open-sse/services/usage/kimi.ts b/open-sse/services/usage/kimi.ts index ca9f2d5630..d27c3c889b 100644 --- a/open-sse/services/usage/kimi.ts +++ b/open-sse/services/usage/kimi.ts @@ -9,12 +9,16 @@ */ import { safePercentage } from "@/shared/utils/formatting"; +import { + KIMI_CODE_ADDITIONAL_CREDITS_URL, + type KimiBillingStatus, +} from "@/shared/utils/kimiBilling"; import { buildKimiCodeIdentityHeaders, getKimiCodeCliUserAgent, } from "../../config/providers/registry/kimi/coding/runtime.ts"; import { toRecord, toNumber } from "./scalars.ts"; -import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { createQuotaFromUsage, type UsageQuota, parseResetTime } from "./quota.ts"; type JsonRecord = Record; @@ -25,6 +29,145 @@ const KIMI_CONFIG = { apiVersion: "2023-06-01", }; +const KIMI_BOOSTER_FIXED_POINT_PER_CENT = 1_000_000; + +function toInteger(value: unknown): number | null { + const parsed = toNumber(value, Number.NaN); + return Number.isFinite(parsed) ? Math.trunc(parsed) : null; +} + +function fixedPointToCents(value: number): number { + const cents = value / KIMI_BOOSTER_FIXED_POINT_PER_CENT; + if (cents > 0 && cents < 1) return 1; + return Math.round(cents); +} + +function parseKimiMoney(value: unknown): { cents: number; currency: string } | null { + const money = toRecord(value); + const cents = toInteger(money.priceInCents); + const currency = money.currency; + if ( + cents === null || + cents < 0 || + typeof currency !== "string" || + !/^[A-Za-z]{3}$/.test(currency) + ) { + return null; + } + return { cents, currency: currency.toUpperCase() }; +} + +function parseKimiExtraUsageStatus(value: unknown): KimiBillingStatus["extraUsageStatus"] { + switch (value) { + case "STATUS_ACTIVE": + return "enabled"; + case "STATUS_DISABLED": + return "disabled"; + case "STATUS_FROZEN": + return "frozen"; + default: + return "unavailable"; + } +} + +function parseKimiBoosterWallet(value: unknown): KimiBillingStatus | null { + const wallet = toRecord(value); + const balance = toRecord(wallet.balance); + if (balance.type !== "BOOSTER") return null; + + const amount = toInteger(balance.amount); + const amountLeft = toInteger(balance.amountLeft); + const monthlyLimit = parseKimiMoney(wallet.monthlyChargeLimit); + const monthlyUsed = parseKimiMoney(wallet.monthlyUsed); + const autoRefillCharge = parseKimiMoney(wallet.autoRefillCharge); + const autoRefillThreshold = parseKimiMoney(wallet.autoRefillThreshold); + const extraUsageStatus = parseKimiExtraUsageStatus(wallet.status); + const hasWalletEvidence = + (amount !== null && amount > 0) || + amountLeft !== null || + monthlyLimit !== null || + monthlyUsed !== null || + extraUsageStatus !== "unavailable"; + if (!hasWalletEvidence) return null; + + const currency = + monthlyLimit?.currency ?? + monthlyUsed?.currency ?? + autoRefillCharge?.currency ?? + autoRefillThreshold?.currency ?? + "USD"; + + return { + currency, + // Proto JSON omits numeric zero values. Production therefore returns a + // BOOSTER balance record without amount/amountLeft when the preserved + // balance is exactly zero; treat that as an explicit zero, not unknown. + extraCreditsMinorUnits: + amountLeft === null || amountLeft < 0 ? 0 : fixedPointToCents(amountLeft), + monthlyUsedMinorUnits: monthlyUsed?.cents ?? 0, + monthlyLimitEnabled: wallet.monthlyChargeLimitEnabled === true, + monthlyLimitMinorUnits: monthlyLimit?.cents ?? 0, + extraUsageStatus, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }; +} + +function buildKimiBillingStatus(value: unknown): KimiBillingStatus { + return ( + parseKimiBoosterWallet(value) ?? { + currency: "USD", + extraUsageStatus: "unavailable", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + } + ); +} + +function optionalNumber(value: unknown): number | null { + if (typeof value !== "number" && typeof value !== "string") return null; + const parsed = toNumber(value, Number.NaN); + return Number.isFinite(parsed) ? parsed : null; +} + +function createKimiCountQuota(value: unknown): UsageQuota | null { + const detail = toRecord(value); + const limit = optionalNumber(detail.limit ?? detail.Limit); + if (limit === null || limit <= 0) return null; + + const reportedUsed = optionalNumber(detail.used ?? detail.Used); + const reportedRemaining = optionalNumber(detail.remaining ?? detail.Remaining); + const used = reportedUsed ?? (reportedRemaining === null ? 0 : limit - reportedRemaining); + return createQuotaFromUsage(used, limit, detail.resetTime ?? detail.reset_at ?? detail.resetAt); +} + +type KimiWindowLabel = { key: string; displayName: string }; + +function normalizeKimiWindow(value: unknown, fallbackIndex: number): KimiWindowLabel { + const window = toRecord(value); + const duration = optionalNumber(window.duration); + const timeUnit = window.timeUnit; + + if (duration !== null && duration > 0) { + if (timeUnit === "TIME_UNIT_MINUTE" && duration % 60 === 0) { + const hours = duration / 60; + return { key: `${hours}h`, displayName: `Code · ${hours}h` }; + } + if (timeUnit === "TIME_UNIT_HOUR") { + return { key: `${duration}h`, displayName: `Code · ${duration}h` }; + } + if (timeUnit === "TIME_UNIT_DAY") { + return { key: `${duration}d`, displayName: `Code · ${duration}d` }; + } + if (timeUnit === "TIME_UNIT_WEEK") { + return { key: `${duration}w`, displayName: `Code · ${duration}w` }; + } + if (timeUnit === "TIME_UNIT_MINUTE") { + return { key: `${duration}m`, displayName: `Code · ${duration}m` }; + } + } + + return { key: `limit_${fallbackIndex}`, displayName: `Code · Limit ${fallbackIndex}` }; +} + /** * Map Kimi membership level to display name * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, @@ -100,52 +243,38 @@ export async function getKimiUsage( const quotas: Record = {}; const dataObj = toRecord(data); + const billing = buildKimiBillingStatus(dataObj.boosterWallet); - // Parse Kimi usage response format - // Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] } - const usageObj = toRecord(dataObj.usage); - - // Check for Kimi's actual usage fields (strings, not numbers) - const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0); - const usageUsed = toNumber(usageObj.used || usageObj.Used, 0); - const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0); - const usageResetTime = - usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; - - if (usageLimit > 0) { - const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0; - - quotas["Weekly"] = { - used: usageUsed, - total: usageLimit, - remaining: usageRemaining, - remainingPercentage: percentRemaining, - resetAt: parseResetTime(usageResetTime), - unlimited: false, - }; + // The managed Kimi Code API reports the Code 7-day quota in `usage`. + // The website's separate shared-membership total/Kimi split comes from a + // Web-session-only endpoint and cannot be read with a Coding OAuth token. + const weeklyQuota = createKimiCountQuota(dataObj.usage); + if (weeklyQuota) { + quotas.code_7d = { ...weeklyQuota, displayName: "Code · 7d" }; } - // Also parse limits array for rate limits + // Each limits[] item is an independent rolling window. Preserve all of + // them with deterministic window-derived keys instead of overwriting one + // generic `Ratelimit` row. const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : []; for (let i = 0; i < limitsArray.length; i++) { const limitItem = toRecord(limitsArray[i]); - const window = toRecord(limitItem.window); - const detail = toRecord(limitItem.detail); + const quota = createKimiCountQuota(limitItem.detail); + if (!quota) continue; - const limit = toNumber(detail.limit || detail.Limit, 0); - const remaining = toNumber(detail.remaining || detail.Remaining, 0); - const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; - - if (limit > 0) { - quotas["Ratelimit"] = { - used: limit - remaining, - total: limit, - remaining, - remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0, - resetAt: parseResetTime(resetTime), - unlimited: false, - }; - } + const normalized = normalizeKimiWindow(limitItem.window, i + 1); + const baseKey = `code_${normalized.key}`; + let key = baseKey; + let suffix = 2; + while (key in quotas) key = `${baseKey}_${suffix++}`; + const reportedName = + typeof limitItem.name === "string" && limitItem.name.trim() ? limitItem.name.trim() : null; + const displayName = reportedName + ? /^code\b/i.test(reportedName) + ? reportedName + : `Code · ${reportedName}` + : normalized.displayName; + quotas[key] = { ...quota, displayName }; } // Check for quota windows (Claude-like format with utilization) as fallback @@ -189,6 +318,7 @@ export async function getKimiUsage( return { plan: planName || "Kimi Coding", quotas, + billing, }; } @@ -199,6 +329,7 @@ export async function getKimiUsage( return { plan: planName || "Kimi Coding", message: "Kimi Coding connected. Usage tracked per request.", + billing, }; } catch (error) { return { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index 7859c0b008..af76b84a77 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -2,7 +2,10 @@ import { useMemo, useState } from "react"; import Card from "@/shared/components/Card"; -import type { GrokBillingStatus } from "@/shared/utils/grokBilling"; +import { + isProviderBillingProvider, + type ProviderBillingStatus, +} from "@/shared/utils/providerBilling"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; import { normalizePlanTier, @@ -35,8 +38,8 @@ interface QuotaCardProps { quotas?: any[]; plan?: string | null; message?: string | null; - billing?: GrokBillingStatus | null; - raw?: { billing?: GrokBillingStatus | null }; + billing?: ProviderBillingStatus | null; + raw?: { billing?: ProviderBillingStatus | null }; stale?: { since?: string; reason?: string } | null; } | undefined; @@ -151,7 +154,9 @@ export default function QuotaCard({ error={error} message={quota?.message ?? null} billing={ - connection.provider === "grok-cli" ? (quota?.billing ?? quota?.raw?.billing) : null + isProviderBillingProvider(connection.provider) + ? (quota?.billing ?? quota?.raw?.billing) + : null } refreshedAt={displayRefreshedAt} hasStaleData={hasStaleData} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 25e47a8741..60346dc03b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -2,7 +2,13 @@ import { useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { buildGrokBillingCardRows, type GrokBillingStatus } from "@/shared/utils/grokBilling"; +import { buildGrokBillingCardRows } from "@/shared/utils/grokBilling"; +import { buildKimiBillingCardRows } from "@/shared/utils/kimiBilling"; +import { + isKimiBillingStatus, + isProviderBillingProvider, + type ProviderBillingStatus, +} from "@/shared/utils/providerBilling"; import { formatCountdown, formatQuotaLabel, @@ -27,19 +33,23 @@ const CURRENCY_SYMBOLS: Record = { const DEFAULT_VISIBLE_ROWS = 3; -function GrokBillingDetails({ billing }: { billing: GrokBillingStatus }) { +function ProviderBillingDetails({ billing }: { billing: ProviderBillingStatus }) { const t = useTranslations("usage"); const locale = useLocale(); - const rows = buildGrokBillingCardRows(billing, locale, (key, fallback) => - translateUsageOrFallback(t, key, fallback) - ); + const rows = isKimiBillingStatus(billing) + ? buildKimiBillingCardRows(billing, locale, (key, fallback) => + translateUsageOrFallback(t, key, fallback) + ) + : buildGrokBillingCardRows(billing, locale, (key, fallback) => + translateUsageOrFallback(t, key, fallback) + ); return (
{rows.map((row) => row.kind === "link" ? ( ) : (
void; @@ -357,7 +367,9 @@ export default function QuotaCardExpanded({
)} - {providerId === "grok-cli" && billing && } + {isProviderBillingProvider(providerId) && billing && ( + + )} {hiddenQuotaRows.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 3aea687442..63977b1d12 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -10,15 +10,16 @@ const CODEX_QUOTA_ORDER: Record = { banked_reset_credits: 4, }; const GLM_FAMILY_PROVIDERS = ["glm", "glm-cn", "glmt", "opencode-go"]; +const KIMI_CODING_PROVIDERS = ["kimi-coding", "kimi-coding-apikey"]; /** - * Providers whose quotas already get a deterministic fixed-window order from - * sortGlmOrder()/sortCodexOrder() below. Display layers (e.g. QuotaCardExpanded) + * Providers whose quotas already get a deterministic fixed-window order below + * (Codex, GLM family, and Kimi Coding). Display layers (e.g. QuotaCardExpanded) * must not re-sort these by remaining percentage, or they undo this order (#6687). */ export function hasFixedQuotaOrder(providerId: string | undefined): boolean { const id = String(providerId || "").toLowerCase(); - return id === "codex" || GLM_FAMILY_PROVIDERS.includes(id); + return id === "codex" || GLM_FAMILY_PROVIDERS.includes(id) || KIMI_CODING_PROVIDERS.includes(id); } function quotaEntries(data: any): Array<[string, any]> { @@ -269,6 +270,19 @@ function sortCodexOrder(providerId: string, quotas: any[]) { quotas.sort((a, b) => (CODEX_QUOTA_ORDER[a.name] ?? 99) - (CODEX_QUOTA_ORDER[b.name] ?? 99)); } +function sortKimiOrder(providerId: string, quotas: any[]) { + if (!KIMI_CODING_PROVIDERS.includes(providerId)) return; + const rank = (name: string) => { + if (/^code_5h(?:_|$)/.test(name)) return 0; + if (/^code_7d(?:_|$)/.test(name)) return 1; + return 99; + }; + quotas.sort((a, b) => { + const rankDiff = rank(String(a.name)) - rank(String(b.name)); + return rankDiff || String(a.name).localeCompare(String(b.name)); + }); +} + export function parseQuotaData(provider: string | undefined, data: any) { if (!data || typeof data !== "object") return []; const providerId = String(provider || "").toLowerCase(); @@ -278,6 +292,7 @@ export function parseQuotaData(provider: string | undefined, data: any) { sortProviderModelOrder(provider, normalizedQuotas); sortGlmOrder(providerId, normalizedQuotas); sortCodexOrder(providerId, normalizedQuotas); + sortKimiOrder(providerId, normalizedQuotas); return normalizedQuotas; } catch (error) { console.error(`Error parsing quota data for ${provider}:`, error); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index add21b4e17..3af32da285 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -392,8 +392,8 @@ const STATUS_ORDER: Record<"critical" | "alert" | "ok", number> = { export function topQuotas(quotas: any[], n = 3, providerId?: string): any[] { const filtered = quotas.filter(Boolean); - // Providers with a deterministic fixed-window order (codex, glm family — see - // quotaParsing.ts's sortCodexOrder()/sortGlmOrder()) must keep the order + // Providers with a deterministic fixed-window order (Codex, GLM family, + // Kimi Coding — see quotaParsing.ts) must keep the order // parseQuotaData() already established rather than being re-sorted by // status/remaining-%, which would undo it (#6687's collapsed-card sibling, #7764). if (hasFixedQuotaOrder(providerId)) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f8683269c4..4a67320eab 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -9098,6 +9098,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "month", "grokAdditionalCredits": "Additional Credits", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0d79c2a0d2..ef36e19471 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -9090,6 +9090,16 @@ "grokAutoTopUpMax": "máximo", "grokAutoTopUpMonth": "mês", "grokAdditionalCredits": "Créditos adicionais", + "kimiExtraUsageCredits": "Créditos de uso extra", + "kimiExtraUsage": "Uso extra", + "kimiExtraUsageEnabled": "Ativado", + "kimiExtraUsageDisabled": "Desativado", + "kimiExtraUsageFrozen": "Congelado", + "kimiExtraUsageUnavailable": "Indisponível", + "kimiMonthlyUsed": "Usado neste mês", + "kimiMonthlyLimit": "Limite mensal", + "kimiMonthlyLimitUnlimited": "Ilimitado", + "kimiAdditionalCredits": "Créditos adicionais", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Gerenciamento de Orçamento", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 16dece1256..1691040f80 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -9098,6 +9098,16 @@ "grokAutoTopUpMax": "tối đa", "grokAutoTopUpMonth": "tháng", "grokAdditionalCredits": "Tín dụng bổ sung", + "kimiExtraUsageCredits": "Tín dụng sử dụng bổ sung", + "kimiExtraUsage": "Sử dụng bổ sung", + "kimiExtraUsageEnabled": "Đã bật", + "kimiExtraUsageDisabled": "Đã tắt", + "kimiExtraUsageFrozen": "Đã đóng băng", + "kimiExtraUsageUnavailable": "Không khả dụng", + "kimiMonthlyUsed": "Đã dùng trong tháng này", + "kimiMonthlyLimit": "Giới hạn hàng tháng", + "kimiMonthlyLimitUnlimited": "Không giới hạn", + "kimiAdditionalCredits": "Tín dụng bổ sung", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Quản lý ngân sách", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f4b62520a6..18a1ea316f 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -9077,6 +9077,16 @@ "grokAutoTopUpMax": "最大", "grokAutoTopUpMonth": "月", "grokAdditionalCredits": "额外的致谢", + "kimiExtraUsageCredits": "加油包余额", + "kimiExtraUsage": "额度加油包", + "kimiExtraUsageEnabled": "已开启", + "kimiExtraUsageDisabled": "已关闭", + "kimiExtraUsageFrozen": "已冻结", + "kimiExtraUsageUnavailable": "不可用", + "kimiMonthlyUsed": "本月已用", + "kimiMonthlyLimit": "每月限额", + "kimiMonthlyLimitUnlimited": "无限制", + "kimiAdditionalCredits": "充值加油包", "loggerTab": "记录器", "proxyTab": "代理", "budgetManagement": "预算管理", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a7ac344ae8..7465d3e9f6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -9077,6 +9077,16 @@ "grokAutoTopUpMax": "最大", "grokAutoTopUpMonth": "月份", "grokAdditionalCredits": "額外的致謝", + "kimiExtraUsageCredits": "加油包餘額", + "kimiExtraUsage": "額度加油包", + "kimiExtraUsageEnabled": "已開啟", + "kimiExtraUsageDisabled": "已關閉", + "kimiExtraUsageFrozen": "已凍結", + "kimiExtraUsageUnavailable": "無法使用", + "kimiMonthlyUsed": "本月已用", + "kimiMonthlyLimit": "每月限額", + "kimiMonthlyLimitUnlimited": "無限制", + "kimiAdditionalCredits": "儲值加油包", "loggerTab": "記錄器", "proxyTab": "代理", "budgetManagement": "預算管理", diff --git a/src/lib/db/providerLimits.ts b/src/lib/db/providerLimits.ts index 427cc4a1ef..677c067a6f 100644 --- a/src/lib/db/providerLimits.ts +++ b/src/lib/db/providerLimits.ts @@ -1,4 +1,7 @@ -import { sanitizeGrokBillingStatus, type GrokBillingStatus } from "@/shared/utils/grokBilling"; +import { + sanitizeProviderBillingStatus, + type ProviderBillingStatus, +} from "@/shared/utils/providerBilling"; import { getDbInstance, isBuildPhase, isCloud } from "./core"; type JsonRecord = Record; @@ -26,7 +29,7 @@ export interface ProviderLimitsCacheEntry { fetchedAt: string; source?: string | null; bankedResetCredits?: number; - billing?: GrokBillingStatus; + billing?: ProviderBillingStatus; } const PROVIDER_LIMITS_CACHE_NAMESPACE = "providerLimitsCache"; @@ -45,7 +48,7 @@ function toRecord(value: unknown): JsonRecord | null { function sanitizeCacheEntryForStorage(entry: ProviderLimitsCacheEntry): ProviderLimitsCacheEntry { const { billing: rawBilling, ...rest } = entry; - const billing = sanitizeGrokBillingStatus(rawBilling); + const billing = sanitizeProviderBillingStatus(rawBilling); return billing ? { ...rest, billing } : rest; } @@ -58,7 +61,7 @@ function normalizeCacheEntry(value: unknown): ProviderLimitsCacheEntry | null { if (!fetchedAt) return null; const bankedResetCredits = Number(record.bankedResetCredits); - const billing = sanitizeGrokBillingStatus(record.billing); + const billing = sanitizeProviderBillingStatus(record.billing); return { quotas: toRecord(record.quotas), diff --git a/src/lib/usage/providerLimitsCache.ts b/src/lib/usage/providerLimitsCache.ts index 75fd031057..6548bef54b 100644 --- a/src/lib/usage/providerLimitsCache.ts +++ b/src/lib/usage/providerLimitsCache.ts @@ -1,5 +1,6 @@ import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits"; -import { sanitizeGrokBillingStatus } from "@/shared/utils/grokBilling"; +import { sanitizeProviderBillingStatus } from "@/shared/utils/providerBilling"; +import { GROK_BUILD_ADDITIONAL_CREDITS_URL } from "@/shared/utils/grokBilling"; const GROK_CLI_PROVIDER = "grok-cli"; @@ -26,7 +27,7 @@ export function toProviderLimitsCacheEntry( fetchedAt, source, bankedResetCredits: Number.isFinite(bankedResetCredits) ? bankedResetCredits : undefined, - billing: sanitizeGrokBillingStatus(usage.billing), + billing: sanitizeProviderBillingStatus(usage.billing), }; } @@ -44,14 +45,21 @@ export function mergeProviderLimitsCacheEntry( if (provider !== GROK_CLI_PROVIDER) return next; const nextBilling = next.billing; - const previousAutoTopUp = previous.billing?.autoTopUp; - if (!nextBilling || nextBilling.autoTopUp.available || !previousAutoTopUp) return next; + const previousBilling = previous.billing; + if ( + !nextBilling || + nextBilling.additionalCreditsUrl !== GROK_BUILD_ADDITIONAL_CREDITS_URL || + nextBilling.autoTopUp.available || + !previousBilling || + previousBilling.additionalCreditsUrl !== GROK_BUILD_ADDITIONAL_CREDITS_URL + ) + return next; return { ...next, billing: { ...nextBilling, - autoTopUp: previousAutoTopUp, + autoTopUp: previousBilling.autoTopUp, }, }; } diff --git a/src/shared/utils/kimiBilling.ts b/src/shared/utils/kimiBilling.ts new file mode 100644 index 0000000000..c818a675aa --- /dev/null +++ b/src/shared/utils/kimiBilling.ts @@ -0,0 +1,199 @@ +/** + * Public Dashboard contract for Kimi Coding Extra Usage (额度加油包). + * + * The existing read-only `GET /coding/v1/usages` response carries both the + * Code quota windows and `boosterWallet`. Only the strictly whitelisted fields + * below may cross the Provider Limits cache/UI boundary. + */ + +export const KIMI_CODE_ADDITIONAL_CREDITS_URL = + "https://www.kimi.com/membership/subscription?tab=quota&aff=omniroute"; + +type KimiExtraUsageStatus = "enabled" | "disabled" | "frozen" | "unavailable"; + +export interface KimiBillingStatus { + /** ISO 4217 currency reported by the wallet money wrappers. */ + currency: string; + /** Remaining Extra Usage balance in cents. */ + extraCreditsMinorUnits?: number; + /** Extra Usage spend so far this calendar month, in cents. */ + monthlyUsedMinorUnits?: number; + /** Whether the member enabled a monthly spending cap. */ + monthlyLimitEnabled?: boolean; + /** Monthly spending cap in cents; 0/absent means unlimited. */ + monthlyLimitMinorUnits?: number; + extraUsageStatus: KimiExtraUsageStatus; + additionalCreditsUrl: typeof KIMI_CODE_ADDITIONAL_CREDITS_URL; +} + +type KimiBillingTranslationKey = + | "kimiExtraUsageCredits" + | "kimiExtraUsage" + | "kimiExtraUsageEnabled" + | "kimiExtraUsageDisabled" + | "kimiExtraUsageFrozen" + | "kimiExtraUsageUnavailable" + | "kimiMonthlyUsed" + | "kimiMonthlyLimit" + | "kimiMonthlyLimitUnlimited" + | "kimiAdditionalCredits"; + +type KimiBillingTranslator = (key: KimiBillingTranslationKey, fallback: string) => string; + +type KimiBillingCardRow = + | { kind: "balance" | "status"; label: string; value: string } + | { + kind: "link"; + label: string; + href: typeof KIMI_CODE_ADDITIONAL_CREDITS_URL; + target: "_blank"; + rel: "noreferrer noopener"; + }; + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function minorUnits(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +const ISO_4217 = /^[A-Za-z]{3}$/; +const EXTRA_USAGE_STATUSES = new Set([ + "enabled", + "disabled", + "frozen", + "unavailable", +]); + +export function sanitizeKimiBillingStatus(value: unknown): KimiBillingStatus | undefined { + const billing = toRecord(value); + if (!billing || billing.additionalCreditsUrl !== KIMI_CODE_ADDITIONAL_CREDITS_URL) + return undefined; + + const currency = + typeof billing.currency === "string" && ISO_4217.test(billing.currency) + ? billing.currency.toUpperCase() + : undefined; + const extraUsageStatus = + typeof billing.extraUsageStatus === "string" && + EXTRA_USAGE_STATUSES.has(billing.extraUsageStatus as KimiExtraUsageStatus) + ? (billing.extraUsageStatus as KimiExtraUsageStatus) + : undefined; + if (!currency || !extraUsageStatus) return undefined; + + const extraCreditsMinorUnits = minorUnits(billing.extraCreditsMinorUnits); + const monthlyUsedMinorUnits = minorUnits(billing.monthlyUsedMinorUnits); + const monthlyLimitMinorUnits = minorUnits(billing.monthlyLimitMinorUnits); + const monthlyLimitEnabled = + typeof billing.monthlyLimitEnabled === "boolean" ? billing.monthlyLimitEnabled : undefined; + + return { + currency, + ...(extraCreditsMinorUnits !== undefined ? { extraCreditsMinorUnits } : {}), + ...(monthlyUsedMinorUnits !== undefined ? { monthlyUsedMinorUnits } : {}), + ...(monthlyLimitEnabled !== undefined ? { monthlyLimitEnabled } : {}), + ...(monthlyLimitMinorUnits !== undefined ? { monthlyLimitMinorUnits } : {}), + extraUsageStatus, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }; +} + +function formatKimiMinorUnits( + value: number | undefined, + currency: KimiBillingStatus["currency"], + locales?: Intl.LocalesArgument +): string | null { + if (value === undefined) return null; + return new Intl.NumberFormat(locales, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value / 100); +} + +const fallbackTranslation: KimiBillingTranslator = (_key, fallback) => fallback; + +function formatExtraUsageStatus( + status: KimiExtraUsageStatus, + translate: KimiBillingTranslator +): string { + switch (status) { + case "enabled": + return translate("kimiExtraUsageEnabled", "Enabled"); + case "disabled": + return translate("kimiExtraUsageDisabled", "Disabled"); + case "frozen": + return translate("kimiExtraUsageFrozen", "Frozen"); + default: + return translate("kimiExtraUsageUnavailable", "Unavailable"); + } +} + +export function buildKimiBillingCardRows( + billing: KimiBillingStatus, + locales?: Intl.LocalesArgument, + translate: KimiBillingTranslator = fallbackTranslation +): KimiBillingCardRow[] { + const rows: KimiBillingCardRow[] = []; + const walletPresent = billing.extraCreditsMinorUnits !== undefined; + + const extraCredits = formatKimiMinorUnits( + billing.extraCreditsMinorUnits, + billing.currency, + locales + ); + if (extraCredits !== null) { + rows.push({ + kind: "balance", + label: translate("kimiExtraUsageCredits", "Extra Usage Credits"), + value: extraCredits, + }); + } + + rows.push({ + kind: "status", + label: translate("kimiExtraUsage", "Extra Usage"), + value: formatExtraUsageStatus(billing.extraUsageStatus, translate), + }); + + if (walletPresent) { + const monthlyUsed = formatKimiMinorUnits( + billing.monthlyUsedMinorUnits, + billing.currency, + locales + ); + if (monthlyUsed !== null) { + rows.push({ + kind: "status", + label: translate("kimiMonthlyUsed", "Used this month"), + value: monthlyUsed, + }); + } + + const capped = + billing.monthlyLimitEnabled === true && + billing.monthlyLimitMinorUnits !== undefined && + billing.monthlyLimitMinorUnits > 0; + const monthlyLimit = capped + ? formatKimiMinorUnits(billing.monthlyLimitMinorUnits, billing.currency, locales) + : null; + rows.push({ + kind: "status", + label: translate("kimiMonthlyLimit", "Monthly limit"), + value: monthlyLimit ?? translate("kimiMonthlyLimitUnlimited", "Unlimited"), + }); + } + + rows.push({ + kind: "link", + label: translate("kimiAdditionalCredits", "Additional Credits"), + href: billing.additionalCreditsUrl, + target: "_blank", + rel: "noreferrer noopener", + }); + return rows; +} diff --git a/src/shared/utils/providerBilling.ts b/src/shared/utils/providerBilling.ts new file mode 100644 index 0000000000..f2ddca4dee --- /dev/null +++ b/src/shared/utils/providerBilling.ts @@ -0,0 +1,36 @@ +import { + GROK_BUILD_ADDITIONAL_CREDITS_URL, + sanitizeGrokBillingStatus, + type GrokBillingStatus, +} from "./grokBilling"; +import { + KIMI_CODE_ADDITIONAL_CREDITS_URL, + sanitizeKimiBillingStatus, + type KimiBillingStatus, +} from "./kimiBilling"; + +export type ProviderBillingStatus = GrokBillingStatus | KimiBillingStatus; + +export const PROVIDER_BILLING_PROVIDERS = [ + "grok-cli", + "kimi-coding", + "kimi-coding-apikey", +] as const; + +export function isProviderBillingProvider(provider: string | undefined): boolean { + return ( + provider !== undefined && (PROVIDER_BILLING_PROVIDERS as readonly string[]).includes(provider) + ); +} + +export function sanitizeProviderBillingStatus(value: unknown): ProviderBillingStatus | undefined { + return sanitizeGrokBillingStatus(value) ?? sanitizeKimiBillingStatus(value); +} + +export function isGrokBillingStatus(billing: ProviderBillingStatus): billing is GrokBillingStatus { + return billing.additionalCreditsUrl === GROK_BUILD_ADDITIONAL_CREDITS_URL; +} + +export function isKimiBillingStatus(billing: ProviderBillingStatus): billing is KimiBillingStatus { + return billing.additionalCreditsUrl === KIMI_CODE_ADDITIONAL_CREDITS_URL; +} diff --git a/tests/unit/kimi-coding-billing-ui.test.ts b/tests/unit/kimi-coding-billing-ui.test.ts new file mode 100644 index 0000000000..985ba05205 --- /dev/null +++ b/tests/unit/kimi-coding-billing-ui.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildKimiBillingCardRows, KIMI_CODE_ADDITIONAL_CREDITS_URL, sanitizeKimiBillingStatus } = + await import("../../src/shared/utils/kimiBilling.ts"); +const { isKimiBillingStatus, isProviderBillingProvider, sanitizeProviderBillingStatus } = + await import("../../src/shared/utils/providerBilling.ts"); +const { PROVIDER_LABEL } = + await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/constants.ts"); +const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + +const baseBilling = { + currency: "CNY", + extraUsageStatus: "unavailable" as const, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, +}; + +test("Kimi billing rows show the real Extra Usage status when the wallet is unavailable", () => { + const rows = buildKimiBillingCardRows(baseBilling, "en-US"); + assert.deepEqual(rows, [ + { kind: "status", label: "Extra Usage", value: "Unavailable" }, + { + kind: "link", + label: "Additional Credits", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ]); +}); + +test("Kimi billing rows show balance, wallet status, monthly spend, cap and buy link", () => { + const rows = buildKimiBillingCardRows( + { + ...baseBilling, + extraCreditsMinorUnits: 1234, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "enabled", + }, + "en-US" + ); + + assert.deepEqual(rows, [ + { kind: "balance", label: "Extra Usage Credits", value: "CN¥12.34" }, + { kind: "status", label: "Extra Usage", value: "Enabled" }, + { kind: "status", label: "Used this month", value: "CN¥2.50" }, + { kind: "status", label: "Monthly limit", value: "CN¥50.00" }, + { + kind: "link", + label: "Additional Credits", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ]); +}); + +test("Kimi monthly cap displays Unlimited when disabled or zero", () => { + for (const billing of [ + { ...baseBilling, extraCreditsMinorUnits: 0, monthlyLimitEnabled: false }, + { + ...baseBilling, + extraCreditsMinorUnits: 0, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 0, + }, + ]) { + const row = buildKimiBillingCardRows(billing, "en-US").find( + (candidate) => candidate.kind === "status" && candidate.label === "Monthly limit" + ); + assert.deepEqual(row, { kind: "status", label: "Monthly limit", value: "Unlimited" }); + } +}); + +test("Kimi billing labels support localized translation fallbacks", () => { + const translate = (key: string, fallback: string) => + ({ + kimiExtraUsageCredits: "加油包余额", + kimiExtraUsage: "额度加油包", + kimiExtraUsageEnabled: "已开启", + kimiExtraUsageDisabled: "已关闭", + kimiExtraUsageFrozen: "已冻结", + kimiExtraUsageUnavailable: "不可用", + kimiMonthlyUsed: "本月已用", + kimiMonthlyLimit: "每月限额", + kimiMonthlyLimitUnlimited: "无限制", + kimiAdditionalCredits: "充值加油包", + })[key] ?? fallback; + + assert.deepEqual( + buildKimiBillingCardRows( + { + ...baseBilling, + extraCreditsMinorUnits: 0, + monthlyLimitEnabled: false, + extraUsageStatus: "disabled", + }, + "zh-CN", + translate + ), + [ + { kind: "balance", label: "加油包余额", value: "¥0.00" }, + { kind: "status", label: "额度加油包", value: "已关闭" }, + { kind: "status", label: "每月限额", value: "无限制" }, + { + kind: "link", + label: "充值加油包", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ] + ); +}); + +test("Kimi billing sanitizer strips private fields and rejects forged public contracts", () => { + const billing = sanitizeKimiBillingStatus({ + currency: "cny", + extraCreditsMinorUnits: 0, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "disabled", + paymentMethodId: "secret", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + rawBody: "secret", + }); + + assert.deepEqual(billing, { + currency: "CNY", + extraCreditsMinorUnits: 0, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "disabled", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }); + assert.equal(buildKimiBillingCardRows(billing!, "zh-CN")[0]?.value, "¥0.00"); + assert.equal(isKimiBillingStatus(billing!), true); + assert.deepEqual(sanitizeProviderBillingStatus(billing), billing); + + for (const forged of [ + { ...baseBilling, currency: "US