Files
oh-my-pi/packages/coding-agent/src/cli/usage-cli.ts
T
can1357 579a005a04 feat(ai): added usage history tracking and trend reporting for account limits
- Added usage snapshot persistence in sqlite with hour-bucket upsert behavior.
- Added listUsageHistory query support with optional provider and sinceMs filters.
- Added usage CLI history mode with `--history` and `--days` and trend rendering.
- Added changelog documentation for usage trend inspection and no-history exit behavior.
2026-06-12 05:27:59 +02:00

775 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
resolveUsedFraction,
type UsageHistoryEntry,
type UsageLimit,
type UsageReport,
type 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;
/** Show recorded usage-limit history instead of a live snapshot. */
history?: boolean;
/** History window in days (with `history`). */
days?: number;
}
/** 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<string>): Map<string, string> {
const unique = [...new Set(values)];
const map = new Map<string, string>();
const byAnchor = new Map<string, string[]>();
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<string, string[]>();
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<UsageLimit["status"]>;
function resolveStatus(limit: UsageLimit): LimitStatus {
if (limit.status && limit.status !== "unknown") return limit.status;
const fraction = resolveUsedFraction(limit);
if (fraction === undefined) return "unknown";
if (fraction >= 1) return "exhausted";
if (fraction >= 0.8) return "warning";
return "ok";
}
const STATUS_COLOR: Record<LimitStatus, (text: string) => 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<UsageUnit, string> = {
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 = resolveUsedFraction(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 = resolveUsedFraction(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<string> {
const ids = new Set<string>();
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<string, UsageReport[]>();
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<string>();
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, string>,
): 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}`);
const savedResets = report.resetCredits?.availableCount ?? 0;
if (savedResets > 0) header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`);
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 much account quota is burned and left. */
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' worth of quota still available across reporting accounts. */
remainingAccounts: number;
}
/**
* Aggregate one provider's reports into per-window quota capacity 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<string, { window: string; durationMs?: number; fractions: number[] }>();
for (const report of reports) {
const accountMax = new Map<string, number>();
for (const limit of report.limits) {
const fraction = resolveUsedFraction(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,
remainingAccounts: Math.max(0, bucket.fractions.length - usedAccounts),
};
});
}
/**
* 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, string>,
): string {
const reportsByProvider = new Map<string, UsageReport[]>();
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<string, UsageAccountIdentity[]>();
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.usedAccounts.toFixed(2)}/${stat.accounts} ${stat.accounts === 1 ? "account" : "accounts"} used (${stat.remainingAccounts.toFixed(2)}× quota left)`,
);
lines.push(` ${chalk.dim(`capacity: ${parts.join(" · ")}`)}`);
}
}
return lines.join("\n");
}
const HISTORY_SPARK_WIDTH = 48;
const SPARK_LEVELS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
interface HistorySeries {
title: string;
/** Snapshots ascending by recordedAt (listUsageHistory order). */
entries: UsageHistoryEntry[];
}
interface HistoryAccount {
label: string;
series: Map<string, HistorySeries>;
}
/** Mirror of {@link limitTitle} for history rows (no scope/tier available). */
function historySeriesTitle(entry: UsageHistoryEntry): string {
const label = entry.label;
const windowLabel = entry.windowLabel;
if (!windowLabel) return label;
if (windowLabel.toLowerCase() === "quota window") return label;
if (label.toLowerCase().includes(windowLabel.toLowerCase())) return label;
return `${label} (${windowLabel})`;
}
function historyAccountLabel(entry: UsageHistoryEntry): string {
return entry.email ?? entry.accountId ?? entry.accountKey;
}
function historyStatus(fraction: number | undefined, status: UsageHistoryEntry["status"]): LimitStatus {
if (status && status !== "unknown") return status;
if (fraction === undefined) return "unknown";
if (fraction >= 1) return "exhausted";
if (fraction >= 0.8) return "warning";
return "ok";
}
/** Peak-per-bucket sparkline over [sinceMs, nowMs]; empty buckets render dim dots. */
function renderHistorySparkline(entries: UsageHistoryEntry[], sinceMs: number, nowMs: number): string {
const span = Math.max(1, nowMs - sinceMs);
const buckets: Array<number | undefined> = new Array(HISTORY_SPARK_WIDTH).fill(undefined);
for (const entry of entries) {
if (entry.usedFraction === undefined) continue;
const offset = Math.floor(((entry.recordedAt - sinceMs) / span) * HISTORY_SPARK_WIDTH);
const index = Math.min(HISTORY_SPARK_WIDTH - 1, Math.max(0, offset));
const prev = buckets[index];
buckets[index] = prev === undefined ? entry.usedFraction : Math.max(prev, entry.usedFraction);
}
return buckets
.map(fraction => {
if (fraction === undefined) return chalk.dim("·");
const clamped = Math.min(Math.max(fraction, 0), 1);
const level = SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.floor(clamped * SPARK_LEVELS.length))];
return STATUS_COLOR[historyStatus(clamped, undefined)](level);
})
.join("");
}
/** Identity strings a history rendering could surface — input for {@link buildRedactionMap}. */
function collectHistoryIdentityStrings(entries: UsageHistoryEntry[]): string[] {
const values: string[] = [];
for (const entry of entries) {
if (entry.email) values.push(entry.email);
if (entry.accountId) values.push(entry.accountId);
values.push(entry.accountKey);
}
return values;
}
/**
* Render recorded usage-limit history: per provider, per account, one
* peak-per-bucket sparkline per limit window plus latest/peak percentages.
*/
export function formatUsageHistory(
entries: UsageHistoryEntry[],
sinceMs: number,
nowMs: number,
redaction?: Map<string, string>,
): string {
const providers = new Map<string, Map<string, HistoryAccount>>();
for (const entry of entries) {
let accounts = providers.get(entry.provider);
if (!accounts) {
accounts = new Map();
providers.set(entry.provider, accounts);
}
let account = accounts.get(entry.accountKey);
if (!account) {
account = { label: historyAccountLabel(entry), series: new Map() };
accounts.set(entry.accountKey, account);
}
let series = account.series.get(entry.limitId);
if (!series) {
series = { title: historySeriesTitle(entry), entries: [] };
account.series.set(entry.limitId, series);
}
// Labels can change across snapshots (provider renames); latest wins.
series.title = historySeriesTitle(entry);
series.entries.push(entry);
}
const lines: string[] = [];
lines.push(
`${chalk.bold("Usage history")}${chalk.dim(` · last ${formatDuration(nowMs - sinceMs)} · peak per bucket`)}`,
);
for (const provider of [...providers.keys()].sort((a, b) => a.localeCompare(b))) {
const accounts = providers.get(provider) ?? new Map<string, HistoryAccount>();
lines.push("");
lines.push(
`${chalk.bold.cyan(formatProviderName(provider))} ${chalk.dim(`— ${accounts.size} ${accounts.size === 1 ? "account" : "accounts"}`)}`,
);
const sortedAccounts = [...accounts.values()].sort((a, b) => a.label.localeCompare(b.label));
for (const account of sortedAccounts) {
lines.push(` ${chalk.bold(redaction?.get(account.label) ?? account.label)}`);
const labelWidth = [...account.series.values()].reduce((max, series) => Math.max(max, series.title.length), 0);
const sortedSeries = [...account.series.values()].sort((a, b) => a.title.localeCompare(b.title));
for (const series of sortedSeries) {
const fractions = series.entries
.map(entry => entry.usedFraction)
.filter((fraction): fraction is number => fraction !== undefined);
const latestEntry = series.entries[series.entries.length - 1];
const latestFraction = fractions.length > 0 ? fractions[fractions.length - 1] : undefined;
const peakFraction = fractions.length > 0 ? Math.max(...fractions) : undefined;
const status = historyStatus(latestFraction, latestEntry?.status);
const details: string[] = [];
if (latestFraction !== undefined) details.push(`latest ${(latestFraction * 100).toFixed(1)}%`);
if (peakFraction !== undefined) details.push(`peak ${(peakFraction * 100).toFixed(1)}%`);
details.push(`${series.entries.length} snapshot${series.entries.length === 1 ? "" : "s"}`);
lines.push(
` ${STATUS_COLOR[status]("●")} ${series.title.padEnd(labelWidth)} ${renderHistorySparkline(series.entries, sinceMs, nowMs)} ${chalk.dim(details.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<string, string>, 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<UsageReport, "raw">,
redaction: Map<string, string>,
): Omit<UsageReport, "raw"> {
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<void> {
const authStorage = await discoverAuthStorage();
try {
if (cmd.history) {
const days = cmd.days !== undefined && Number.isFinite(cmd.days) && cmd.days > 0 ? cmd.days : 7;
const nowMs = Date.now();
const sinceMs = nowMs - days * 86_400_000;
const entries = authStorage.listUsageHistory({ sinceMs, provider: cmd.provider?.toLowerCase() });
const redaction = cmd.redact ? buildRedactionMap(collectHistoryIdentityStrings(entries)) : undefined;
if (cmd.json) {
const masked = redaction
? entries.map(entry => ({
...entry,
accountKey: redaction.get(entry.accountKey) ?? entry.accountKey,
email: maskIdentity(redaction, entry.email),
accountId: maskIdentity(redaction, entry.accountId),
}))
: entries;
process.stdout.write(`${JSON.stringify({ generatedAt: nowMs, sinceMs, entries: masked }, null, 2)}\n`);
return;
}
if (entries.length === 0) {
const scope = cmd.provider ? ` for provider "${cmd.provider}"` : "";
process.stderr.write(
chalk.yellow(
`No usage history recorded${scope} yet. Snapshots accumulate whenever usage is fetched (TUI footer, /usage, omp usage).\n`,
),
);
process.exitCode = 1;
return;
}
process.stdout.write(`${formatUsageHistory(entries, sinceMs, nowMs, redaction)}\n`);
return;
}
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<string, ProviderWindowStat[]> = {};
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();
}
}