From 6ddb7a807ff1b7ba50821bef6d3f62dbea9dfe11 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 15 Oct 2026 22:59:31 +0000 Subject: [PATCH 1/3] feat(cli): compare a usage range with the previous equal window Add `ocx usage compare` to read GET /api/usage for the named range and for the same range shifted back one equal window. Refuse --range all. --- .../src/content/docs/reference/cli/agents.md | 18 ++ scripts/test-layout/layout.json | 2 +- .../ocx/references/01_management_surface.md | 8 +- .../references/01_surface_observe-system.md | 29 +- skills/ocx/references/02_json_shapes.md | 7 + skills/ocx/references/03_recipes.md | 4 + src/cli/capabilities-base.ts | 2 +- src/cli/capabilities-observe-system.ts | 19 ++ src/cli/observe-stream.ts | 2 + src/cli/observe.ts | 8 +- src/cli/registry.ts | 5 +- src/cli/usage-compare.ts | 273 ++++++++++++++++++ structure/cli-management.md | 2 + structure/runtime.md | 2 +- ...cli-capability-operation-workflows.test.ts | 22 ++ tests/cli/cli-usage-compare.test.ts | 223 ++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 17 files changed, 619 insertions(+), 8 deletions(-) create mode 100644 src/cli/usage-compare.ts create mode 100644 tests/cli/cli-usage-compare.test.ts diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 882c7ef59db..b372e6b08d4 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -416,6 +416,7 @@ Inspect proxy requests, usage, storage, memory, and debug data. The direct alias | --- | --- | | `ocx logs [filters] [--follow] [--json|--jsonl|--events]` | `ocx observe logs` | | `ocx usage [--range ] [--since --until ] [--surface ] [--provider ] [--model ] [--api-key-id ] [--json]` | `ocx observe usage` | +| `ocx usage compare [--range ] [--json]` | `ocx observe usage compare` | | `ocx storage [--json]` | `ocx observe storage` | | `ocx memory [--json]` | `ocx observe memory` | @@ -450,6 +451,23 @@ Any displayed totals reflect readable records only. If a filter has no readable the warning and guidance instead of total lines; skipped records may contain matches. `--json` preserves the response-level `usageIncomplete` diagnostic and reason. +### Compare a range with the previous equal window + +```bash +ocx usage compare --range today --json +ocx usage compare --range 7d --provider xai +``` + +`ocx observe usage compare` is the equivalent family form. The command reads `GET /api/usage` +twice: the named range ending now, then the same range evaluated one equal window earlier +(one local day for `today`/`1d`, seven for `7d`, thirty for `30d`). Default range is `30d`. +`--range all` is refused because open history has no previous equal window. `--since` and +`--until` are not accepted; the windows are derived. Surface, provider, model, and API-key +filters apply to both reads. Human output prints current, previous, signed delta, and percent +change. `--json` is `{schemaVersion,range,current,previous,delta,change,incomplete}`. The two +reads are sequential, not an atomic snapshot. Custom `since`/`until` on each response must +match the requested window. + ### Filter a bounded log snapshot ```bash diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..c04637548fb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -22,7 +22,7 @@ "management-sidecar-anthropic-instance.test.ts": "server", "anthropic2-usage-attribution.test.ts": "usage", "vision-anthropic-instance-sidecar.test.ts": "vision", "web-search-anthropic-instance.test.ts": "web-search", "provider-anthropic-instance.test.ts": "providers", "config-anthropic-instance-pool.test.ts": "config", "oauth-anthropic-instance-registration.test.ts": "oauth", "oauth-anthropic-instance-refresh.test.ts": "oauth", "claude-picker-upload.test.ts": "claude-integration", "claude-picker-linger.test.ts": "claude-integration", "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", - "cli-log-view-filter.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-companion-usage.test.ts": "cli", "cli-account-key-quota.test.ts": "cli", + "cli-log-view-filter.test.ts": "cli", "cli-usage-compare.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-companion-usage.test.ts": "cli", "cli-account-key-quota.test.ts": "cli", "cli-observe-snapshot.test.ts": "cli", "cli-access-data-plane.test.ts": "cli", "cli-access-key-guard.test.ts": "cli", "cli-access-rename.test.ts": "cli", "cli-access-audio.test.ts": "cli", "cli-access-audio-live.test.ts": "cli", "cli-log-follow.test.ts": "cli", "cli-injection-follow.test.ts": "cli", "cli-usage-scope.test.ts": "cli", "cli-system-health.test.ts": "cli", "cli-companion-timeline.test.ts": "cli", "server-cli-key-probe-admission.test.ts": "server", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 901cb97f388..40e3d10bed6 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -30,7 +30,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [accounts](01_surface_accounts.md) | 40 | | [agents-routing](01_surface_agents-routing.md) | 51 | | [integrations](01_surface_integrations.md) | 41 | -| [observe-system](01_surface_observe-system.md) | 92 | +| [observe-system](01_surface_observe-system.md) | 93 | | [access-remote](01_surface_access-remote.md) | 32 | | [lab](01_surface_lab.md) | 21 | @@ -939,6 +939,10 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_observe-system.md#ocx-observe-usage) +### `ocx usage compare` + +[Read-oriented task](01_surface_observe-system.md#ocx-usage-compare) + ### `ocx observe storage` [Read-oriented task](01_surface_observe-system.md#ocx-observe-storage) @@ -1393,6 +1397,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 336 +- declared capabilities: 337 - of those, state-changing: 205 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 2c6f86aded2..7441ed1397e 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 92. +Declared capabilities: 93. ### `ocx companion` @@ -62,6 +62,7 @@ JSON mode: `payload`. - Alias of observe usage. Connected clients read only their enrolled-key Hub report through /v1/usage; caller --api-key-id is refused before key read/transport and never grants management authority. - Nonclient --api-key-id uses /api/usage and requires matching filter acknowledgment. An unknown acknowledged key is empty/matched:false, not404. Custom-window acknowledgment remains required. - --search filters only returned model rows after scope acknowledgment. Blank search selects the top100 model rows; modelView reports match/return counts without recomputing totals. +- Nested compare reads the named range and the previous equal window; --range all is refused. ### `ocx logs` @@ -654,6 +655,32 @@ JSON mode: `payload`. - Nonclient --api-key-id uses /api/usage and requires matching filter acknowledgment. An unknown acknowledged key is empty/matched:false, not404. Custom-window acknowledgment remains required. - --search filters only returned model rows after scope acknowledgment. Blank search selects the top100 model rows; modelView reports match/return counts without recomputing totals. +### `ocx usage compare` + +Usage: `ocx usage compare [--range ] [--surface ] [--provider ] [--model ] [--api-key-id ] [--json]` + +Compare a usage range with the previous equal window. + +State-changing: no. + +| Method | Route | +|---|---| +| GET | `/api/usage` | + +| Flag | Value | Meaning | +|---|---|---| +| `--range` | string | today \| 1d \| 7d \| 30d; default 30d. all is refused. | +| `--surface` | string | all \| codex \| claude \| grok; default all. | +| `--provider` | string | Filter provider. | +| `--model` | string | Filter model. | +| `--api-key-id` | string | Exact nonblank management key scope; connected clients refuse this. | +| `--json` | boolean | Emit current, previous, delta and change percentages. | + +JSON mode: `envelope`. + +- Two sequential GET /api/usage reads: the named range at now, then the same range shifted back one equal window. Custom since/until must be acknowledged on both. --range all is refused. +- Also accepted after observe usage. Default range is 30d. This is not an atomic snapshot. + ### `ocx observe storage` Usage: `ocx observe storage [--limit ] [--json]` diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index 0a36442ff36..a8cd0a9063c 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -86,6 +86,13 @@ Two honesty markers to respect: - Under `--provider` or `--model`, per-account rows are **withheld** rather than filtered, because account totals cannot be honestly re-partitioned by provider. +## `ocx usage compare --json` + +CLI envelope `schemaVersion: 1` with `range`, `days`, `filter`, then `current` and `previous` +(`since`, `until`, `summary`, `usageIncomplete`), plus `delta`, `change` (percent, or `null` when +the previous value is zero and the current value is not), and `incomplete`. Totals are request, +token and estimated-cost fields only; use `ocx usage --json` for provider/model/account rows. + ## `ocx account list --json` `accounts[]` with `id`, `email`, `plan`, `paused`, `selected`, `priority`, and `needsReauth`. Quota diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 7b0c7b84b0c..eb565a634a3 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -88,8 +88,12 @@ actually served it, not only the one requested. ```bash ocx usage --range 7d --json +ocx usage compare --range 7d --json ``` +`ocx usage compare` reads the named range and the previous equal window. `--range all` is refused. +`--json` carries `delta` and `change` (percent). The two reads are sequential. + Read `accounts[]`. Two things to respect: - A row with `ambiguous: true` (label `legacy-ambiguous`) aggregates several accounts from before diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 4a374354b43..46287654a8b 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -326,7 +326,7 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [{"name":"--range","value":"string","summary":"today | 1d | 7d | 30d | all; default 30d."},{"name":"--surface","value":"string","summary":"all | codex | claude | grok; default all."},{"name":"--since","value":"string","summary":"Inclusive epoch milliseconds or ISO datetime with timezone; requires --until."},{"name":"--until","value":"string","summary":"Inclusive end; requires --since. Custom bounds override --range."},{"name":"--provider","value":"string","summary":"Filter provider."},{"name":"--model","value":"string","summary":"Filter model."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."},{"name":"--api-key-id","value":"string","summary":"Exact nonblank management key scope; connected clients refuse this before reading their enrolled key."},{"name":"--search","value":"string","summary":"Model-row substring view over model/provider/resolvedModel; top100 by tokens. Whole-report totals stay unchanged."}], mutates: false, json: "payload", - details: ["Alias of observe usage. Connected clients read only their enrolled-key Hub report through /v1/usage; caller --api-key-id is refused before key read/transport and never grants management authority.","Nonclient --api-key-id uses /api/usage and requires matching filter acknowledgment. An unknown acknowledged key is empty/matched:false, not404. Custom-window acknowledgment remains required.","--search filters only returned model rows after scope acknowledgment. Blank search selects the top100 model rows; modelView reports match/return counts without recomputing totals."], + details: ["Alias of observe usage. Connected clients read only their enrolled-key Hub report through /v1/usage; caller --api-key-id is refused before key read/transport and never grants management authority.","Nonclient --api-key-id uses /api/usage and requires matching filter acknowledgment. An unknown acknowledged key is empty/matched:false, not404. Custom-window acknowledgment remains required.","--search filters only returned model rows after scope acknowledgment. Blank search selects the top100 model rows; modelView reports match/return counts without recomputing totals.","Nested compare reads the named range and the previous equal window; --range all is refused."], usage: "ocx usage [--range ] [--surface ] [--since ] [--until ] [--provider ] [--model ] [--api-key-id ] [--search ] [--json]", }, { diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 4f2027a2d25..6498d1bb94f 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -50,6 +50,25 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["Also available as ocx usage. Connected clients read only their enrolled-key Hub report through /v1/usage; caller --api-key-id is refused before key read/transport and never grants management authority.","Nonclient --api-key-id uses /api/usage and requires matching filter acknowledgment. An unknown acknowledged key is empty/matched:false, not404. Custom-window acknowledgment remains required.","--search filters only returned model rows after scope acknowledgment. Blank search selects the top100 model rows; modelView reports match/return counts without recomputing totals."], }, + { + command: ["usage", "compare"], + summary: "Compare a usage range with the previous equal window.", + usage: "ocx usage compare [--range ] [--surface ] [--provider ] [--model ] [--api-key-id ] [--json]", + routes: [{"method": "GET", "path": "/api/usage"}], + flags: [ + {"name": "--range", "value": "string", "summary": "today | 1d | 7d | 30d; default 30d. all is refused."}, + {"name": "--surface", "value": "string", "summary": "all | codex | claude | grok; default all."}, + {"name": "--provider", "value": "string", "summary": "Filter provider."}, + {"name": "--model", "value": "string", "summary": "Filter model."}, + {"name": "--api-key-id", "value": "string", "summary": "Exact nonblank management key scope; connected clients refuse this."}, + {"name": "--json", "value": "boolean", "summary": "Emit current, previous, delta and change percentages."}, + ], + mutates: false, json: "envelope", + details: [ + "Two sequential GET /api/usage reads: the named range at now, then the same range shifted back one equal window. Custom since/until must be acknowledged on both. --range all is refused.", + "Also accepted after observe usage. Default range is 30d. This is not an atomic snapshot.", + ], + }, { command: ["observe", "storage"], summary: "Read storage diagnostics.", usage: "ocx observe storage [--limit ] [--json]", diff --git a/src/cli/observe-stream.ts b/src/cli/observe-stream.ts index bd32fbeee98..21890cba0ea 100644 --- a/src/cli/observe-stream.ts +++ b/src/cli/observe-stream.ts @@ -14,6 +14,8 @@ export interface ObserveStreamDeps extends RuntimeApiDeps { /** Reduced timings for isolated tests only; never command-line options. */ pollIntervalMs?: number; requestTimeoutMs?: number; + /** Frozen clock for usage-compare tests; never a command-line option. */ + now?: () => number; } export class ObservationError extends Error {} diff --git a/src/cli/observe.ts b/src/cli/observe.ts index 4cdc7727a41..209e414818d 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -17,6 +17,7 @@ import { handleLogFilterCommand } from "./log-view-filter"; import { followInjection } from "./injection-follow"; import type { ObserveStreamDeps } from "./observe-stream"; import { formatUsageReport } from "./usage-report"; +import { handleUsageCompareCommand } from "./usage-compare"; import { selectUsageModelView, takeUsageSearchOption } from "./usage-model-search"; import { USAGE_RANGES, USAGE_SURFACES, type UsageSummary, type UsageFilterEcho } from "../usage/summary"; import { parseUsageTimeWindow, type UsageTimeWindow } from "../usage/time-range"; @@ -36,6 +37,8 @@ const USAGE = `Usage: ocx observe usage [--range ] [--surface ] [--since ] [--until ] [--provider ] [--model ] [--api-key-id ] [--search ] [--json] + ocx usage compare [--range ] [--surface ] + [--provider ] [--model ] [--api-key-id ] [--json] ocx observe storage [codex-logs [status|protect|unprotect|repair|compact] [--mode ]] [--json] ocx observe memory [--json] ocx observe debug [--json] @@ -325,7 +328,10 @@ export async function handleObserveCommand(argv: string[], deps: ObserveStreamDe else if (action === "index-status") await indexStatus(rest.slice(1), deps); else streamExit = await logs(rest, deps); } - else if (sub === "usage") await usage(rest, deps); + else if (sub === "usage") { + if (rest[0] === "compare") await handleUsageCompareCommand(rest.slice(1), deps); + else await usage(rest, deps); + } else if (sub === "storage") await storage(rest, deps); else if (sub === "memory") await simple("/api/system/memory", rest, deps); else if (sub === "debug") await simple("/api/debug", rest, deps); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f31b42cbda5..ccc596636d0 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -447,8 +447,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "logs", usage: "ocx logs [filters] [--follow] [--json|--jsonl|--events]", summary: "Read or follow request logs." }, { name: "usage", - usage: "ocx usage [--range ] [--surface ] [--provider ] [--model ] [--api-key-id ] [--search ] [--json]", + usage: "ocx usage [--range ] [--surface ] [--provider ] [--model ] [--api-key-id ] [--search ] [--json]; ocx usage compare [--range ] [--json]", summary: "Report token usage and estimated cost (alias of ocx observe usage).", + details: [ + "`compare` reads the current named range and the previous equal window; `--range all` is refused.", + ], }, { name: "storage", diff --git a/src/cli/usage-compare.ts b/src/cli/usage-compare.ts new file mode 100644 index 00000000000..9b8060c6327 --- /dev/null +++ b/src/cli/usage-compare.ts @@ -0,0 +1,273 @@ +/** + * Compare a bounded usage range with the previous equal window. + * + * Two sequential GET /api/usage reads with acknowledged custom bounds. + * `--range all` is refused: there is no previous equal window for open history. + */ +import { readClientConnectionState, sameClientConnectionOwner } from "../client/state"; +import { fetchHubUsage } from "../client/hub-client"; +import { readServiceApiTokenState } from "../lib/service-secrets"; +import { redactSecretString } from "../lib/redact"; +import { rangeWindow, USAGE_SURFACES, type UsageFilterEcho, type UsageSummary } from "../usage/summary"; +import type { UsageTimeWindow } from "../usage/time-range"; +import { + CliUsageError, + printData, + rejectArgs, + RuntimeApiError, + runtimeRequest, + takeFlag, + takeOption, + takeOptionWithSyntax, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const USAGE_COMPARE_USAGE = `Usage: + ocx usage compare [--range ] [--surface ] + [--provider ] [--model ] [--api-key-id ] [--json]`; + +export type CompareRange = "today" | "7d" | "30d"; +export type UsageCompareDeps = RuntimeApiDeps & { now?: () => number }; + +const METRICS = ["requests", "totalTokens", "inputTokens", "outputTokens", "cachedInputTokens", "estimatedCostUsd"] as const; +type Metric = typeof METRICS[number]; +type Totals = Partial>; +type ScopedUsage = (UsageSummary | { summary?: Totals; customWindow?: boolean; since?: number | null; until?: number; usageIncomplete?: boolean }) + & { filter?: UsageFilterEcho }; + +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) search.set(key, String(value)); + } + const encoded = search.toString(); + return encoded ? `?${encoded}` : ""; +} + +function parseCompareRange(raw: string): CompareRange { + if (raw === "all") throw new CliUsageError("--range all has no previous equal window; use today, 1d, 7d or 30d", USAGE_COMPARE_USAGE); + if (raw === "today" || raw === "1d") return "today"; + if (raw === "7d" || raw === "30d") return raw; + throw new CliUsageError("--range must be one of today, 1d, 7d, 30d", USAGE_COMPARE_USAGE); +} + +/** Named range at `now`, then the same range evaluated one equal window earlier. */ +export function compareUsageWindows(range: CompareRange, now: number): { + current: UsageTimeWindow; + previous: UsageTimeWindow; + days: number; +} { + const days = range === "today" ? 1 : range === "7d" ? 7 : 30; + const { since } = rangeWindow(range, now); + if (since === null) throw new Error("compare requires a bounded usage range"); + const prior = new Date(now); + prior.setDate(prior.getDate() - days); + const priorNow = prior.getTime(); + const { since: previousSince } = rangeWindow(range, priorNow); + if (previousSince === null) throw new Error("compare requires a bounded usage range"); + return { + current: Object.freeze({ since, until: now }), + previous: Object.freeze({ since: previousSince, until: priorNow }), + days, + }; +} + +function metric(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function totalsOf(report: ScopedUsage): Totals { + const row = report.summary ?? {}; + const totals: Totals = {}; + for (const key of METRICS) { + const value = metric((row as Totals)[key]); + if (value !== undefined) totals[key] = value; + } + return totals; +} + +function delta(current?: number, previous?: number): number | undefined { + if (current === undefined || previous === undefined) return undefined; + return current - previous; +} + +function changePct(current?: number, previous?: number): number | null | undefined { + if (current === undefined || previous === undefined) return undefined; + if (previous === 0) return current === 0 ? 0 : null; + return (current - previous) / previous * 100; +} + +function count(value: number | undefined): string { + return value === undefined ? "—" : value.toLocaleString("en-US"); +} + +function usd(value: number | undefined): string { + return value === undefined ? "—" : `~$${value.toFixed(4)}`; +} + +function signed(value: number | undefined, money: boolean): string { + if (value === undefined) return "—"; + const abs = money ? usd(Math.abs(value)) : count(Math.abs(value)); + if (value === 0) return money ? usd(0) : "0"; + return `${value > 0 ? "+" : "-"}${abs}`; +} + +function pct(value: number | null | undefined): string { + if (value === undefined) return "—"; + if (value === null) return "n/a"; + const abs = Math.abs(value).toFixed(1); + if (value === 0) return "0.0%"; + return `${value > 0 ? "+" : "-"}${abs}%`; +} + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +function formatCompare(input: { + range: CompareRange; + days: number; + currentWindow: UsageTimeWindow; + previousWindow: UsageTimeWindow; + current: Totals; + previous: Totals; + incomplete: boolean; +}): string[] { + const lines = [ + `Usage compare — ${input.range} vs previous ${input.days} local day${input.days === 1 ? "" : "s"} (equal window)`, + `Current ${iso(input.currentWindow.since)} to ${iso(input.currentWindow.until)}`, + `Previous ${iso(input.previousWindow.since)} to ${iso(input.previousWindow.until)}`, + "", + ]; + if (input.incomplete) { + lines.push("WARNING: Usage is incomplete; some records could not be included. Totals reflect readable records only.", ""); + } + const labels: Record = { + requests: "Requests", + totalTokens: "Tokens", + inputTokens: "Input", + outputTokens: "Output", + cachedInputTokens: "Cached", + estimatedCostUsd: "Est. cost", + }; + const rows = METRICS.map(key => { + const money = key === "estimatedCostUsd"; + const show = money ? usd : count; + return [ + labels[key], + show(input.current[key]), + show(input.previous[key]), + signed(delta(input.current[key], input.previous[key]), money), + pct(changePct(input.current[key], input.previous[key])), + ]; + }); + const header = ["METRIC", "CURRENT", "PREVIOUS", "DELTA", "CHANGE"]; + const widths = header.map((h, i) => Math.max(h.length, ...rows.map(row => row[i]!.length))); + const line = (cols: string[]) => cols.map((c, i) => c.padEnd(widths[i]!)).join(" ").trimEnd(); + lines.push(line(header), ...rows.map(line), "", "Not a billing receipt. Subscription usage or provider credits may apply instead."); + return lines; +} + +async function readUsage( + suffix: string, + window: UsageTimeWindow, + apiKeyId: string | undefined, + deps: RuntimeApiDeps, +): Promise { + const connection = readClientConnectionState(); + if (connection.kind === "invalid" || connection.kind === "mismatched") { + throw new Error(`Client usage unavailable: ${connection.reason}`); + } + let result: ScopedUsage; + if (connection.kind === "connected") { + if (apiKeyId !== undefined) { + throw new CliUsageError("--api-key-id is unavailable on connected clients; run on the hub for a selected key, or omit it for this client's usage", USAGE_COMPARE_USAGE); + } + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== connection.value.tokenFingerprint) { + throw new Error("Client usage unavailable: the enrolled data key is missing or changed; repair the client connection"); + } + const link = connection.value.transport === "link" && connection.value.link; + const peerFetch = deps.fetchImpl + ?? (link ? (await import("../client/link-home")).linkBoundFetch(connection.value, token.token) : undefined); + result = await fetchHubUsage(connection.value.serverUrl, token.token, new URLSearchParams(suffix.replace(/^\?/, "")), { + fetchImpl: peerFetch, timeoutMs: 60_000, ...(link ? { peer: "Home" as const } : {}), + }); + const current = readClientConnectionState(); + const currentToken = readServiceApiTokenState(); + if (current.kind !== "connected" || !sameClientConnectionOwner(current.value, connection.value) + || current.value.tokenFingerprint !== token.fingerprint + || currentToken.kind !== "present" || currentToken.fingerprint !== token.fingerprint) { + throw new Error("Client connection changed while reading usage; retry for the current connection"); + } + } else if (apiKeyId === undefined) { + result = await runtimeRequest(`/api/usage${suffix}`, {}, deps); + } else { + try { + result = await runtimeRequest(`/api/usage${suffix}`, { redirect: "error", credentials: "omit" }, deps); + } catch (error) { + if (error instanceof RuntimeApiError && error.code === "proxy_not_running") throw error; + throw new Error("Key-scoped usage could not be read. Check runtime access and retry."); + } + if (result?.filter?.apiKeyId !== apiKeyId) { + throw new Error("The server did not confirm the requested API key scope. Upgrade and restart the proxy, then retry."); + } + } + if (result?.customWindow !== true || result.since !== window.since || result.until !== window.until) { + throw new Error("The server did not confirm the requested custom usage window. Upgrade and restart the proxy, then retry."); + } + return result; +} + +/** Compare the current named range with the previous equal window through two /api/usage reads. */ +export async function handleUsageCompareCommand(argv: string[], deps: UsageCompareDeps = {}): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const range = parseCompareRange(takeOption(args, "--range") ?? "30d"); + const surface = takeOption(args, "--surface") ?? "all"; + const provider = takeOption(args, "--provider"); + const model = takeOption(args, "--model"); + const rawKeyId = takeOptionWithSyntax(args, "--api-key-id")?.value; + const apiKeyId = rawKeyId?.trim(); + if (apiKeyId !== undefined && !apiKeyId) throw new CliUsageError("--api-key-id must not be blank", USAGE_COMPARE_USAGE); + if (takeOption(args, "--since") !== undefined || takeOption(args, "--until") !== undefined) { + throw new CliUsageError("compare derives both windows; do not pass --since or --until", USAGE_COMPARE_USAGE); + } + if (!USAGE_SURFACES.includes(surface as (typeof USAGE_SURFACES)[number])) { + throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE_COMPARE_USAGE); + } + rejectArgs(args.map(redactSecretString), USAGE_COMPARE_USAGE); + const now = deps.now?.() ?? Date.now(); + if (!Number.isSafeInteger(now) || now < 0) throw new CliUsageError("compare clock is invalid", USAGE_COMPARE_USAGE); + const windows = compareUsageWindows(range, now); + const filters = { range, surface, provider, model, apiKeyId }; + const currentReport = await readUsage(query({ ...filters, since: windows.current.since, until: windows.current.until }), windows.current, apiKeyId, deps); + const previousReport = await readUsage(query({ ...filters, since: windows.previous.since, until: windows.previous.until }), windows.previous, apiKeyId, deps); + const current = totalsOf(currentReport); + const previous = totalsOf(previousReport); + const deltaTotals: Totals = {}; + const change: Partial> = {}; + for (const key of METRICS) { + const d = delta(current[key], previous[key]); + if (d !== undefined) deltaTotals[key] = d; + const pctValue = changePct(current[key], previous[key]); + if (pctValue !== undefined) change[key] = pctValue; + } + const incomplete = currentReport.usageIncomplete === true || previousReport.usageIncomplete === true; + const result = { + schemaVersion: 1, + range, + surface, + days: windows.days, + filter: { provider: provider ?? null, model: model ?? null, apiKeyId: apiKeyId ?? null }, + current: { since: windows.current.since, until: windows.current.until, summary: current, usageIncomplete: currentReport.usageIncomplete === true }, + previous: { since: windows.previous.since, until: windows.previous.until, summary: previous, usageIncomplete: previousReport.usageIncomplete === true }, + delta: deltaTotals, + change, + incomplete, + }; + printData(result, wantsJson, wantsJson ? undefined : formatCompare({ + range, days: windows.days, currentWindow: windows.current, previousWindow: windows.previous, + current, previous, incomplete, + })); +} diff --git a/structure/cli-management.md b/structure/cli-management.md index ec0a2af8f04..800172769ed 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -108,6 +108,8 @@ Storage policy flags in `src/cli/storage.ts` preserve omitted enable/settings an `src/cli/log-view-filter.ts` owns the additive logs-filter snapshot, separate from existing follow/event transport. It fetches only the fixed log endpoint with a scan limit of at most 2000, applies GUI-equivalent local selectors, then returns the newest matches in source order without deduplicating. Model/provider matching covers resolved/served/attempt identities; protocol and conversation matching reuse their actual owners. Speed bounds accept observed value-kind metrics only. The versioned JSON view separates loaded, matched and returned counts from configured scan/output limits; JSONL is rows only. Neither a match count nor a cursor claims historical completeness or resumable search. Invalid options fail before discovery; malformed windows never become empty success. +`src/cli/usage-compare.ts` reads `GET /api/usage` twice: the named range at now, then the same range shifted back one equal window. `--range all` is refused. Both responses must acknowledge the derived custom bounds. Key-scoped reads keep redirect-error transport and echoed `apiKeyId`. Connected clients reuse the existing hub usage path and refuse caller `--api-key-id`. Default range is 30d. The two reads are sequential, not an atomic snapshot. + `src/cli/usage-model-search.ts` applies optional substring search only after the existing usage target/scope acknowledgment. A copied model list sorts stably by total tokens, searches model/provider/resolvedModel and caps at 100. The report's other fields remain intact; explicit modelView metadata distinguishes a view miss from no usage. `src/cli/usage-report.ts` retains the legacy renderer when no view is supplied and renders up to the selected 100 rows when one is supplied. Search never becomes a server attribution query or widens connected-client/key scope. `src/cli/companion-usage.ts` reads settings and two usage ranges sequentially on a pinned management target through `src/cli/observe-stream.ts`. Its safe projection follows the saved model/provider filters and measured-totals rules in `gui/src/pages/tray-data.ts` without importing GUI runtime code. It retains unknown/incomplete metrics, valid server defaults with null update time, and explicit corrupt/fallback flags. Malformed settings stop before usage reads. Range failure retains the other observed range, labels unavailable data, and returns partial/nonzero; signal cancellation emits no late report. The observation owner supplies a separate 10-second fetch/body deadline after discovery and 32 MiB cap for each fixed GET, not a command-wide budget. Snapshot context supplies truthful retry wording without altering default follow behavior. diff --git a/structure/runtime.md b/structure/runtime.md index 2a31215cb52..452af4b3cd7 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -384,7 +384,7 @@ Usage consumers preserve positive incomplete-history metadata as specified in as a complete ledger. The same contract owns `src/usage/log.ts` append-path permission rechecks and their bounded cache. -Connected `ocx usage` reads `/v1/usage` through `src/client/hub-client.ts`, using its enrolled data key and checking connection/token ownership before and after the read. It reports hub/client scope and never substitutes local totals on failure. Standalone commands retain their management endpoint. +Connected `ocx usage` and `ocx usage compare` read `/v1/usage` through `src/client/hub-client.ts`, using the enrolled data key and checking connection/token ownership before and after each read. They report hub/client scope and never substitute local totals on failure. Standalone commands retain their management endpoint. The client usage read requires HTTPS or loopback HTTP before adding the enrolled credential, and sets request `cache: "no-store"`; the hub response also forbids caching. diff --git a/tests/cli/cli-capability-operation-workflows.test.ts b/tests/cli/cli-capability-operation-workflows.test.ts index 04359da9d1f..7d8a7eb5dcf 100644 --- a/tests/cli/cli-capability-operation-workflows.test.ts +++ b/tests/cli/cli-capability-operation-workflows.test.ts @@ -86,6 +86,28 @@ describe("operation capability workflows use existing handlers", () => { expect(await handleObserveCommand(["usage", "--range", "7d", "--surface", "claude", "--since", "1000", "--until", "2000", "--json"], io.deps)).toBe(0); expect(io.requests).toEqual([{ method: "GET", path: "/api/usage?range=7d&surface=claude&since=1000&until=2000", body: null }]); }); + test("usage compare reads current then previous equal windows", async () => { + expect(capability("usage compare").routes).toEqual([{ method: "GET", path: "/api/usage" }]); + const requests: string[] = []; + const deps = { + baseUrl: "http://capability.invalid", + now: () => Date.UTC(2026, 9, 15, 15, 30, 0), + fetchImpl: async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + requests.push(url.pathname + url.search); + return Response.json({ + customWindow: true, + since: Number(url.searchParams.get("since")), + until: Number(url.searchParams.get("until")), + summary: { requests: 1, totalTokens: 1, estimatedCostUsd: 1 }, + }); + }, + }; + expect(await handleObserveCommand(["usage", "compare", "--range", "today", "--json"], deps)).toBe(0); + expect(requests).toHaveLength(2); + expect(requests[0]!.startsWith("/api/usage?range=today&")).toBe(true); + expect(requests[1]!.startsWith("/api/usage?range=today&")).toBe(true); + }); test("system settings distinguishes read from submitted field writes", async () => { const leaf = capability("system settings"); expect(leaf.mutates).toBe(true); diff --git a/tests/cli/cli-usage-compare.test.ts b/tests/cli/cli-usage-compare.test.ts new file mode 100644 index 00000000000..f008c6db181 --- /dev/null +++ b/tests/cli/cli-usage-compare.test.ts @@ -0,0 +1,223 @@ +/** + * `ocx usage compare` reads the named range, then the previous equal window. + */ +import { describe, expect, test } from "bun:test"; +import { handleObserveCommand } from "../../src/cli/observe"; +import { compareUsageWindows } from "../../src/cli/usage-compare"; + +const NOW = Date.UTC(2026, 9, 15, 15, 30, 0); + +function report(url: URL, requests: number): Record { + return { + customWindow: true, + since: Number(url.searchParams.get("since")), + until: Number(url.searchParams.get("until")), + summary: { + requests, + totalTokens: requests * 10, + inputTokens: requests * 6, + outputTokens: requests * 4, + cachedInputTokens: requests, + estimatedCostUsd: requests * 0.01, + }, + }; +} + +async function run( + argv: string[], + status = 200, +): Promise<{ code: number; out: string; err: string; urls: URL[]; inits: RequestInit[] }> { + const urls: URL[] = []; + const inits: RequestInit[] = []; + let n = 0; + const fetchImpl: typeof fetch = async (input, init) => { + const url = new URL(String(input)); + urls.push(url); + inits.push(init ?? {}); + n += 1; + return new Response(JSON.stringify(status === 200 ? report(url, n === 1 ? 20 : 10) : { error: "not found" }), { + status, + headers: { "content-type": "application/json" }, + }); + }; + const lines: string[] = []; + const errors: string[] = []; + const originalLog = console.log; + const originalErr = console.error; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; + try { + const code = await handleObserveCommand(argv, { baseUrl: "http://cli.test", fetchImpl, now: () => NOW }); + return { code, out: lines.join("\n"), err: errors.join("\n"), urls, inits }; + } finally { + console.log = originalLog; + console.error = originalErr; + } +} + +describe("compareUsageWindows", () => { + test("today shifts one local day and keeps equal elapsed length", () => { + const windows = compareUsageWindows("today", NOW); + expect(windows.days).toBe(1); + expect(windows.current.until).toBe(NOW); + expect(windows.previous.until).toBeLessThan(windows.current.since); + expect(windows.previous.until - windows.previous.since).toBe(windows.current.until - windows.current.since); + }); + + test("7d and 30d shift by that many local days", () => { + expect(compareUsageWindows("7d", NOW).days).toBe(7); + expect(compareUsageWindows("30d", NOW).days).toBe(30); + const week = compareUsageWindows("7d", NOW); + expect(week.previous.until).toBeLessThan(week.current.since); + }); +}); + +describe("ocx usage compare", () => { + test("reads current then previous equal windows and prints signed deltas", async () => { + const expected = compareUsageWindows("today", NOW); + const result = await run(["usage", "compare", "--range", "today", "--surface", "claude", "--provider", "xai", "--model", "grok"]); + expect(result.code).toBe(0); + expect(result.urls).toHaveLength(2); + expect(result.urls[0]!.pathname).toBe("/api/usage"); + expect(result.urls[0]!.searchParams.get("range")).toBe("today"); + expect(result.urls[0]!.searchParams.get("surface")).toBe("claude"); + expect(result.urls[0]!.searchParams.get("provider")).toBe("xai"); + expect(result.urls[0]!.searchParams.get("model")).toBe("grok"); + expect(result.urls[0]!.searchParams.get("since")).toBe(String(expected.current.since)); + expect(result.urls[0]!.searchParams.get("until")).toBe(String(expected.current.until)); + expect(result.urls[1]!.searchParams.get("since")).toBe(String(expected.previous.since)); + expect(result.urls[1]!.searchParams.get("until")).toBe(String(expected.previous.until)); + expect(result.out).toContain("Usage compare — today vs previous 1 local day"); + expect(result.out).toContain("Requests"); + expect(result.out).toContain("+10"); + expect(result.out).toContain("+100.0%"); + expect(result.out).toContain("~$0.2000"); + expect(result.out).toContain("~$0.1000"); + }); + + test("JSON carries delta, percent change, and 1d as today", async () => { + const expected = compareUsageWindows("today", NOW); + const result = await run(["observe", "usage", "compare", "--range", "1d", "--json"]); + expect(result.code).toBe(0); + const body = JSON.parse(result.out) as { + schemaVersion: number; + range: string; + days: number; + current: { since: number; summary: { requests: number } }; + previous: { since: number; summary: { requests: number } }; + delta: { requests: number }; + change: { requests: number }; + }; + expect(body.schemaVersion).toBe(1); + expect(body.range).toBe("today"); + expect(body.days).toBe(1); + expect(body.current.since).toBe(expected.current.since); + expect(body.previous.since).toBe(expected.previous.since); + expect(body.current.summary.requests).toBe(20); + expect(body.previous.summary.requests).toBe(10); + expect(body.delta.requests).toBe(10); + expect(body.change.requests).toBe(100); + }); + + test("default range is 30d and a zero previous window reports n/a change", async () => { + const expected = compareUsageWindows("30d", NOW); + const urls: URL[] = []; + let n = 0; + const fetchImpl: typeof fetch = async input => { + const url = new URL(String(input)); + urls.push(url); + n += 1; + const requests = n === 1 ? 5 : 0; + return Response.json({ + customWindow: true, + since: Number(url.searchParams.get("since")), + until: Number(url.searchParams.get("until")), + summary: { requests, totalTokens: requests, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostUsd: 0 }, + }); + }; + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + try { + expect(await handleObserveCommand(["usage", "compare"], { baseUrl: "http://cli.test", fetchImpl, now: () => NOW })).toBe(0); + } finally { + console.log = original; + } + expect(urls[0]!.searchParams.get("range")).toBe("30d"); + expect(urls[0]!.searchParams.get("since")).toBe(String(expected.current.since)); + expect(lines.join("\n")).toContain("n/a"); + }); + + test("rejects all, unknown ranges, custom bounds, and inverted surfaces before transport", async () => { + expect((await run(["usage", "compare", "--range", "all"])).code).toBe(2); + expect((await run(["usage", "compare", "--range", "all"])).urls).toHaveLength(0); + expect((await run(["usage", "compare", "--range", "year"])).code).toBe(2); + expect((await run(["usage", "compare", "--since", "1", "--until", "2"])).code).toBe(2); + expect((await run(["usage", "compare", "--surface", "web"])).code).toBe(2); + expect((await run(["usage", "compare", "--range", "all"])).err).toContain("no previous equal window"); + }); + + test("unacknowledged custom windows and key-scope mismatch fail closed", async () => { + const fetchImpl: typeof fetch = async () => Response.json({ summary: { requests: 1 } }); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; + try { + expect(await handleObserveCommand(["usage", "compare", "--range", "today", "--json"], { + baseUrl: "http://cli.test", fetchImpl, now: () => NOW, + })).toBe(1); + expect(errors.join("\n")).toContain("did not confirm the requested custom usage window"); + } finally { + console.error = original; + } + + const scoped: typeof fetch = async () => Response.json({ + customWindow: true, since: 1, until: 2, filter: { apiKeyId: "other" }, summary: { requests: 1 }, + }); + errors.length = 0; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; + try { + expect(await handleObserveCommand(["usage", "compare", "--api-key-id", "Key-A", "--json"], { + baseUrl: "http://cli.test", fetchImpl: scoped, now: () => NOW, + })).toBe(1); + expect(errors.join("\n")).toContain("did not confirm the requested API key scope"); + } finally { + console.error = original; + } + }); + + test("key scope uses redirect-error transport and echoes the id", async () => { + const expected = compareUsageWindows("7d", NOW); + const urls: URL[] = []; + const inits: RequestInit[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = new URL(String(input)); + urls.push(url); + inits.push(init ?? {}); + return Response.json({ + customWindow: true, + since: Number(url.searchParams.get("since")), + until: Number(url.searchParams.get("until")), + filter: { apiKeyId: "Key-A", provider: null, model: null, matched: true, comboOverlap: false }, + summary: { requests: 3, totalTokens: 9, inputTokens: 1, outputTokens: 1, cachedInputTokens: 0, estimatedCostUsd: 0.1 }, + }); + }; + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + try { + expect(await handleObserveCommand(["usage", "compare", "--range", "7d", "--api-key-id", "Key-A", "--json"], { + baseUrl: "http://cli.test", fetchImpl, now: () => NOW, + })).toBe(0); + } finally { + console.log = original; + } + expect(urls).toHaveLength(2); + expect(urls[0]!.searchParams.get("apiKeyId")).toBe("Key-A"); + expect(urls[0]!.searchParams.get("range")).toBe("7d"); + expect(urls[0]!.searchParams.get("since")).toBe(String(expected.current.since)); + expect(inits[0]!.redirect).toBe("error"); + expect(inits[0]!.credentials).toBe("omit"); + expect(JSON.parse(lines.join("\n")).filter.apiKeyId).toBe("Key-A"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..8028837b78a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -36,6 +36,7 @@ "claude-picker-upload.test.ts": "claude-integration", "claude-picker-linger.test.ts": "claude-integration", "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", "cli-log-view-filter.test.ts": "cli", + "cli-usage-compare.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-companion-usage.test.ts": "cli", "cli-account-key-quota.test.ts": "cli", From 09ee9b8d08235f30e9136cba3a86e2daf02d80f2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 15 Oct 2026 22:59:31 +0000 Subject: [PATCH 2/3] test(cli): include usage compare in route discovery and handler argv --- tests/cli/cli-capabilities.test.ts | 2 +- tests/cli/cli-usage-compare.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index 6ad31c359e6..b85ba586a98 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -142,7 +142,7 @@ describe("ocx capabilities output", () => { try { code = await runCapabilities(["--json", "--route", "/api/usage"]); } finally { cap.restore(); } expect(code).toBe(0); const parsed = JSON.parse(cap.lines.join("\n")) as { capabilities: { invocation: string }[] }; - expect(parsed.capabilities.map(c => c.invocation)).toEqual(["ocx usage", "ocx models order set", "ocx combo stats", "ocx companion usage", "ocx observe usage"]); + expect(parsed.capabilities.map(c => c.invocation)).toEqual(["ocx usage", "ocx models order set", "ocx combo stats", "ocx companion usage", "ocx observe usage", "ocx usage compare"]); }); test("an unmatched route exits non-zero instead of reporting empty success", async () => { diff --git a/tests/cli/cli-usage-compare.test.ts b/tests/cli/cli-usage-compare.test.ts index f008c6db181..b5123a0dd30 100644 --- a/tests/cli/cli-usage-compare.test.ts +++ b/tests/cli/cli-usage-compare.test.ts @@ -97,7 +97,7 @@ describe("ocx usage compare", () => { test("JSON carries delta, percent change, and 1d as today", async () => { const expected = compareUsageWindows("today", NOW); - const result = await run(["observe", "usage", "compare", "--range", "1d", "--json"]); + const result = await run(["usage", "compare", "--range", "1d", "--json"]); expect(result.code).toBe(0); const body = JSON.parse(result.out) as { schemaVersion: number; From 3f0aa18f3586da61f906041287b4d00d0bca741d Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 15 Oct 2026 22:59:31 +0000 Subject: [PATCH 3/3] fix(cli): type usage-compare reports without the full summary shape Hub and management payloads both supply totals and window fields; they do not share UsageSummary, so the compare reader keeps a local projection. --- src/cli/usage-compare.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/cli/usage-compare.ts b/src/cli/usage-compare.ts index 9b8060c6327..6a081a2186d 100644 --- a/src/cli/usage-compare.ts +++ b/src/cli/usage-compare.ts @@ -8,7 +8,7 @@ import { readClientConnectionState, sameClientConnectionOwner } from "../client/ import { fetchHubUsage } from "../client/hub-client"; import { readServiceApiTokenState } from "../lib/service-secrets"; import { redactSecretString } from "../lib/redact"; -import { rangeWindow, USAGE_SURFACES, type UsageFilterEcho, type UsageSummary } from "../usage/summary"; +import { rangeWindow, USAGE_SURFACES } from "../usage/summary"; import type { UsageTimeWindow } from "../usage/time-range"; import { CliUsageError, @@ -32,8 +32,14 @@ export type UsageCompareDeps = RuntimeApiDeps & { now?: () => number }; const METRICS = ["requests", "totalTokens", "inputTokens", "outputTokens", "cachedInputTokens", "estimatedCostUsd"] as const; type Metric = typeof METRICS[number]; type Totals = Partial>; -type ScopedUsage = (UsageSummary | { summary?: Totals; customWindow?: boolean; since?: number | null; until?: number; usageIncomplete?: boolean }) - & { filter?: UsageFilterEcho }; +type ScopedUsage = { + customWindow?: boolean; + since?: number | null; + until?: number; + summary?: Totals; + usageIncomplete?: boolean; + filter?: Record; +}; function query(params: Record): string { const search = new URLSearchParams(); @@ -209,7 +215,7 @@ async function readUsage( if (error instanceof RuntimeApiError && error.code === "proxy_not_running") throw error; throw new Error("Key-scoped usage could not be read. Check runtime access and retry."); } - if (result?.filter?.apiKeyId !== apiKeyId) { + if (result.filter?.apiKeyId !== apiKeyId) { throw new Error("The server did not confirm the requested API key scope. Upgrade and restart the proxy, then retry."); } }