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)); + }); +});