diff --git a/.changeset/structured-managed-usage.md b/.changeset/structured-managed-usage.md new file mode 100644 index 00000000000..90f29c4c7ed --- /dev/null +++ b/.changeset/structured-managed-usage.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kimi-code-oauth": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kap-server": patch +--- + +Derive the /usage plan usage window labels and reset hints from structured usage data instead of preformatted text. diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 71d6e1b5b9d..c29cb2cb2d6 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -6,6 +6,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import { formatDuration } from '@moonshot-ai/kimi-code-oauth'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { @@ -24,11 +25,36 @@ const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; type Colorize = (text: string) => string; +export interface ManagedUsageWindow { + readonly duration: number; + readonly unit: 'minute' | 'hour' | 'day' | 'week'; +} + export interface ManagedUsageRow { - readonly label: string; + readonly name?: string; + readonly window?: ManagedUsageWindow; readonly used: number; readonly limit: number; - readonly resetHint?: string; + readonly resetAt?: string; +} + +function usageRowLabel(row: ManagedUsageRow): string { + const window = row.window; + if (window !== undefined) { + if (window.unit === 'week') return 'Weekly limit'; + return `${String(window.duration)}${window.unit[0] ?? ''} limit`; + } + return row.name ?? 'Limit'; +} + +function usageRowResetHint(row: ManagedUsageRow): string | undefined { + const resetAt = row.resetAt; + if (resetAt === undefined) return undefined; + const parsed = Date.parse(resetAt); + if (!Number.isFinite(parsed)) return undefined; + const diffSec = Math.floor((parsed - Date.now()) / 1000); + if (diffSec <= 0) return 'reset'; + return `resets in ${formatDuration(diffSec)}`; } export interface BoosterWalletInfo { @@ -130,17 +156,20 @@ function buildManagedUsageSection( rows.push(...limits); const usedRatio = (r: ManagedUsageRow): number => r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; - const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); + const labels = rows.map((r) => usageRowLabel(r)); + const labelWidth = Math.max(10, ...labels.map((l) => l.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); const out: string[] = [accent('Plan usage')]; - for (const row of rows) { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); const pct = `${Math.round(ratioUsed * 100)}% used`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); - const label = row.label.padEnd(labelWidth, ' '); - const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; + const label = labels[i]!.padEnd(labelWidth, ' '); + const resetHint = usageRowResetHint(row); + const resetStr = resetHint !== undefined ? ` ${muted(resetHint)}` : ''; out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); } return out; diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 34eaaed7dd9..0e81fda8921 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -41,10 +41,10 @@ describe('status panel report lines', () => { summary: null, limits: [ { - label: '5h limit', + window: { duration: 5, unit: 'hour' }, used: 8, limit: 100, - resetHint: 'resets in 1h', + resetAt: new Date(Date.now() + 3600_000).toISOString(), }, ], }, @@ -62,6 +62,7 @@ describe('status panel report lines', () => { expect(output).toContain('25%'); expect(output).toContain('(2.9k / 11.7k)'); expect(output).toContain('Plan usage'); + expect(output).toContain('5h limit'); expect(output).toContain('8% used'); expect(output).not.toContain('Account'); expect(output).not.toContain('AGENTS.md'); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 199031896cb..29cb4ac7de3 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -1,5 +1,5 @@ import { visibleWidth } from '@moonshot-ai/pi-tui'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; @@ -14,38 +14,93 @@ function strip(text: string): string { describe('UsagePanelComponent', () => { it('formats session, context, and managed usage sections', () => { - const lines = buildUsageReportLines({ - sessionUsage: { - byModel: { - kimi: { - inputOther: 1000, - inputCacheRead: 500, - inputCacheCreation: 500, - output: 250, + // Freeze the clock so the resetAt fixture is an exact hour out — with a + // live clock the elapsed milliseconds floor the diff down to 59m. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T00:00:00Z')); + try { + const lines = buildUsageReportLines({ + sessionUsage: { + byModel: { + kimi: { + inputOther: 1000, + inputCacheRead: 500, + inputCacheCreation: 500, + output: 250, + }, + }, + }, + contextUsage: 0.25, + contextTokens: 2500, + maxContextTokens: 10000, + managedUsage: { + summary: { + name: 'daily', + used: 20, + limit: 100, + resetAt: new Date(Date.now() + 3600_000).toISOString(), }, + limits: [], }, + }).map(strip); + + expect(lines).toContain('Session usage'); + expect(lines).toContain(' kimi input 2k output 250 total 2.2k'); + expect(lines).toContain('Context window'); + expect(lines.join('\n')).toContain('25%'); + expect(lines).toContain('Plan usage'); + expect(lines.join('\n')).toContain('daily'); + expect(lines.join('\n')).toContain('20% used'); + expect(lines.join('\n')).toContain('resets in 1h'); + } finally { + vi.useRealTimers(); + } + }); + + it('derives plan usage labels from the window and falls back to name / Limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: { window: { duration: 1, unit: 'week' }, used: 1, limit: 10 }, + limits: [ + { window: { duration: 5, unit: 'hour' }, used: 2, limit: 10 }, + { name: 'Custom cap', used: 3, limit: 10 }, + { used: 4, limit: 10 }, + ], }, - contextUsage: 0.25, - contextTokens: 2500, - maxContextTokens: 10000, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Weekly limit'); + expect(output).toContain('5h limit'); + expect(output).toContain('Custom cap'); + expect(output).toContain('Limit'); + }); + + it('shows "reset" when the reset timestamp is already in the past', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, managedUsage: { - summary: { - label: 'daily', - used: 20, - limit: 100, - resetHint: 'resets tomorrow', - }, - limits: [], + summary: null, + limits: [ + { + name: 'daily', + used: 1, + limit: 10, + resetAt: new Date(Date.now() - 60_000).toISOString(), + }, + ], }, }).map(strip); - expect(lines).toContain('Session usage'); - expect(lines).toContain(' kimi input 2k output 250 total 2.2k'); - expect(lines).toContain('Context window'); - expect(lines.join('\n')).toContain('25%'); - expect(lines).toContain('Plan usage'); - expect(lines.join('\n')).toContain('20% used'); - expect(lines.join('\n')).toContain('resets tomorrow'); + expect(lines.join('\n')).toContain('reset'); + expect(lines.join('\n')).not.toContain('resets in'); }); it('formats extra usage with a monthly limit', () => { diff --git a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts index d72fd875a16..1064217207a 100644 --- a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts +++ b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts @@ -113,11 +113,18 @@ export type RefreshOAuthProviderModelsResponse = z.infer< // `AuthManagedUsageResult` (camelCase domain model → snake_case wire DTO). // --------------------------------------------------------------------------- +export const usageWindowSchema = z.object({ + duration: z.number().int(), + unit: z.enum(['minute', 'hour', 'day', 'week']), +}); +export type UsageWindow = z.infer; + export const usageRowSchema = z.object({ - label: z.string(), + name: z.string().optional(), + window: usageWindowSchema.optional(), used: z.number().int(), limit: z.number().int(), - reset_hint: z.string().optional(), + reset_at: z.string().optional(), }); export type UsageRow = z.infer; diff --git a/packages/kap-server/src/routes/oauth.ts b/packages/kap-server/src/routes/oauth.ts index ae63e883eb5..edcc5b89bf3 100644 --- a/packages/kap-server/src/routes/oauth.ts +++ b/packages/kap-server/src/routes/oauth.ts @@ -201,8 +201,20 @@ function toWireUsage(result: ManagedUsageDomainResult): ManagedUsageResult { } type ManagedUsageDomainResult = Awaited>; -type DomainUsageRow = { label: string; used: number; limit: number; resetHint?: string }; +type DomainUsageRow = { + name?: string; + window?: { duration: number; unit: 'minute' | 'hour' | 'day' | 'week' }; + used: number; + limit: number; + resetAt?: string; +}; function toWireUsageRow(row: DomainUsageRow): UsageRow { - return { label: row.label, used: row.used, limit: row.limit, reset_hint: row.resetHint }; + return { + name: row.name, + window: row.window, + used: row.used, + limit: row.limit, + reset_at: row.resetAt, + }; } diff --git a/packages/kap-server/test/oauthUsage.test.ts b/packages/kap-server/test/oauthUsage.test.ts index 8daa4826936..8db52b51659 100644 --- a/packages/kap-server/test/oauthUsage.test.ts +++ b/packages/kap-server/test/oauthUsage.test.ts @@ -88,8 +88,17 @@ describe('server-v2 GET /api/v1/oauth/usage', () => { it('maps the ok usage payload to the snake_case wire shape', async () => { const getManagedUsage = vi.fn(async () => ({ kind: 'ok' as const, - summary: { label: 'Weekly limit', used: 40, limit: 1000, resetHint: 'resets in 2d' }, - limits: [{ label: '5h limit', used: 1, limit: 100 }], + summary: { + name: 'Weekly limit', + window: { duration: 1, unit: 'week' as const }, + used: 40, + limit: 1000, + resetAt: '2030-01-01T00:00:00.000Z', + }, + limits: [ + { name: '5h limit', window: { duration: 5, unit: 'hour' as const }, used: 1, limit: 100 }, + { used: 2, limit: 50 }, + ], extraUsage: { balanceCents: 500, totalCents: 1000, @@ -103,8 +112,17 @@ describe('server-v2 GET /api/v1/oauth/usage', () => { expect(await getUsage()).toEqual({ kind: 'ok', - summary: { label: 'Weekly limit', used: 40, limit: 1000, reset_hint: 'resets in 2d' }, - limits: [{ label: '5h limit', used: 1, limit: 100 }], + summary: { + name: 'Weekly limit', + window: { duration: 1, unit: 'week' }, + used: 40, + limit: 1000, + reset_at: '2030-01-01T00:00:00.000Z', + }, + limits: [ + { name: '5h limit', window: { duration: 5, unit: 'hour' }, used: 1, limit: 100 }, + { used: 2, limit: 50 }, + ], extra_usage: { balance_cents: 500, total_cents: 1000, diff --git a/packages/node-sdk/examples/kimi-harness-auth-smoke.ts b/packages/node-sdk/examples/kimi-harness-auth-smoke.ts index 90ce644a8d2..a63860fef0c 100644 --- a/packages/node-sdk/examples/kimi-harness-auth-smoke.ts +++ b/packages/node-sdk/examples/kimi-harness-auth-smoke.ts @@ -100,9 +100,9 @@ function printUsage(usage: Awaited); @@ -714,7 +714,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. await expect(harness.auth.getManagedUsage()).resolves.toMatchObject({ kind: 'ok', - summary: { label: 'Dev limit', used: 2, limit: 10 }, + summary: { name: 'Dev limit', used: 2, limit: 10 }, }); await expect( harness.auth.submitFeedback({ @@ -798,7 +798,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ ).resolves.toBe('env-access-token'); await expect(harness.auth.getManagedUsage()).resolves.toMatchObject({ kind: 'ok', - summary: { label: 'Env limit', used: 3, limit: 10 }, + summary: { name: 'Env limit', used: 3, limit: 10 }, }); await expect( harness.auth.submitFeedback({ diff --git a/packages/oauth/examples/kimi-oauth-smoke.ts b/packages/oauth/examples/kimi-oauth-smoke.ts index cd23a43e517..ebdd2e3c70e 100644 --- a/packages/oauth/examples/kimi-oauth-smoke.ts +++ b/packages/oauth/examples/kimi-oauth-smoke.ts @@ -109,9 +109,11 @@ function printUsage( process.stdout.write(`usage: no summary, limits=${String(usage.limits.length)}\n`); return; } - process.stdout.write( - `usage: ${summary.label} ${String(summary.used)}/${String(summary.limit)}\n`, - ); + const label = + summary.window !== undefined + ? `${String(summary.window.duration)}${summary.window.unit[0] ?? ''} limit` + : (summary.name ?? 'Limit'); + process.stdout.write(`usage: ${label} ${String(summary.used)}/${String(summary.limit)}\n`); } function shouldKeepToken(hasExplicitHomeDir: boolean): boolean { diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 943d122e857..3cea3ecef56 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -80,7 +80,6 @@ export type { export { fetchManagedUsage, formatDuration, - formatResetTime, isManagedKimiCode, isManagedKimiCodeBaseUrl, kimiCodeBaseUrl, @@ -92,6 +91,7 @@ export type { FetchManagedUsageResult, ParsedManagedUsage, UsageRow, + UsageWindow, } from './managed-usage'; export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback'; diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 3869e2b11b4..f004795002a 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -5,16 +5,20 @@ * `/usages` endpoint that returns a payload of the shape: * * { - * "usage": { "name": "Weekly limit", "used": 40, "limit": 1000, "resetAt": "..." }, + * "usage": { "used": "40", "limit": "1000", "resetTime": "2026-08-03T05:20:51Z" }, * "limits": [ - * { "detail": {"used":1, "limit":100, "name":"5h limit"}, "window": {...} }, + * { + * "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + * "detail": { "used": "1", "limit": "100", "resetTime": "..." } + * }, * ... - * ] + * ], + * "boosterWallet": { ... } * } * - * The parser is intentionally loose because field spelling / casing - * drifted across versions (`used` vs `remaining`, `resetAt` vs - * `reset_at`, `duration+timeUnit` window labels, etc.). + * Numbers arrive as decimal strings; `timeUnit` is a proto-style enum. The + * parser normalizes the payload into a structured, camelCase domain model; + * presentation (labels, reset hints) is left to the consumer. */ import { readApiErrorMessage } from './api-error'; @@ -65,11 +69,19 @@ function parseNormalizedUrl(value: string): string | undefined { } } +export interface UsageWindow { + readonly duration: number; + readonly unit: 'minute' | 'hour' | 'day' | 'week'; +} + export interface UsageRow { - readonly label: string; + /** Backend `name`, passed through for custom labels. */ + readonly name?: string; + readonly window?: UsageWindow; readonly used: number; readonly limit: number; - readonly resetHint?: string | undefined; + /** ISO timestamp at which the window resets. */ + readonly resetAt?: string; } export interface BoosterWalletInfo { @@ -146,107 +158,88 @@ export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { return { summary: null, limits: [], extraUsage: null }; } const rec = payload as Record; - const summary = toUsageRow(rec['usage'], 'Weekly limit'); + let summary = toUsageRow(rec['usage']); + // The summary is the plan's weekly limit; the backend omits the window, + // so synthesize it here instead of making every client special-case it. + if (summary !== null && summary.window === undefined) { + summary = { ...summary, window: { duration: 1, unit: 'week' } }; + } + return { + summary, + limits: parseLimitRows(rec), + extraUsage: parseBoosterWallet(rec['boosterWallet']), + }; +} + +function parseLimitRows(rec: Record): UsageRow[] { const limits: UsageRow[] = []; const rawLimits = rec['limits']; - if (Array.isArray(rawLimits)) { - for (let idx = 0; idx < rawLimits.length; idx++) { - const item = rawLimits[idx] as Record | undefined; - if (!item || typeof item !== 'object') continue; - const detailRaw = item['detail']; - const detail = isRecord(detailRaw) ? detailRaw : item; - const windowRaw = item['window']; - const window = isRecord(windowRaw) ? windowRaw : {}; - const label = limitLabel(item, detail, window, idx); - const row = toUsageRow(detail, label); - if (row !== null) limits.push(row); - } + if (!Array.isArray(rawLimits)) return limits; + for (const rawItem of rawLimits) { + if (!isRecord(rawItem)) continue; + const row = toUsageRow(rawItem['detail'], { + name: nameFrom(rawItem), + window: windowFrom(rawItem['window']), + }); + if (row !== null) limits.push(row); } - const extraUsage = parseBoosterWallet(rec['boosterWallet']); - return { summary, limits, extraUsage }; + return limits; } -function toUsageRow(raw: unknown, defaultLabel: string): UsageRow | null { +function toUsageRow( + raw: unknown, + extra: { readonly name?: string; readonly window?: UsageWindow } = {}, +): UsageRow | null { if (!isRecord(raw)) return null; + const used = toInt(raw['used']); const limit = toInt(raw['limit']); - let used = toInt(raw['used']); - if (used === null) { - const remaining = toInt(raw['remaining']); - if (remaining !== null && limit !== null) { - used = limit - remaining; - } - } if (used === null && limit === null) return null; - const name = - typeof raw['name'] === 'string' - ? raw['name'] - : typeof raw['title'] === 'string' - ? raw['title'] - : defaultLabel; - const resetHint = resetHintFrom(raw); return { - label: name, + name: extra.name ?? nameFrom(raw), + window: extra.window, used: used ?? 0, limit: limit ?? 0, - resetHint, + resetAt: resetAtFrom(raw), }; } -function limitLabel( - item: Record, - detail: Record, - window: Record, - idx: number, -): string { - for (const key of ['name', 'title', 'scope']) { - const v = item[key] ?? detail[key]; - if (typeof v === 'string' && v.length > 0) return v; - } - const duration = toInt(window['duration'] ?? item['duration'] ?? detail['duration']); - const rawUnit = window['timeUnit'] ?? item['timeUnit'] ?? detail['timeUnit']; - const timeUnit = typeof rawUnit === 'string' ? rawUnit : ''; - if (duration !== null) { - if (timeUnit.includes('MINUTE')) { - if (duration >= 60 && duration % 60 === 0) return `${String(duration / 60)}h limit`; - return `${String(duration)}m limit`; - } - if (timeUnit.includes('HOUR')) return `${String(duration)}h limit`; - if (timeUnit.includes('DAY')) return `${String(duration)}d limit`; - return `${String(duration)}s limit`; - } - return `Limit #${String(idx + 1)}`; +function nameFrom(raw: Record): string | undefined { + const v = raw['name']; + return typeof v === 'string' && v.length > 0 ? v : undefined; } -function resetHintFrom(raw: Record): string | undefined { - for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) { - const v = raw[key]; - if (typeof v === 'string' && v.length > 0) { - return formatResetTime(v); - } +function normalizeTimeUnit(raw: unknown): UsageWindow['unit'] | null { + switch (raw) { + case 'TIME_UNIT_MINUTE': + return 'minute'; + case 'TIME_UNIT_HOUR': + return 'hour'; + case 'TIME_UNIT_DAY': + return 'day'; + case 'TIME_UNIT_WEEK': + return 'week'; + default: + return null; } - for (const key of ['reset_in', 'resetIn', 'ttl', 'window']) { - const seconds = toInt(raw[key]); - if (seconds !== null && seconds > 0) { - return `resets in ${formatDuration(seconds)}`; - } - } - return undefined; } -export function formatResetTime(val: string): string { - let normalised = val; - // ISO with nano precision → trim to ms for JS Date. - if (normalised.includes('.') && normalised.endsWith('Z')) { - const [base, frac] = normalised.slice(0, -1).split('.'); - if (base !== undefined && frac !== undefined) { - normalised = `${base}.${frac.slice(0, 3)}Z`; - } +function windowFrom(raw: unknown): UsageWindow | undefined { + if (!isRecord(raw)) return undefined; + const duration = toInt(raw['duration']); + const unit = normalizeTimeUnit(raw['timeUnit']); + if (duration === null || unit === null) return undefined; + // The platform expresses sub-day windows in minutes (the 5-hour limit + // arrives as 300 TIME_UNIT_MINUTE); fold whole hours so clients render + // "5h limit" rather than "300m limit". + if (unit === 'minute' && duration >= 60 && duration % 60 === 0) { + return { duration: duration / 60, unit: 'hour' }; } - const parsed = Date.parse(normalised); - if (!Number.isFinite(parsed)) return `resets at ${val}`; - const diffSec = Math.floor((parsed - Date.now()) / 1000); - if (diffSec <= 0) return 'reset'; - return `resets in ${formatDuration(diffSec)}`; + return { duration, unit }; +} + +function resetAtFrom(raw: Record): string | undefined { + const v = raw['resetTime']; + return typeof v === 'string' && v.length > 0 ? v : undefined; } export function formatDuration(totalSeconds: number): string { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index f3c493040c5..6e3cd70291e 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest'; import { fetchManagedUsage, formatDuration, - formatResetTime, isManagedKimiCode, isManagedKimiCodeBaseUrl, kimiCodeBaseUrl, @@ -76,50 +75,87 @@ describe('parseManagedUsagePayload', () => { expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [], extraUsage: null }); }); - it('extracts a summary from the `usage` object', () => { + it('parses the numeric strings the platform reports', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: '17', limit: '100', resetTime: '2030-01-01T00:00:00.000Z' }, + }); + expect(parsed.summary).toEqual({ + used: 17, + limit: 100, + resetAt: '2030-01-01T00:00:00.000Z', + window: { duration: 1, unit: 'week' }, + }); + }); + + it('extracts a summary from the `usage` object and passes its name through', () => { const parsed = parseManagedUsagePayload({ usage: { used: 40, limit: 1000, name: 'Weekly limit' }, }); expect(parsed.summary).toEqual({ - label: 'Weekly limit', + name: 'Weekly limit', + window: { duration: 1, unit: 'week' }, used: 40, limit: 1000, }); expect(parsed.limits).toEqual([]); }); - it('falls back to remaining=limit-used when used is absent', () => { - const parsed = parseManagedUsagePayload({ usage: { remaining: 200, limit: 1000 } }); - expect(parsed.summary).toEqual({ label: 'Weekly limit', used: 800, limit: 1000 }); + it('treats an unnamed summary as the weekly limit', () => { + const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }); + expect(parsed.summary).toEqual({ + used: 1, + limit: 10, + window: { duration: 1, unit: 'week' }, + }); + }); + + it('defaults used to 0 when absent', () => { + const parsed = parseManagedUsagePayload({ usage: { limit: 1000 } }); + expect(parsed.summary).toMatchObject({ used: 0, limit: 1000 }); }); - it('labels limits from window duration when no name is given', () => { + it('normalizes window duration and timeUnit from the window record', () => { const parsed = parseManagedUsagePayload({ limits: [ - { detail: { used: 1, limit: 100 }, window: { duration: 300, timeUnit: 'MINUTE' } }, - { detail: { used: 2, limit: 50 }, window: { duration: 24, timeUnit: 'HOUR' } }, + { detail: { used: 1, limit: 100 }, window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' } }, + { detail: { used: 2, limit: 50 }, window: { duration: 24, timeUnit: 'TIME_UNIT_HOUR' } }, + { detail: { used: 3, limit: 60 }, window: { duration: 7, timeUnit: 'TIME_UNIT_DAY' } }, + { detail: { used: 4, limit: 30 }, window: { duration: 90, timeUnit: 'TIME_UNIT_MINUTE' } }, ], }); - expect(parsed.limits.map((l) => l.label)).toEqual(['5h limit', '24h limit']); + expect(parsed.limits.map((l) => l.window)).toEqual([ + // Whole-hour minute windows fold to hours (300 MINUTE = the 5h limit). + { duration: 5, unit: 'hour' }, + { duration: 24, unit: 'hour' }, + { duration: 7, unit: 'day' }, + // Non-hour-aligned minute windows stay in minutes. + { duration: 90, unit: 'minute' }, + ]); }); - it('prefers explicit item.name over window duration label', () => { + it('passes through `name` from the item or detail', () => { const parsed = parseManagedUsagePayload({ limits: [ - { - name: 'Daily cap', - detail: { used: 5, limit: 100 }, - window: { duration: 1440, timeUnit: 'MINUTE' }, - }, + { name: 'Daily cap', detail: { used: 5, limit: 100 } }, + { detail: { used: 1, limit: 10, name: 'Detail named' } }, ], }); - expect(parsed.limits[0]!.label).toBe('Daily cap'); + expect(parsed.limits.map((l) => l.name)).toEqual(['Daily cap', 'Detail named']); }); - it('surfaces reset hints from resetAt timestamps', () => { - const future = new Date(Date.now() + 3600_000).toISOString(); - const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, resetAt: future } }); - expect(parsed.summary?.resetHint).toMatch(/resets in/); + it('skips limit rows without a detail record', () => { + const parsed = parseManagedUsagePayload({ + limits: [{ used: 2, limit: 20 }], + }); + expect(parsed.limits).toEqual([]); + }); + + it('passes the detail resetTime through as resetAt', () => { + const at = '2030-01-01T00:00:00.000Z'; + const parsed = parseManagedUsagePayload({ + limits: [{ detail: { used: 1, limit: 10, resetTime: at } }], + }); + expect(parsed.limits[0]?.resetAt).toBe(at); }); it('extracts extra usage from boosterWallet.balance', () => { @@ -204,7 +240,7 @@ describe('fetchManagedUsage', () => { await expect(fetchManagedUsage('https://api.example/usages', 'access-token')).resolves.toEqual({ kind: 'ok', parsed: { - summary: { label: 'Weekly limit', used: 1, limit: 10 }, + summary: { used: 1, limit: 10, window: { duration: 1, unit: 'week' } }, limits: [], extraUsage: null, }, @@ -281,19 +317,3 @@ describe('formatDuration', () => { expect(formatDuration(86_400 + 7200 + 600)).toBe('1d 2h 10m'); }); }); - -describe('formatResetTime', () => { - it('returns "reset" for past timestamps', () => { - const past = new Date(Date.now() - 5000).toISOString(); - expect(formatResetTime(past)).toBe('reset'); - }); - - it('returns "resets in X" for future timestamps', () => { - const future = new Date(Date.now() + 3600_000).toISOString(); - expect(formatResetTime(future)).toMatch(/^resets in /); - }); - - it('falls back when parsing fails', () => { - expect(formatResetTime('not-a-date')).toBe('resets at not-a-date'); - }); -}); diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 6ceaae41705..56c86b44ebd 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -599,7 +599,12 @@ describe('KimiOAuthToolkit', () => { await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ kind: 'ok', - summary: { label: 'Weekly limit', used: 10, limit: 100 }, + summary: { + name: 'Weekly limit', + window: { duration: 1, unit: 'week' }, + used: 10, + limit: 100, + }, limits: [], extraUsage: { balanceCents: 10000, @@ -634,7 +639,12 @@ describe('KimiOAuthToolkit', () => { await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ kind: 'ok', - summary: { label: 'Weekly limit', used: 10, limit: 100 }, + summary: { + name: 'Weekly limit', + window: { duration: 1, unit: 'week' }, + used: 10, + limit: 100, + }, limits: [], extraUsage: null, });