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.
This commit is contained in:
@@ -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<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 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<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 = 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<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}`);
|
||||
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<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 = 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, 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.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<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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user