From dbbf7ffac5929bf24168d03caf5311cd66f55adf Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 01:21:56 +0200 Subject: [PATCH] feat(cli): added per-account `omp usage` reporting with provider/json/redact flags - Added per-account usage reporting in the `omp usage` command. - Added `provider`, `json`, and `redact` options to customize usage output. - Updated CLI wiring to route usage commands to the new per-account behavior. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/cli-commands.ts | 1 + packages/coding-agent/src/cli/usage-cli.ts | 603 +++++++++++++++++++ packages/coding-agent/src/commands/usage.ts | 35 ++ packages/coding-agent/test/usage-cli.test.ts | 172 ++++++ 5 files changed, 815 insertions(+) create mode 100644 packages/coding-agent/src/cli/usage-cli.ts create mode 100644 packages/coding-agent/src/commands/usage.ts create mode 100644 packages/coding-agent/test/usage-cli.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 610571c82..577217a2d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Added + +- New `omp usage` command: a detailed per-account breakdown of provider usage limits (bars, windows, reset times, plan metadata) covering every stored credential — accounts with no usage endpoint are listed as "no usage data" rows. Each provider section ends with per-window capacity stats ("need: 5h → 3 of 5 accounts"). Flags: `--provider` to filter, `--json` for the broker-shaped report payload, and `--redact` to mask account emails/ids down to a two-char anchor plus a minimal middle-out differentiator (`ca*9*`) for screenshot-safe sharing. + ### Changed - Added a limit of 4 concurrent IRC cards in the transcript live region and evicted the oldest live-region card when new IRC cards would exceed the cap diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index efa68d4fa..3480e46ff 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -32,6 +32,7 @@ export const commands: CommandEntry[] = [ { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) }, { name: "stats", load: () => import("./commands/stats").then(m => m.default) }, { name: "update", load: () => import("./commands/update").then(m => m.default) }, + { name: "usage", load: () => import("./commands/usage").then(m => m.default) }, { name: "tiny-models", load: () => import("./commands/tiny-models").then(m => m.default) }, { name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] }, { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] }, diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts new file mode 100644 index 000000000..a62f88232 --- /dev/null +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -0,0 +1,603 @@ +/** + * Usage CLI command handler. + * + * Handles `omp usage` — fetches provider usage reports for every + * authenticated account and prints a detailed per-account breakdown + * (limits, windows, reset times, plan metadata). Accounts whose + * credentials produced no usage report are listed too, so the output + * always covers the full credential pool. + */ +import type { AuthStorage, UsageLimit, UsageReport, UsageUnit } from "@oh-my-pi/pi-ai"; +import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils"; +import chalk from "chalk"; +import { ModelRegistry } from "../config/model-registry"; +import { discoverAuthStorage } from "../sdk"; + +const BAR_WIDTH = 28; + +export interface UsageCommandArgs { + json?: boolean; + provider?: string; + redact?: boolean; +} + +/** Identity slice of a stored credential, for "every account" coverage. */ +export interface UsageAccountIdentity { + provider: string; + type: "api_key" | "oauth"; + email?: string; + accountId?: string; + projectId?: string; + enterpriseUrl?: string; +} + +/** + * Minimal-reveal masks for identity strings (`--redact`). + * + * Every mask shows a two-character anchor. When two identities share the + * anchor, the mask additionally reveals the shortest "middle-out" + * differentiator — the shortest substring (closest to the string's middle on + * ties) that no colliding identity contains — as `an*`, `ca*9*`, `ca*nb*`. + * Prefix growth is deliberately avoided: it leaks the start of the local + * part (`can.boluk@*`) when a couple of mid-string characters suffice. + * Duplicate strings (same account on two providers) share a mask. + */ +export function buildRedactionMap(values: Iterable): Map { + const unique = [...new Set(values)]; + const map = new Map(); + const byAnchor = new Map(); + for (const value of unique) { + const anchor = value.slice(0, 2); + const list = byAnchor.get(anchor) ?? []; + list.push(value); + byAnchor.set(anchor, list); + } + for (const value of unique) { + const anchor = value.slice(0, 2); + const peers = (byAnchor.get(anchor) ?? []).filter(other => other !== value); + if (peers.length === 0) { + map.set(value, `${anchor}*`); + continue; + } + const infix = findDistinguishingInfix(value, peers); + map.set(value, infix === undefined ? `${anchor}*` : `${anchor}*${infix}*`); + } + // Residual collisions (a value whose every substring also occurs in a + // peer gets the bare anchor mask) fall back to prefix extension. + const byMask = new Map(); + for (const value of unique) { + const mask = map.get(value)!; + const list = byMask.get(mask) ?? []; + list.push(value); + byMask.set(mask, list); + } + for (const collided of byMask.values()) { + if (collided.length < 2) continue; + for (const value of collided) { + let length = Math.min(2, value.length); + while ( + length < value.length && + collided.some(other => other !== value && other.startsWith(value.slice(0, length))) + ) { + length++; + } + map.set(value, `${value.slice(0, length)}*`); + } + } + return map; +} + +/** + * Shortest substring of `value` (past the revealed two-char anchor) that no + * peer contains. Among equal-length candidates, picks the one centered + * closest to the middle of the string. Returns undefined when every + * substring also occurs in a peer (e.g. `value` is contained in a peer — + * that peer's own differentiator keeps the masks distinct). + */ +function findDistinguishingInfix(value: string, peers: string[]): string | undefined { + const start = Math.min(2, value.length); + const center = value.length / 2; + for (let length = 1; length <= value.length - start; length++) { + let best: { infix: string; distance: number } | undefined; + for (let pos = start; pos + length <= value.length; pos++) { + const candidate = value.slice(pos, pos + length); + if (peers.some(peer => peer.includes(candidate))) continue; + const distance = Math.abs(pos + length / 2 - center); + if (!best || distance < best.distance) best = { infix: candidate, distance }; + } + if (best) return best.infix; + } + return undefined; +} + +/** Every identity string the output could surface — input for {@link buildRedactionMap}. */ +function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountIdentity[]): string[] { + const values: string[] = []; + const add = (value: unknown): void => { + if (typeof value === "string" && value) values.push(value); + }; + for (const report of reports) { + const meta = report.metadata ?? {}; + add(meta.email); + add(meta.accountId); + add(meta.projectId); + add(meta.orgId); + for (const limit of report.limits) { + add(limit.scope.accountId); + add(limit.scope.projectId); + add(limit.scope.orgId); + } + } + for (const account of accounts) { + add(account.email); + add(account.accountId); + add(account.projectId); + add(account.enterpriseUrl); + } + return values; +} + +type LimitStatus = NonNullable; + +function resolveFraction(limit: UsageLimit): number | undefined { + const amount = limit.amount; + if (amount.usedFraction !== undefined) return amount.usedFraction; + if (amount.used !== undefined && amount.limit !== undefined && amount.limit > 0) { + return amount.used / amount.limit; + } + if (amount.unit === "percent" && amount.used !== undefined) return amount.used / 100; + if (amount.remainingFraction !== undefined) return Math.max(0, 1 - amount.remainingFraction); + return undefined; +} + +function resolveStatus(limit: UsageLimit): LimitStatus { + if (limit.status && limit.status !== "unknown") return limit.status; + const fraction = resolveFraction(limit); + if (fraction === undefined) return "unknown"; + if (fraction >= 1) return "exhausted"; + if (fraction >= 0.8) return "warning"; + return "ok"; +} + +const STATUS_COLOR: Record string> = { + exhausted: chalk.red, + warning: chalk.yellow, + ok: chalk.green, + unknown: chalk.dim, +}; + +/** Worst-of aggregation: exhausted > warning > ok > unknown. */ +function aggregateStatus(limits: UsageLimit[]): LimitStatus { + const statuses = limits.map(resolveStatus); + if (statuses.includes("exhausted")) return "exhausted"; + if (statuses.includes("warning")) return "warning"; + if (statuses.includes("ok")) return "ok"; + return "unknown"; +} + +function formatProviderName(provider: string): string { + return provider + .split(/[-_]/g) + .map(part => (part ? part[0].toUpperCase() + part.slice(1) : "")) + .join(" "); +} + +function formatUnitValue(value: number, unit: UsageUnit): string { + if (unit === "usd") return `$${value.toFixed(2)}`; + return formatNumber(value); +} + +const UNIT_SUFFIX: Record = { + tokens: " tokens", + requests: " requests", + minutes: " min", + bytes: " bytes", + percent: "", + usd: "", + unknown: "", +}; + +function describeAmount(limit: UsageLimit): string { + const amount = limit.amount; + const parts: string[] = []; + const absoluteUnit = amount.unit !== "percent" && amount.unit !== "unknown"; + if (absoluteUnit && amount.used !== undefined && amount.limit !== undefined) { + parts.push( + `${formatUnitValue(amount.used, amount.unit)} / ${formatUnitValue(amount.limit, amount.unit)}${UNIT_SUFFIX[amount.unit]}`, + ); + } else if (absoluteUnit && amount.remaining !== undefined) { + parts.push(`${formatUnitValue(amount.remaining, amount.unit)}${UNIT_SUFFIX[amount.unit]} left`); + } + const fraction = resolveFraction(limit); + if (fraction !== undefined) { + parts.push(`${(fraction * 100).toFixed(1)}% used`); + } else if (amount.remainingFraction !== undefined) { + parts.push(`${(amount.remainingFraction * 100).toFixed(1)}% left`); + } + if (parts.length === 0) parts.push("no data"); + return parts.join(" · "); +} + +function renderBar(limit: UsageLimit): string { + const fraction = resolveFraction(limit); + if (fraction === undefined) return chalk.dim("·".repeat(BAR_WIDTH)); + const clamped = Math.min(Math.max(fraction, 0), 1); + const filled = Math.round(clamped * BAR_WIDTH); + const color = STATUS_COLOR[resolveStatus(limit)]; + return color("█".repeat(filled)) + chalk.dim("░".repeat(BAR_WIDTH - filled)); +} + +/** Append the window label when the limit label doesn't already carry it. */ +function limitTitle(limit: UsageLimit): string { + let label = limit.label; + const tier = limit.scope.tier; + if (tier && !label.toLowerCase().includes(tier.toLowerCase())) label = `${label} (${tier})`; + const windowLabel = limit.window?.label ?? limit.scope.windowId; + if (!windowLabel) return label; + if (windowLabel.toLowerCase() === "quota window") return label; + if (label.toLowerCase().includes(windowLabel.toLowerCase())) return label; + return `${label} (${windowLabel})`; +} + +function reportAccountLabel(report: UsageReport, index: number): string { + const meta = report.metadata ?? {}; + for (const key of ["email", "accountId", "projectId"] as const) { + const value = meta[key]; + if (typeof value === "string" && value) return value; + } + for (const limit of report.limits) { + const scoped = limit.scope.accountId ?? limit.scope.projectId; + if (scoped) return scoped; + } + return `account ${index + 1}`; +} + +/** Lowercased identity strings a report can be attributed to. */ +function reportIdentifiers(report: UsageReport): Set { + const ids = new Set(); + const add = (value: unknown): void => { + if (typeof value === "string" && value) ids.add(value.toLowerCase()); + }; + const meta = report.metadata ?? {}; + add(meta.email); + add(meta.accountId); + add(meta.projectId); + add(meta.orgId); + for (const limit of report.limits) { + add(limit.scope.accountId); + add(limit.scope.projectId); + add(limit.scope.orgId); + } + return ids; +} + +/** + * Stored credentials that no usage report could be attributed to. + * + * Conservative on purpose: when a provider's reports carry no identity at + * all (or the credential is an API key alongside existing reports), we + * can't attribute, so we don't claim the account is missing. + */ +export function collectUnreportedAccounts( + reports: UsageReport[], + accounts: UsageAccountIdentity[], +): UsageAccountIdentity[] { + const byProvider = new Map(); + for (const report of reports) { + const list = byProvider.get(report.provider) ?? []; + list.push(report); + byProvider.set(report.provider, list); + } + return accounts.filter(account => { + const providerReports = byProvider.get(account.provider) ?? []; + if (providerReports.length === 0) return true; + if (account.type === "api_key") return false; + const ids = [account.email, account.accountId, account.projectId] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map(value => value.toLowerCase()); + if (ids.length === 0) return false; + const reported = new Set(); + let anyIdentified = false; + for (const report of providerReports) { + const identifiers = reportIdentifiers(report); + if (identifiers.size > 0) anyIdentified = true; + for (const id of identifiers) reported.add(id); + } + if (!anyIdentified) return false; + return !ids.some(id => reported.has(id)); + }); +} + +function accountIdentityLabel(account: UsageAccountIdentity): string { + if (account.type === "api_key") return "API key"; + return account.email ?? account.accountId ?? account.projectId ?? account.enterpriseUrl ?? "OAuth account"; +} + +function formatAccountHeader( + report: UsageReport, + index: number, + nowMs: number, + redaction?: Map, +): string { + const status = aggregateStatus(report.limits); + const icon = STATUS_COLOR[status]("●"); + const label = reportAccountLabel(report, index); + let header = `${icon} ${chalk.bold(redaction?.get(label) ?? label)}`; + const planType = report.metadata?.planType; + if (typeof planType === "string" && planType) header += chalk.dim(` · plan: ${planType}`); + if (report.fetchedAt && nowMs - report.fetchedAt > 90_000) { + header += chalk.dim(` · fetched ${formatDuration(nowMs - report.fetchedAt)} ago`); + } + return header; +} + +function formatLimitLine(limit: UsageLimit, labelWidth: number, nowMs: number): string[] { + const status = resolveStatus(limit); + const title = limitTitle(limit); + const padded = title.padEnd(labelWidth); + const details: string[] = [describeAmount(limit)]; + const resetsAt = limit.window?.resetsAt; + if (resetsAt !== undefined && resetsAt > nowMs) { + details.push(`resets in ${formatDuration(resetsAt - nowMs)}`); + } + const lines = [ + ` ${STATUS_COLOR[status]("●")} ${padded} ${renderBar(limit)} ${chalk.dim(details.join(" · "))}`, + ]; + if (limit.notes && limit.notes.length > 0) { + lines.push(` ${chalk.dim(limit.notes.join(" · "))}`); + } + return lines; +} + +/** Per-window capacity stat: how many accounts the current burn requires. */ +export interface ProviderWindowStat { + /** Compact window label, e.g. "5h", "7d". */ + window: string; + durationMs?: number; + /** Accounts reporting a limit in this window. */ + accounts: number; + /** Sum of each account's binding used fraction — accounts' worth of quota burned. */ + usedAccounts: number; + /** Accounts the current burn requires: max(1, ceil(usedAccounts)). */ + needed: number; +} + +/** + * Aggregate one provider's reports into per-window "accounts needed" stats. + * + * Limits are bucketed by window duration (5h, 7d, ...). Within a bucket each + * account contributes its single highest used fraction — when an account has + * several meters on the same window (tiered/metered limits), the most-burned + * one is what binds. + */ +export function computeProviderWindowStats(reports: UsageReport[]): ProviderWindowStat[] { + const buckets = new Map(); + for (const report of reports) { + const accountMax = new Map(); + for (const limit of report.limits) { + const fraction = resolveFraction(limit); + if (fraction === undefined) continue; + const durationMs = limit.window?.durationMs; + const key = + durationMs !== undefined ? `d:${durationMs}` : (limit.scope.windowId ?? limit.window?.label ?? limit.label); + const previous = accountMax.get(key); + if (previous === undefined || fraction > previous) accountMax.set(key, fraction); + if (!buckets.has(key)) { + const window = + durationMs !== undefined + ? formatDuration(durationMs) + : (limit.window?.label ?? limit.scope.windowId ?? limit.label); + buckets.set(key, { window, durationMs, fractions: [] }); + } + } + for (const [key, fraction] of accountMax) buckets.get(key)!.fractions.push(fraction); + } + return [...buckets.values()] + .sort((a, b) => (a.durationMs ?? Number.POSITIVE_INFINITY) - (b.durationMs ?? Number.POSITIVE_INFINITY)) + .map(bucket => { + const usedAccounts = bucket.fractions.reduce((sum, fraction) => sum + fraction, 0); + return { + window: bucket.window, + durationMs: bucket.durationMs, + accounts: bucket.fractions.length, + usedAccounts, + needed: Math.max(1, Math.ceil(usedAccounts - 1e-9)), + }; + }); +} + +/** + * Render the full text breakdown: per provider, per account, every limit + * with a bar, amounts, and reset times; unattributed credentials trail + * each provider section as "no usage data" rows. + */ +export function formatUsageBreakdown( + reports: UsageReport[], + accounts: UsageAccountIdentity[], + nowMs: number, + redaction?: Map, +): string { + const reportsByProvider = new Map(); + for (const report of reports) { + const list = reportsByProvider.get(report.provider) ?? []; + list.push(report); + reportsByProvider.set(report.provider, list); + } + const unreported = collectUnreportedAccounts(reports, accounts); + const unreportedByProvider = new Map(); + for (const account of unreported) { + const list = unreportedByProvider.get(account.provider) ?? []; + list.push(account); + unreportedByProvider.set(account.provider, list); + } + + const providers = [...new Set([...reportsByProvider.keys(), ...unreportedByProvider.keys()])].sort((a, b) => + a.localeCompare(b), + ); + + const lines: string[] = []; + const latestFetchedAt = Math.max(0, ...reports.map(report => report.fetchedAt ?? 0)); + const headerSuffix = latestFetchedAt ? chalk.dim(` · fetched ${formatDuration(nowMs - latestFetchedAt)} ago`) : ""; + lines.push(`${chalk.bold("Usage")}${headerSuffix}`); + + for (const provider of providers) { + const providerReports = reportsByProvider.get(provider) ?? []; + const providerUnreported = unreportedByProvider.get(provider) ?? []; + const accountCount = providerReports.length + providerUnreported.length; + lines.push(""); + lines.push( + `${chalk.bold.cyan(formatProviderName(provider))} ${chalk.dim(`— ${accountCount} ${accountCount === 1 ? "account" : "accounts"}`)}`, + ); + + const labelWidth = providerReports + .flatMap(report => report.limits) + .reduce((max, limit) => Math.max(max, limitTitle(limit).length), 0); + + providerReports.forEach((report, index) => { + lines.push(` ${formatAccountHeader(report, index, nowMs, redaction)}`); + if (report.limits.length === 0) { + lines.push(` ${chalk.dim("no limits reported")}`); + return; + } + for (const limit of report.limits) { + lines.push(...formatLimitLine(limit, labelWidth, nowMs)); + } + }); + + for (const account of providerUnreported) { + const label = accountIdentityLabel(account); + lines.push(` ${chalk.dim("○")} ${chalk.dim(`${redaction?.get(label) ?? label} — no usage data`)}`); + } + + const stats = computeProviderWindowStats(providerReports); + if (stats.length > 0) { + const parts = stats.map( + stat => + `${stat.window} → ${stat.needed} of ${stat.accounts} ${stat.accounts === 1 ? "account" : "accounts"} (${stat.usedAccounts.toFixed(2)}× quota burned)`, + ); + lines.push(` ${chalk.dim(`need: ${parts.join(" · ")}`)}`); + } + } + + return lines.join("\n"); +} + +function collectStoredAccounts(authStorage: AuthStorage): UsageAccountIdentity[] { + const accounts: UsageAccountIdentity[] = []; + const all = authStorage.getAll(); + for (const provider in all) { + const entry = all[provider]; + const credentials = Array.isArray(entry) ? entry : [entry]; + for (const credential of credentials) { + if (credential.type === "oauth") { + accounts.push({ + provider, + type: "oauth", + email: credential.email, + accountId: credential.accountId, + projectId: credential.projectId, + enterpriseUrl: credential.enterpriseUrl, + }); + } else { + accounts.push({ provider, type: "api_key" }); + } + } + } + return accounts; +} + +/** Apply a redaction mask to an optional identity field. */ +function maskIdentity(redaction: Map, value: string | undefined): string | undefined { + return value === undefined ? undefined : (redaction.get(value) ?? value); +} + +const IDENTITY_METADATA_KEYS = ["email", "accountId", "projectId", "orgId"] as const; + +/** Mask identity fields in a raw-stripped report for `--redact --json`. */ +function redactReportForJson( + report: Omit, + redaction: Map, +): Omit { + let metadata = report.metadata; + if (metadata) { + metadata = { ...metadata }; + for (const key of IDENTITY_METADATA_KEYS) { + const value = metadata[key]; + if (typeof value === "string") metadata[key] = redaction.get(value) ?? value; + } + } + const limits = report.limits.map(limit => ({ + ...limit, + scope: { + ...limit.scope, + accountId: maskIdentity(redaction, limit.scope.accountId), + projectId: maskIdentity(redaction, limit.scope.projectId), + orgId: maskIdentity(redaction, limit.scope.orgId), + }, + })); + return { ...report, metadata, limits }; +} + +export async function runUsageCommand(cmd: UsageCommandArgs): Promise { + const authStorage = await discoverAuthStorage(); + try { + const modelRegistry = new ModelRegistry(authStorage); + const reports = + (await authStorage.fetchUsageReports({ + baseUrlResolver: provider => modelRegistry.getProviderBaseUrl(provider), + })) ?? []; + let accounts = collectStoredAccounts(authStorage); + let filteredReports = reports; + if (cmd.provider) { + const wanted = cmd.provider.toLowerCase(); + filteredReports = reports.filter(report => report.provider.toLowerCase() === wanted); + accounts = accounts.filter(account => account.provider.toLowerCase() === wanted); + } + + const redaction = cmd.redact ? buildRedactionMap(collectIdentityStrings(filteredReports, accounts)) : undefined; + + if (cmd.json) { + // Drop the heavy provider-specific `raw` payload — same shape as the + // broker/gateway `/v1/usage` endpoints. + let trimmed = filteredReports.map(({ raw: _raw, ...rest }) => rest); + let unreportedAccounts = collectUnreportedAccounts(filteredReports, accounts); + if (redaction) { + trimmed = trimmed.map(report => redactReportForJson(report, redaction)); + unreportedAccounts = unreportedAccounts.map(account => ({ + ...account, + email: maskIdentity(redaction, account.email), + accountId: maskIdentity(redaction, account.accountId), + projectId: maskIdentity(redaction, account.projectId), + enterpriseUrl: maskIdentity(redaction, account.enterpriseUrl), + })); + } + const capacity: Record = {}; + for (const report of filteredReports) { + if (capacity[report.provider]) continue; + const stats = computeProviderWindowStats(filteredReports.filter(peer => peer.provider === report.provider)); + if (stats.length > 0) capacity[report.provider] = stats; + } + const payload = { + generatedAt: Date.now(), + reports: trimmed, + accountsWithoutUsage: unreportedAccounts, + capacity, + }; + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + + if (filteredReports.length === 0 && accounts.length === 0) { + const scope = cmd.provider ? ` for provider "${cmd.provider}"` : ""; + process.stderr.write( + chalk.yellow(`No credentials found${scope}. Run \`omp\` and use /login to add accounts.\n`), + ); + process.exitCode = 1; + return; + } + + process.stdout.write(`${formatUsageBreakdown(filteredReports, accounts, Date.now(), redaction)}\n`); + } finally { + authStorage.close(); + } +} diff --git a/packages/coding-agent/src/commands/usage.ts b/packages/coding-agent/src/commands/usage.ts new file mode 100644 index 000000000..4808ac5c2 --- /dev/null +++ b/packages/coding-agent/src/commands/usage.ts @@ -0,0 +1,35 @@ +/** + * Show provider usage limits for every authenticated account. + */ +import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; +import { runUsageCommand } from "../cli/usage-cli"; + +export default class Usage extends Command { + static description = "Show provider usage limits for every authenticated account"; + + static flags = { + json: Flags.boolean({ char: "j", description: "Output usage reports as JSON", default: false }), + provider: Flags.string({ char: "p", description: "Only show usage for this provider id (e.g. anthropic)" }), + redact: Flags.boolean({ + char: "r", + description: "Redact account emails/ids (shortest unique prefix) for sharing screenshots", + default: false, + }), + }; + + static examples = [ + "# Detailed per-account usage breakdown across all providers\n omp usage", + "# Only Anthropic accounts\n omp usage --provider anthropic", + "# Redact account identifiers for screenshots\n omp usage --redact", + "# Machine-readable output\n omp usage --json", + ]; + + async run(): Promise { + const { flags } = await this.parse(Usage); + await runUsageCommand({ + json: flags.json, + provider: flags.provider, + redact: flags.redact, + }); + } +} diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts new file mode 100644 index 000000000..3f9efc915 --- /dev/null +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; +import type { UsageReport } from "@oh-my-pi/pi-ai"; +import { + buildRedactionMap, + collectUnreportedAccounts, + computeProviderWindowStats, + formatUsageBreakdown, + type UsageAccountIdentity, +} from "@oh-my-pi/pi-coding-agent/cli/usage-cli"; + +const HOUR = 3_600_000; +const FIVE_HOURS = 5 * HOUR; +const SEVEN_DAYS = 7 * 24 * HOUR; + +function makeLimit(opts: { + id: string; + usedFraction: number; + durationMs?: number; + windowId?: string; + tier?: string; + accountId?: string; +}): UsageReport["limits"][number] { + return { + id: opts.id, + label: opts.id, + scope: { + provider: "anthropic", + windowId: opts.windowId, + tier: opts.tier, + accountId: opts.accountId, + }, + window: + opts.durationMs !== undefined + ? { id: opts.windowId ?? opts.id, label: opts.windowId ?? opts.id, durationMs: opts.durationMs } + : undefined, + amount: { unit: "percent", usedFraction: opts.usedFraction }, + }; +} + +function makeReport(provider: string, email: string, limits: UsageReport["limits"]): UsageReport { + return { provider, fetchedAt: Date.now(), limits, metadata: { email } }; +} + +describe("buildRedactionMap", () => { + it("masks everything past a two-char anchor when the anchor is unique", () => { + const map = buildRedactionMap(["alpha@example.test", "bravo@example.test"]); + expect(map.get("alpha@example.test")).toBe("an*"); + expect(map.get("bravo@example.test")).toBe("ha*"); + }); + + it("reveals a minimal middle-out differentiator instead of growing the prefix", () => { + const values = ["dum.my@example.org", "dum.my9@example.net", "dummy@example.net"]; + const map = buildRedactionMap(values); + const masks = values.map(value => map.get(value)!); + // Masks must be pairwise distinct so accounts stay tellable-apart. + expect(new Set(masks).size).toBe(masks.length); + for (const mask of masks) { + // Never leak the local part the way prefix growth would ("can.boluk@*"). + expect(mask).not.toContain("boluk"); + // anchor + at most a two-char differentiator. + expect(mask).toMatch(/^ca\*(.{1,2}\*)?$/); + } + // The "89" account is distinguished by a digit only it contains. + expect(map.get("dum.my9@example.net")).toBe("ca*9*"); + }); + + it("gives duplicate identities the same mask", () => { + const map = buildRedactionMap(["user@example.test", "user@example.test"]); + expect(map.size).toBe(1); + expect(map.get("user@example.test")).toBe("me*"); + }); +}); + +describe("computeProviderWindowStats", () => { + it("buckets by window duration, binds each account to its worst meter, and ceils the need", () => { + const reports = [ + makeReport("anthropic", "a@x", [ + makeLimit({ id: "5h", usedFraction: 0.9, durationMs: FIVE_HOURS, windowId: "5h" }), + makeLimit({ id: "7d", usedFraction: 0.1, durationMs: SEVEN_DAYS, windowId: "7d" }), + // Tiered meter on the same window: higher burn must bind. + makeLimit({ id: "7d-opus", usedFraction: 0.4, durationMs: SEVEN_DAYS, windowId: "7d", tier: "opus" }), + ]), + makeReport("anthropic", "b@x", [ + makeLimit({ id: "5h", usedFraction: 0.4, durationMs: FIVE_HOURS, windowId: "5h" }), + makeLimit({ id: "7d", usedFraction: 0.2, durationMs: SEVEN_DAYS, windowId: "7d" }), + ]), + ]; + const stats = computeProviderWindowStats(reports); + expect(stats).toHaveLength(2); + const [fiveHour, sevenDay] = stats; + // Sorted shortest window first. + expect(fiveHour.window).toBe("5h"); + expect(fiveHour.accounts).toBe(2); + expect(fiveHour.usedAccounts).toBeCloseTo(1.3); + expect(fiveHour.needed).toBe(2); + expect(sevenDay.window).toBe("7d"); + expect(sevenDay.usedAccounts).toBeCloseTo(0.6); // 0.4 (opus binds) + 0.2 + expect(sevenDay.needed).toBe(1); + }); + + it("ignores limits without a resolvable fraction", () => { + const reports = [ + makeReport("anthropic", "a@x", [ + { + id: "mystery", + label: "mystery", + scope: { provider: "anthropic" }, + amount: { unit: "unknown" }, + }, + ]), + ]; + expect(computeProviderWindowStats(reports)).toHaveLength(0); + }); +}); + +describe("collectUnreportedAccounts", () => { + const accounts: UsageAccountIdentity[] = [ + { provider: "anthropic", type: "oauth", email: "seen@x.com" }, + { provider: "anthropic", type: "oauth", email: "missing@x.com" }, + { provider: "anthropic", type: "api_key" }, + { provider: "cerebras", type: "api_key" }, + ]; + const reports = [makeReport("anthropic", "seen@x.com", [])]; + + it("flags providers without reports and identified accounts missing from reports", () => { + const unreported = collectUnreportedAccounts(reports, accounts); + expect(unreported).toEqual([ + { provider: "anthropic", type: "oauth", email: "missing@x.com" }, + { provider: "cerebras", type: "api_key" }, + ]); + }); + + it("does not claim unattributable credentials are missing when reports carry no identity", () => { + const anonymous = [{ ...makeReport("anthropic", "seen@x.com", []), metadata: {} }]; + const unreported = collectUnreportedAccounts(anonymous, accounts); + expect(unreported).toEqual([{ provider: "cerebras", type: "api_key" }]); + }); +}); + +describe("formatUsageBreakdown", () => { + const reports = [ + makeReport("anthropic", "dum.my9@example.net", [ + makeLimit({ id: "Claude 5 Hour", usedFraction: 0.84, durationMs: FIVE_HOURS, windowId: "5h" }), + ]), + makeReport("anthropic", "dummy@example.net", [ + makeLimit({ id: "Claude 5 Hour", usedFraction: 0.5, durationMs: FIVE_HOURS, windowId: "5h" }), + ]), + ]; + const accounts: UsageAccountIdentity[] = [ + { provider: "anthropic", type: "oauth", email: "dum.my9@example.net" }, + { provider: "anthropic", type: "oauth", email: "dummy@example.net" }, + { provider: "cerebras", type: "api_key" }, + ]; + + it("renders every account: reported ones with limits, credential-only ones as no-data rows", () => { + const text = stripVTControlCharacters(formatUsageBreakdown(reports, accounts, Date.now())); + expect(text).toContain("dum.my9@example.net"); + expect(text).toContain("84.0% used"); + expect(text).toContain("Cerebras"); + expect(text).toContain("API key — no usage data"); + expect(text).toContain("need: 5h → 2 of 2 accounts"); + }); + + it("redacts account labels through the provided map without leaking the originals", () => { + const redaction = buildRedactionMap(["dum.my9@example.net", "dummy@example.net"]); + const text = stripVTControlCharacters(formatUsageBreakdown(reports, accounts, Date.now(), redaction)); + expect(text).not.toContain("dum.my9@example.net"); + expect(text).not.toContain("dummy@example.net"); + for (const mask of redaction.values()) expect(text).toContain(mask); + }); +});