From 884a5ff81cc99ff4264ce82f3208aecfcd3ba58f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 28 Jul 2026 14:22:09 +0800 Subject: [PATCH 1/3] feat(oauth): return structured managed usage rows Stop formatting plan-usage labels and reset hints into English strings at the oauth layer. The parser still absorbs backend field drift, but now emits a stable structured row (name / window{duration,unit} / used / limit / resetAt) that kap-server passes through and clients localize themselves: - window: normalized from duration/timeUnit (minute/hour/day/week), whole-hour minute windows fold to hours, unnamed summaries are the weekly limit - resetAt: absolute ISO timestamp; relative reset_in/ttl seconds are converted at parse time - TUI /usage panel formats labels and reset hints locally --- .changeset/structured-managed-usage.md | 7 + .../tui/components/messages/usage-panel.ts | 41 ++++- .../components/messages/status-panel.test.ts | 5 +- .../components/messages/usage-panel.test.ts | 107 +++++++++--- .../src/app/auth/oauthProtocol.ts | 11 +- packages/kap-server/src/routes/oauth.ts | 16 +- packages/kap-server/test/oauthUsage.test.ts | 26 ++- packages/oauth/examples/kimi-oauth-smoke.ts | 8 +- packages/oauth/src/index.ts | 2 +- packages/oauth/src/managed-usage.ts | 164 ++++++++++-------- packages/oauth/test/managed-usage.test.ts | 109 +++++++++--- packages/oauth/test/toolkit.test.ts | 14 +- 12 files changed, 365 insertions(+), 145 deletions(-) create mode 100644 .changeset/structured-managed-usage.md 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/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..86cc553f735 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -7,14 +7,16 @@ * { * "usage": { "name": "Weekly limit", "used": 40, "limit": 1000, "resetAt": "..." }, * "limits": [ - * { "detail": {"used":1, "limit":100, "name":"5h limit"}, "window": {...} }, + * { "detail": {"used":1, "limit":100, "name":"5h limit"}, "window": {"duration":5, "timeUnit":"HOUR"} }, * ... * ] * } * * The parser is intentionally loose because field spelling / casing * drifted across versions (`used` vs `remaining`, `resetAt` vs - * `reset_at`, `duration+timeUnit` window labels, etc.). + * `reset_at`, `duration+timeUnit` window shapes, etc.). It 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 +67,22 @@ function parseNormalizedUrl(value: string): string | undefined { } } +export interface UsageWindow { + /** Raw window length as reported by the backend, e.g. 5. */ + readonly duration: number; + /** Normalized from the backend `timeUnit` (casing / plural drift tolerated). */ + readonly unit: 'minute' | 'hour' | 'day' | 'week'; +} + export interface UsageRow { - readonly label: string; + /** Raw backend name/title/scope, passed through for custom labels. */ + readonly name?: string; + /** Rate-limit window, when it can be derived from the payload. */ + 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,27 +159,47 @@ 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 row is the plan's weekly limit; the backend does not always + // spell that out, so synthesize the window when none was given and the row + // is unnamed or already says "weekly" (clients display window before name, + // so a "Weekly limit" name keeps its label; any other custom name means the + // row is not the weekly limit and is left untouched). + if ( + summary !== null && + summary.window === undefined && + (summary.name === undefined || /weekly/i.test(summary.name)) + ) { + 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 detailRaw = rawItem['detail']; + const detail = isRecord(detailRaw) ? detailRaw : rawItem; + const row = toUsageRow(detail, { + name: nameFrom(rawItem), + window: windowFrom(rawItem), + }); + 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 limit = toInt(raw['limit']); let used = toInt(raw['used']); @@ -177,78 +210,71 @@ function toUsageRow(raw: unknown, defaultLabel: string): UsageRow | null { } } 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 ?? windowFrom(raw), 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; +function nameFrom(...sources: readonly Record[]): string | undefined { + for (const src of sources) { + for (const key of ['name', 'title', 'scope']) { + const v = src[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`; + return undefined; +} + +function normalizeTimeUnit(raw: unknown): UsageWindow['unit'] | null { + if (typeof raw !== 'string') return null; + const unit = raw.toUpperCase(); + if (unit.includes('MINUTE')) return 'minute'; + if (unit.includes('HOUR')) return 'hour'; + if (unit.includes('DAY')) return 'day'; + if (unit.includes('WEEK')) return 'week'; + return null; +} + +function windowFrom(...sources: readonly Record[]): UsageWindow | undefined { + for (const src of sources) { + const nested = src['window']; + const candidates = isRecord(nested) ? [nested, src] : [src]; + for (const candidate of candidates) { + const duration = toInt(candidate['duration']); + const unit = normalizeTimeUnit(candidate['timeUnit']); + if (duration === null || unit === null) continue; + // The platform expresses sub-day windows in minutes (e.g. the 5-hour + // limit arrives as 300 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' }; + } + return { duration, unit }; } - 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)}`; + return undefined; } -function resetHintFrom(raw: Record): string | undefined { +function resetAtFrom(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); + return v; } } - for (const key of ['reset_in', 'resetIn', 'ttl', 'window']) { + for (const key of ['reset_in', 'resetIn', 'ttl']) { const seconds = toInt(raw[key]); if (seconds !== null && seconds > 0) { - return `resets in ${formatDuration(seconds)}`; + return new Date(Date.now() + seconds * 1000).toISOString(); } } 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`; - } - } - 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)}`; -} - export function formatDuration(totalSeconds: number): string { if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return '0s'; const seconds = Math.floor(totalSeconds); diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index f3c493040c5..516079a596e 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, @@ -81,29 +80,68 @@ describe('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('recognizes weekly summaries case-insensitively and only fills a missing window', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10, name: 'WEEKLY LIMIT' }, + }); + expect(parsed.summary?.window).toEqual({ duration: 1, unit: 'week' }); + + const withWindow = parseManagedUsagePayload({ + usage: { used: 1, limit: 10, name: 'Weekly limit', window: { duration: 2, timeUnit: 'WEEK' } }, + }); + expect(withWindow.summary?.window).toEqual({ duration: 2, unit: 'week' }); + }); + 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 }); + expect(parsed.summary).toEqual({ + used: 800, + limit: 1000, + window: { duration: 1, unit: 'week' }, + }); }); - 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: 3, limit: 60 }, window: { duration: 7, timeUnit: 'days' } }, + { detail: { used: 4, limit: 30 }, window: { duration: 90, timeUnit: 'MINUTE' } }, + ], + }); + 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('derives the window from duration/timeUnit fields on the item or detail', () => { + const parsed = parseManagedUsagePayload({ + limits: [ + { duration: 5, timeUnit: 'HOURS', detail: { used: 1, limit: 100 } }, + { used: 2, limit: 50, duration: 1, timeUnit: 'Day' }, ], }); - expect(parsed.limits.map((l) => l.label)).toEqual(['5h limit', '24h limit']); + expect(parsed.limits.map((l) => l.window)).toEqual([ + { duration: 5, unit: 'hour' }, + { duration: 1, unit: 'day' }, + ]); }); - it('prefers explicit item.name over window duration label', () => { + it('passes through item names and falls back to title/scope', () => { const parsed = parseManagedUsagePayload({ limits: [ { @@ -111,15 +149,46 @@ describe('parseManagedUsagePayload', () => { detail: { used: 5, limit: 100 }, window: { duration: 1440, timeUnit: 'MINUTE' }, }, + { title: 'Titled', used: 1, limit: 10 }, + { scope: 'Scoped', used: 2, limit: 10 }, ], }); - expect(parsed.limits[0]!.label).toBe('Daily cap'); + expect(parsed.limits.map((l) => l.name)).toEqual(['Daily cap', 'Titled', 'Scoped']); + }); + + 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('omits name and window on limit rows when the payload carries neither', () => { + const parsed = parseManagedUsagePayload({ + limits: [{ used: 2, limit: 20 }], + }); + expect(parsed.limits).toEqual([{ used: 2, limit: 20 }]); + }); + + it('passes through reset timestamps across spelling drift', () => { + const at = '2030-01-01T00:00:00.000Z'; + for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) { + const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, [key]: at } }); + expect(parsed.summary?.resetAt).toBe(at); + } }); - 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('converts reset_in / resetIn / ttl seconds to an absolute ISO timestamp', () => { + for (const key of ['reset_in', 'resetIn', 'ttl']) { + const before = Date.now(); + const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, [key]: 3600 } }); + const resetAt = Date.parse(parsed.summary?.resetAt ?? ''); + expect(Number.isFinite(resetAt)).toBe(true); + expect(resetAt).toBeGreaterThanOrEqual(before + 3600_000); + expect(resetAt).toBeLessThanOrEqual(Date.now() + 3600_000); + } }); it('extracts extra usage from boosterWallet.balance', () => { @@ -204,7 +273,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 +350,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, }); From 5d1ecb985cdf830c0538b80fdf0ac48551937178 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 28 Jul 2026 14:46:52 +0800 Subject: [PATCH 2/3] refactor(oauth): parse managed usage strictly to the current payload shape Drop the defensive drift tolerance (alternate reset-time spellings, reset_in/ttl seconds, remaining-derived used, title/scope names, top-level duration/timeUnit, fuzzy time-unit matching) and parse only what the platform actually sends: numeric strings, resetTime, nested detail/window records, TIME_UNIT_* enums. --- packages/oauth/src/managed-usage.ts | 125 ++++++++-------------- packages/oauth/test/managed-usage.test.ts | 107 +++++++----------- 2 files changed, 83 insertions(+), 149 deletions(-) diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 86cc553f735..f004795002a 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -5,18 +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": {"duration":5, "timeUnit":"HOUR"} }, + * { + * "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 shapes, etc.). It normalizes the - * payload into a structured, camelCase domain model; presentation - * (labels, reset hints) is left to the consumer. + * 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'; @@ -68,16 +70,13 @@ function parseNormalizedUrl(value: string): string | undefined { } export interface UsageWindow { - /** Raw window length as reported by the backend, e.g. 5. */ readonly duration: number; - /** Normalized from the backend `timeUnit` (casing / plural drift tolerated). */ readonly unit: 'minute' | 'hour' | 'day' | 'week'; } export interface UsageRow { - /** Raw backend name/title/scope, passed through for custom labels. */ + /** Backend `name`, passed through for custom labels. */ readonly name?: string; - /** Rate-limit window, when it can be derived from the payload. */ readonly window?: UsageWindow; readonly used: number; readonly limit: number; @@ -160,16 +159,9 @@ export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { } const rec = payload as Record; let summary = toUsageRow(rec['usage']); - // The summary row is the plan's weekly limit; the backend does not always - // spell that out, so synthesize the window when none was given and the row - // is unnamed or already says "weekly" (clients display window before name, - // so a "Weekly limit" name keeps its label; any other custom name means the - // row is not the weekly limit and is left untouched). - if ( - summary !== null && - summary.window === undefined && - (summary.name === undefined || /weekly/i.test(summary.name)) - ) { + // 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 { @@ -185,11 +177,9 @@ function parseLimitRows(rec: Record): UsageRow[] { if (!Array.isArray(rawLimits)) return limits; for (const rawItem of rawLimits) { if (!isRecord(rawItem)) continue; - const detailRaw = rawItem['detail']; - const detail = isRecord(detailRaw) ? detailRaw : rawItem; - const row = toUsageRow(detail, { + const row = toUsageRow(rawItem['detail'], { name: nameFrom(rawItem), - window: windowFrom(rawItem), + window: windowFrom(rawItem['window']), }); if (row !== null) limits.push(row); } @@ -201,78 +191,55 @@ function toUsageRow( 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; return { name: extra.name ?? nameFrom(raw), - window: extra.window ?? windowFrom(raw), + window: extra.window, used: used ?? 0, limit: limit ?? 0, resetAt: resetAtFrom(raw), }; } -function nameFrom(...sources: readonly Record[]): string | undefined { - for (const src of sources) { - for (const key of ['name', 'title', 'scope']) { - const v = src[key]; - if (typeof v === 'string' && v.length > 0) return v; - } - } - return undefined; +function nameFrom(raw: Record): string | undefined { + const v = raw['name']; + return typeof v === 'string' && v.length > 0 ? v : undefined; } function normalizeTimeUnit(raw: unknown): UsageWindow['unit'] | null { - if (typeof raw !== 'string') return null; - const unit = raw.toUpperCase(); - if (unit.includes('MINUTE')) return 'minute'; - if (unit.includes('HOUR')) return 'hour'; - if (unit.includes('DAY')) return 'day'; - if (unit.includes('WEEK')) return 'week'; - return 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; + } } -function windowFrom(...sources: readonly Record[]): UsageWindow | undefined { - for (const src of sources) { - const nested = src['window']; - const candidates = isRecord(nested) ? [nested, src] : [src]; - for (const candidate of candidates) { - const duration = toInt(candidate['duration']); - const unit = normalizeTimeUnit(candidate['timeUnit']); - if (duration === null || unit === null) continue; - // The platform expresses sub-day windows in minutes (e.g. the 5-hour - // limit arrives as 300 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' }; - } - return { duration, unit }; - } +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' }; } - return undefined; + return { duration, unit }; } function resetAtFrom(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 v; - } - } - for (const key of ['reset_in', 'resetIn', 'ttl']) { - const seconds = toInt(raw[key]); - if (seconds !== null && seconds > 0) { - return new Date(Date.now() + seconds * 1000).toISOString(); - } - } - return 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 516079a596e..6e3cd70291e 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -75,47 +75,52 @@ 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: 40, limit: 1000, name: 'Weekly limit' }, + usage: { used: '17', limit: '100', resetTime: '2030-01-01T00:00:00.000Z' }, }); expect(parsed.summary).toEqual({ - name: 'Weekly limit', + used: 17, + limit: 100, + resetAt: '2030-01-01T00:00:00.000Z', window: { duration: 1, unit: 'week' }, - used: 40, - limit: 1000, }); - expect(parsed.limits).toEqual([]); }); - it('recognizes weekly summaries case-insensitively and only fills a missing window', () => { + it('extracts a summary from the `usage` object and passes its name through', () => { const parsed = parseManagedUsagePayload({ - usage: { used: 1, limit: 10, name: 'WEEKLY LIMIT' }, + usage: { used: 40, limit: 1000, name: 'Weekly limit' }, }); - expect(parsed.summary?.window).toEqual({ duration: 1, unit: 'week' }); - - const withWindow = parseManagedUsagePayload({ - usage: { used: 1, limit: 10, name: 'Weekly limit', window: { duration: 2, timeUnit: 'WEEK' } }, + expect(parsed.summary).toEqual({ + name: 'Weekly limit', + window: { duration: 1, unit: 'week' }, + used: 40, + limit: 1000, }); - expect(withWindow.summary?.window).toEqual({ duration: 2, unit: 'week' }); + expect(parsed.limits).toEqual([]); }); - it('falls back to remaining=limit-used when used is absent', () => { - const parsed = parseManagedUsagePayload({ usage: { remaining: 200, limit: 1000 } }); + it('treats an unnamed summary as the weekly limit', () => { + const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }); expect(parsed.summary).toEqual({ - used: 800, - limit: 1000, + 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('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: 3, limit: 60 }, window: { duration: 7, timeUnit: 'days' } }, - { detail: { used: 4, limit: 30 }, window: { duration: 90, timeUnit: 'MINUTE' } }, + { 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.window)).toEqual([ @@ -128,67 +133,29 @@ describe('parseManagedUsagePayload', () => { ]); }); - it('derives the window from duration/timeUnit fields on the item or detail', () => { - const parsed = parseManagedUsagePayload({ - limits: [ - { duration: 5, timeUnit: 'HOURS', detail: { used: 1, limit: 100 } }, - { used: 2, limit: 50, duration: 1, timeUnit: 'Day' }, - ], - }); - expect(parsed.limits.map((l) => l.window)).toEqual([ - { duration: 5, unit: 'hour' }, - { duration: 1, unit: 'day' }, - ]); - }); - - it('passes through item names and falls back to title/scope', () => { + 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' }, - }, - { title: 'Titled', used: 1, limit: 10 }, - { scope: 'Scoped', used: 2, limit: 10 }, + { name: 'Daily cap', detail: { used: 5, limit: 100 } }, + { detail: { used: 1, limit: 10, name: 'Detail named' } }, ], }); - expect(parsed.limits.map((l) => l.name)).toEqual(['Daily cap', 'Titled', 'Scoped']); - }); - - 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' }, - }); + expect(parsed.limits.map((l) => l.name)).toEqual(['Daily cap', 'Detail named']); }); - it('omits name and window on limit rows when the payload carries neither', () => { + it('skips limit rows without a detail record', () => { const parsed = parseManagedUsagePayload({ limits: [{ used: 2, limit: 20 }], }); - expect(parsed.limits).toEqual([{ used: 2, limit: 20 }]); + expect(parsed.limits).toEqual([]); }); - it('passes through reset timestamps across spelling drift', () => { + it('passes the detail resetTime through as resetAt', () => { const at = '2030-01-01T00:00:00.000Z'; - for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) { - const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, [key]: at } }); - expect(parsed.summary?.resetAt).toBe(at); - } - }); - - it('converts reset_in / resetIn / ttl seconds to an absolute ISO timestamp', () => { - for (const key of ['reset_in', 'resetIn', 'ttl']) { - const before = Date.now(); - const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, [key]: 3600 } }); - const resetAt = Date.parse(parsed.summary?.resetAt ?? ''); - expect(Number.isFinite(resetAt)).toBe(true); - expect(resetAt).toBeGreaterThanOrEqual(before + 3600_000); - expect(resetAt).toBeLessThanOrEqual(Date.now() + 3600_000); - } + 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', () => { From 9bbc504436ffb065b671b41d6bc4d105205b742e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 28 Jul 2026 14:55:48 +0800 Subject: [PATCH 3/3] fix(node-sdk): update managed usage smoke example and facade tests for structured rows --- packages/node-sdk/examples/kimi-harness-auth-smoke.ts | 6 +++--- packages/node-sdk/test/auth-facade.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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({