Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/structured-managed-usage.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 35 additions & 6 deletions apps/kimi-code/src/tui/components/messages/usage-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`;
Comment thread
liruifengv marked this conversation as resolved.
}
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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
],
},
Expand All @@ -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');
Expand Down
107 changes: 81 additions & 26 deletions apps/kimi-code/test/tui/components/messages/usage-panel.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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', () => {
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-core-v2/src/app/auth/oauthProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof usageWindowSchema>;

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<typeof usageRowSchema>;

Expand Down
16 changes: 14 additions & 2 deletions packages/kap-server/src/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,20 @@ function toWireUsage(result: ManagedUsageDomainResult): ManagedUsageResult {
}

type ManagedUsageDomainResult = Awaited<ReturnType<IOAuthService['getManagedUsage']>>;
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,
};
}
26 changes: 22 additions & 4 deletions packages/kap-server/test/oauthUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/node-sdk/examples/kimi-harness-auth-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ function printUsage(usage: Awaited<ReturnType<KimiHarness['auth']['getManagedUsa
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 w = summary.window;
const label = w !== undefined ? `${String(w.duration)}${w.unit} window` : (summary.name ?? 'usage');
process.stdout.write(`usage: ${label} ${String(summary.used)}/${String(summary.limit)}\n`);
}

function shouldKeepToken(hasExplicitHomeDir: boolean): boolean {
Expand Down
6 changes: 3 additions & 3 deletions packages/node-sdk/test/auth-facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ max_context_size = 262144

expect(result).toMatchObject({
kind: 'ok',
summary: { label: 'Weekly limit', used: 1, limit: 10 },
summary: { name: 'Weekly limit', used: 1, limit: 10 },
});
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
const headers = new Headers((init.headers ?? {}) as Record<string, string>);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 5 additions & 3 deletions packages/oauth/examples/kimi-oauth-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/oauth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export type {
export {
fetchManagedUsage,
formatDuration,
formatResetTime,
isManagedKimiCode,
isManagedKimiCodeBaseUrl,
kimiCodeBaseUrl,
Expand All @@ -92,6 +91,7 @@ export type {
FetchManagedUsageResult,
ParsedManagedUsage,
UsageRow,
UsageWindow,
} from './managed-usage';

export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback';
Expand Down
Loading
Loading