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:
can1357
2026-06-10 01:21:56 +02:00
parent abc21fd8d2
commit dbbf7ffac5
5 changed files with 815 additions and 0 deletions
+4
View File
@@ -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
@@ -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"] },
+603
View File
@@ -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();
}
}
@@ -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<void> {
const { flags } = await this.parse(Usage);
await runUsageCommand({
json: flags.json,
provider: flags.provider,
redact: flags.redact,
});
}
}
@@ -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);
});
});