diff --git a/packages/ai/src/usage/kimi.ts b/packages/ai/src/usage/kimi.ts index 3cb021920..94d2a4bd8 100644 --- a/packages/ai/src/usage/kimi.ts +++ b/packages/ai/src/usage/kimi.ts @@ -77,6 +77,23 @@ function formatDurationLabel(duration: number, timeUnit: string): string | undef return undefined; } +const MINUTE_MS = 60_000; +const HOUR_MS = 3_600_000; +const DAY_MS = 86_400_000; + +/** + * Status-line and ranking consumers match on canonical window ids ("5h", + * "7d"), so derive the id from the reported span: the 300-minute burst window + * surfaces as "5h" instead of "300time_unit_minute". Mirrors the + * intervalWindowId convention in minimax-code.ts. + */ +function canonicalWindowId(durationMs: number): string { + if (durationMs > 0 && durationMs % DAY_MS === 0) return `${durationMs / DAY_MS}d`; + if (durationMs > 0 && durationMs % HOUR_MS === 0) return `${durationMs / HOUR_MS}h`; + const minutes = Math.round(durationMs / MINUTE_MS); + return minutes > 0 ? `${minutes}m` : "default"; +} + function buildWindow(windowData: Record, nowMs: number): UsageWindow | undefined { const duration = toNumber(windowData.duration); const timeUnit = typeof windowData.timeUnit === "string" ? windowData.timeUnit : ""; @@ -86,14 +103,15 @@ function buildWindow(windowData: Record, nowMs: number): UsageW if (duration === undefined && !label && !resetsAt) return undefined; let durationMs: number | undefined; if (duration !== undefined) { - if (timeUnit.toUpperCase().includes("MINUTE")) durationMs = duration * 60_000; - else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration * 3_600_000; - else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration * 86_400_000; + if (timeUnit.toUpperCase().includes("MINUTE")) durationMs = duration * MINUTE_MS; + else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration * HOUR_MS; + else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration * DAY_MS; + else if (timeUnit.toUpperCase().includes("WEEK")) durationMs = duration * 7 * DAY_MS; else if (timeUnit.toUpperCase().includes("SECOND")) durationMs = duration * 1000; } return { - id: duration !== undefined && timeUnit ? `${duration}${timeUnit.toLowerCase()}` : "default", + id: durationMs !== undefined ? canonicalWindowId(durationMs) : "default", label: label ?? "Usage window", durationMs, resetsAt, @@ -177,7 +195,13 @@ function parseUsagePayload(payload: unknown, nowMs: number): { rows: KimiUsageRo if (isRecord(data.usage)) { const summary = buildUsageRow(data.usage, "Total quota", nowMs); - if (summary) rows.push(summary); + if (summary) { + // Kimi Code's aggregate quota resets weekly, but the payload carries + // only `resetTime` and no duration. Attach the canonical weekly + // window explicitly so status-line/ranking consumers recognize it. + summary.window = { id: "7d", label: "7 Day", resetsAt: summary.resetsAt }; + rows.push(summary); + } } if (Array.isArray(data.limits)) { diff --git a/packages/ai/test/kimi-usage.test.ts b/packages/ai/test/kimi-usage.test.ts index b86b66310..559452980 100644 --- a/packages/ai/test/kimi-usage.test.ts +++ b/packages/ai/test/kimi-usage.test.ts @@ -45,11 +45,19 @@ describe("kimi usage provider", () => { const total = report!.limits[0]!; expect(total.label).toBe("Total quota"); expect(total.window?.resetsAt).toBe(Date.parse(usageReset)); + // The aggregate quota is the weekly subscription window; canonical id + // lets the status-line usage segment pick it up. + expect(total.window?.id).toBe("7d"); + expect(total.scope?.windowId).toBe("7d"); 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)); + // 300 minutes canonicalizes to "5h" so the status-line usage segment + // recognizes the burst window. + expect(fiveHour.window?.id).toBe("5h"); + expect(fiveHour.scope?.windowId).toBe("5h"); }); it("keeps an explicit window resetTime authoritative over the detail one", async () => { @@ -71,4 +79,27 @@ describe("kimi usage provider", () => { expect(report!.limits).toHaveLength(1); expect(report!.limits[0]!.window?.resetsAt).toBe(Date.parse(windowReset)); }); + + it("canonicalizes whole-day and non-standard window durations", async () => { + const report = await kimiUsageProvider.fetchUsage!( + { provider: "kimi-code", credential: makeCredential(), signal: undefined }, + makeCtx({ + limits: [ + { + window: { duration: 7, timeUnit: "TIME_UNIT_DAY" }, + detail: { limit: "100", remaining: "50" }, + }, + { + window: { duration: 90, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "100", remaining: "50" }, + }, + ], + }), + ); + + expect(report).not.toBeNull(); + expect(report!.limits).toHaveLength(2); + expect(report!.limits[0]!.window?.id).toBe("7d"); + expect(report!.limits[1]!.window?.id).toBe("90m"); + }); }); diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 228d4d124..4c27027b1 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -1407,7 +1407,7 @@ export class StatusLineComponent implements Component { const l = limit as { id?: string; scope?: { windowId?: string; tier?: string }; - window?: { resetsAt?: number }; + window?: { resetsAt?: number; durationMs?: number }; amount?: { usedFraction?: number }; }; const fraction = l.amount?.usedFraction; @@ -1415,9 +1415,22 @@ export class StatusLineComponent implements Component { const windowId = l.scope?.windowId; const tier = l.scope?.tier; const resetsAt = l.window?.resetsAt; + // Canonical window ids win. Fall back to the reported span (same + // tolerance as the 5h priority-boost check) so providers that emit + // non-canonical ids, and cache rows written before a provider was + // canonicalized, still map onto the two subscription windows. + const durationMs = l.window?.durationMs; + const windowClass = + windowId === "5h" || windowId === "7d" + ? windowId + : durationMs !== undefined && Math.abs(durationMs - 5 * 3_600_000) <= 60_000 + ? "5h" + : durationMs !== undefined && Math.abs(durationMs - 7 * 86_400_000) <= 60_000 + ? "7d" + : undefined; // Accept tiered limits, but prefer untiered (backward compat with Anthropic). // An untiered limit always replaces a tiered one; among same-tieredness, first wins. - if (windowId === "5h" && (!fiveHour || (fiveHourTier !== undefined && !tier))) { + if (windowClass === "5h" && (!fiveHour || (fiveHourTier !== undefined && !tier))) { fiveHour = { percent: fraction * 100, resetMinutes: @@ -1425,7 +1438,7 @@ export class StatusLineComponent implements Component { }; fiveHourTier = tier || undefined; } - if (windowId === "7d" && (!sevenDay || (sevenDayTier !== undefined && !tier))) { + if (windowClass === "7d" && (!sevenDay || (sevenDayTier !== undefined && !tier))) { sevenDay = { percent: fraction * 100, resetHours: diff --git a/packages/coding-agent/test/status-line-usage.test.ts b/packages/coding-agent/test/status-line-usage.test.ts index 4f6ea8c46..20c7b85b1 100644 --- a/packages/coding-agent/test/status-line-usage.test.ts +++ b/packages/coding-agent/test/status-line-usage.test.ts @@ -430,4 +430,80 @@ describe("usage status-line segment", () => { expect(stripVTControlCharacters(highWithoutValue)).toBe(stripVTControlCharacters(lowWithoutValue)); expect(highWithoutValue).not.toBe(lowWithoutValue); }); + + it("maps non-canonical window ids onto subscription windows by reported span", async () => { + // Kimi-shaped rows: the burst window reports duration/timeUnit instead + // of a canonical id, and rows written before canonicalization keep the + // old id. The reported span still identifies the window. + const now = Date.now(); + const component = makeComponent([ + { + limits: [ + { + scope: { windowId: "300time_unit_minute" }, + window: { durationMs: 5 * 3_600_000, resetsAt: now + 30 * 60_000 }, + amount: { usedFraction: 0.24 }, + }, + { + scope: { windowId: "weekly" }, + window: { durationMs: 7 * 86_400_000, resetsAt: now + 141 * 3_600_000 }, + amount: { usedFraction: 0.08 }, + }, + ], + }, + ]); + + component.refreshUsageInBackground(); + await flushUsageRefresh(); + const content = stripVTControlCharacters(component.getTopBorder(200).content); + + expect(content).toContain("5h"); + expect(content).toContain("24%"); + expect(content).toContain("7d"); + expect(content).toContain("8%"); + }); + + it("ignores non-canonical windows without a reported span", async () => { + const component = makeComponent([ + { + limits: [ + { scope: { windowId: "default" }, window: {}, amount: { usedFraction: 0.24 } }, + { + scope: { windowId: "monthly" }, + window: { durationMs: 30 * 86_400_000 }, + amount: { usedFraction: 0.5 }, + }, + ], + }, + ]); + + component.refreshUsageInBackground(); + await flushUsageRefresh(); + const content = stripVTControlCharacters(component.getTopBorder(200).content); + + expect(content).not.toContain("24%"); + expect(content).not.toContain("50%"); + }); + + it("prefers canonical window ids over a conflicting reported span", async () => { + const component = makeComponent([ + { + limits: [ + { + scope: { windowId: "5h" }, + window: { durationMs: 7 * 86_400_000 }, + amount: { usedFraction: 0.24 }, + }, + ], + }, + ]); + + component.refreshUsageInBackground(); + await flushUsageRefresh(); + const content = stripVTControlCharacters(component.getTopBorder(200).content); + + expect(content).toContain("5h"); + expect(content).toContain("24%"); + expect(content).not.toContain("7d"); + }); });