From 85e122cececcf31f9b5ea993fee536cf39c2f22a Mon Sep 17 00:00:00 2001 From: iacore Date: Sat, 18 Jul 2026 11:48:20 +0800 Subject: [PATCH] fix(kimi): surface the 5h usage window reset time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kimi Code usages endpoint returns resetTime on each limit's detail object, while window carries only duration/timeUnit. buildWindow() only reads window fields, so the 5h row parsed to a window with durationMs but no resetsAt — and omp usage renders "resets in …" only when window.resetsAt is set. The Total quota row worked because toUsageLimit falls back to row.resetsAt when no window exists, but the row.window ?? short-circuit bypassed that fallback for the 5h row. Carry the row-level reset onto the window when the window itself has none; an explicit window resetTime still wins. --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/usage/kimi.ts | 14 ++++-- packages/ai/test/kimi-usage.test.ts | 74 +++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 packages/ai/test/kimi-usage.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8a57eb6bf..5174d1149 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Kimi Code usage reports dropping the 5h window reset time (`omp usage` showed no "resets in …" for the 5h limit): the API returns `resetTime` on the limit `detail`, not on `window`, so the parsed row-level reset is now carried onto the window when the window itself has none. + ## [17.0.3] - 2026-07-17 ### Fixed diff --git a/packages/ai/src/usage/kimi.ts b/packages/ai/src/usage/kimi.ts index 17c6a2e6f..10c2db5d5 100644 --- a/packages/ai/src/usage/kimi.ts +++ b/packages/ai/src/usage/kimi.ts @@ -144,15 +144,21 @@ function buildUsageStatus(amount: UsageAmount): UsageStatus { } function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accountId?: string): UsageLimit { - const window: UsageWindow | undefined = - row.window ?? - (row.resetsAt + // Kimi puts `resetTime` on the limit `detail`, not on `window`, so a + // window built from `duration`/`timeUnit` alone carries no resetsAt. + // Fall back to the row-level reset so `omp usage` can render + // "resets in …" for the 5h window too. + const window: UsageWindow | undefined = row.window + ? row.window.resetsAt !== undefined || row.resetsAt === undefined + ? row.window + : { ...row.window, resetsAt: row.resetsAt } + : row.resetsAt ? { id: "default", label: "Usage window", resetsAt: row.resetsAt, } - : undefined); + : undefined; const amount = buildUsageAmount(row); return { diff --git a/packages/ai/test/kimi-usage.test.ts b/packages/ai/test/kimi-usage.test.ts new file mode 100644 index 000000000..b86b66310 --- /dev/null +++ b/packages/ai/test/kimi-usage.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import type { UsageFetchContext, UsageFetchParams } from "@oh-my-pi/pi-ai/usage"; +import { kimiUsageProvider } from "@oh-my-pi/pi-ai/usage/kimi"; + +function makeCredential(): UsageFetchParams["credential"] { + return { + type: "oauth", + accessToken: "kimi-test-token", + }; +} + +function makeCtx(payload: unknown): UsageFetchContext { + const fetch: FetchImpl = async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + return { fetch }; +} + +describe("kimi usage provider", () => { + it("surfaces the 5h limit reset time from the limit detail onto the window", async () => { + // Live payload shape: `resetTime` lives on `detail`, while `window` + // carries only duration/timeUnit. The 5h row must still render + // "resets in …" in `omp usage`. + const detailReset = "2026-07-18T05:43:35.355947Z"; + const usageReset = "2026-07-21T07:43:35.355947Z"; + const report = await kimiUsageProvider.fetchUsage!( + { provider: "kimi-code", credential: makeCredential(), signal: undefined }, + makeCtx({ + usage: { limit: "100", used: "28", remaining: "72", resetTime: usageReset }, + limits: [ + { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "100", remaining: "100", resetTime: detailReset }, + }, + ], + }), + ); + + expect(report).not.toBeNull(); + expect(report!.limits).toHaveLength(2); + + const total = report!.limits[0]!; + expect(total.label).toBe("Total quota"); + expect(total.window?.resetsAt).toBe(Date.parse(usageReset)); + + const fiveHour = report!.limits[1]!; + expect(fiveHour.label).toBe("5h limit"); + expect(fiveHour.window?.durationMs).toBe(5 * 60 * 60 * 1000); + expect(fiveHour.window?.resetsAt).toBe(Date.parse(detailReset)); + }); + + it("keeps an explicit window resetTime authoritative over the detail one", async () => { + const windowReset = "2026-07-18T06:00:00.000Z"; + const detailReset = "2026-07-18T05:43:35.355947Z"; + const report = await kimiUsageProvider.fetchUsage!( + { provider: "kimi-code", credential: makeCredential(), signal: undefined }, + makeCtx({ + limits: [ + { + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE", resetTime: windowReset }, + detail: { limit: "100", remaining: "40", resetTime: detailReset }, + }, + ], + }), + ); + + expect(report).not.toBeNull(); + expect(report!.limits).toHaveLength(1); + expect(report!.limits[0]!.window?.resetsAt).toBe(Date.parse(windowReset)); + }); +});