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.
This commit is contained in:
can1357
2026-06-12 05:26:03 +02:00
parent be31ce3098
commit 579a005a04
8 changed files with 669 additions and 19 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
# Changelog
## [Unreleased]
### Added
- Added `AuthStorage.listUsageHistory` to retrieve historical usage snapshots with optional `provider` and `sinceMs` filtering
- Added durable usage-history persistence in the sqlite auth store so successful usage reports are recorded as time-series snapshots of limit utilization for later trend inspection
- Added `AuthStorage.redeemResetCredit` to redeem stored OpenAI Codex saved rate-limit reset credits for a target account by `credentialId`, `accountId`, or `email`
- Added `listCodexResetCredits` and `consumeCodexResetCredit` exports for OpenAI Codex saved reset-credit listing and redemption
- Added `resetCredits` with `availableCount` to `UsageReport` so OpenAI Codex usage data now exposes redeemable rate-limit resets
+187 -1
View File
@@ -24,18 +24,26 @@ import type {
UsageCredential,
UsageFetchContext,
UsageFetchParams,
UsageHistoryEntry,
UsageHistoryQuery,
UsageLimit,
UsageLogger,
UsageProvider,
UsageReport,
} from "./usage";
import { resolveUsedFraction } from "./usage";
import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
import { googleGeminiCliUsageProvider } from "./usage/gemini";
import { githubCopilotUsageProvider } from "./usage/github-copilot";
import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity";
import { kimiUsageProvider } from "./usage/kimi";
import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
import { type CodexResetConsumeCode, consumeCodexResetCredit, listCodexResetCredits } from "./usage/openai-codex-reset";
import {
type CodexResetConsumeCode,
type CodexResetCredit,
consumeCodexResetCredit,
listCodexResetCredits,
} from "./usage/openai-codex-reset";
import { zaiUsageProvider } from "./usage/zai";
const USAGE_RANKING_METRIC_EPSILON = 1e-9;
@@ -285,6 +293,14 @@ export interface AuthCredentialStore {
getCache(key: string, options?: { includeExpired?: boolean }): string | null;
setCache(key: string, value: string, expiresAtSec: number): void;
cleanExpiredCache(): void;
/**
* Append usage-limit snapshots for trend history. Optional: stores without
* durable storage (e.g. the broker remote store) omit it and recording is
* skipped — the broker host records into its own database instead.
*/
recordUsageSnapshots?(entries: UsageHistoryEntry[]): void;
/** Read recorded usage-limit snapshots, oldest first. */
listUsageHistory?(query?: UsageHistoryQuery): UsageHistoryEntry[];
/**
* Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
* it before the per-provider local refresh path. `RemoteAuthCredentialStore`
@@ -484,6 +500,13 @@ const USAGE_CACHE_PREFIX = "usage_cache:";
const USAGE_REPORT_TTL_MS = 5 * 60_000;
const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
/**
* Downsample usage history to at most one row per hour per account window: a
* snapshot landing in the same hour bucket as the series' latest row
* overwrites it in place. That bound makes further retention pruning
* unnecessary — 1 row/hour is ~9k rows per account window per year.
*/
const USAGE_HISTORY_BUCKET_MS = 60 * 60_000;
/**
* Per-credential cool-down after a usage fetch fails. While this window is
* active we serve the last successful value to avoid dropping the credential
@@ -912,6 +935,14 @@ export class AuthStorage {
this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
this.#usageCache = new AuthStorageUsageCache(this.#store);
// Opportunistic hygiene, once per AuthStorage lifetime: drop expired
// cache rows (24h last-good retention). A cheap indexed DELETE;
// failures must never block construction.
try {
this.#store.cleanExpiredCache();
} catch {
// Best-effort.
}
this.#usageFetch = options.usageFetch ?? fetch;
this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
@@ -2002,6 +2033,7 @@ export class AuthStorage {
// fan-out trips 429s every cycle. With ±25% jitter on TTL the refresh
// times decorrelate within a few cycles.
this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter });
this.#recordUsageHistory(request, report);
return report;
}
// Failure: cache the LAST GOOD value (if any) with a short jittered TTL
@@ -2023,6 +2055,50 @@ export class AuthStorage {
return promise;
}
/**
* Append a freshly fetched report to durable usage history (when the store
* supports it). The usage cache is latest-snapshot-only — these rows are
* the only place limit utilization is kept over time.
*/
#recordUsageHistory(request: UsageRequestDescriptor, report: UsageReport): void {
const record = this.#store.recordUsageSnapshots;
if (!record || report.limits.length === 0) return;
const recordedAt = Number.isFinite(report.fetchedAt) && report.fetchedAt > 0 ? report.fetchedAt : Date.now();
const accountKey = this.#buildUsageCacheIdentity(request.credential);
const metadata = report.metadata ?? {};
const metaEmail = typeof metadata.email === "string" ? metadata.email : undefined;
const metaAccountId = typeof metadata.accountId === "string" ? metadata.accountId : undefined;
const entries: UsageHistoryEntry[] = report.limits.map(limit => ({
recordedAt,
provider: request.provider,
accountKey,
email: request.credential.email ?? metaEmail,
accountId: request.credential.accountId ?? limit.scope.accountId ?? metaAccountId,
limitId: limit.id,
label: limit.label,
windowLabel: limit.window?.label ?? limit.scope.windowId,
usedFraction: resolveUsedFraction(limit),
status: limit.status,
resetsAt: limit.window?.resetsAt,
}));
try {
record.call(this.#store, entries);
} catch (error) {
this.#usageLogger?.debug("usage history record failed", {
provider: request.provider,
error: String(error),
});
}
}
/**
* Recorded usage-limit snapshots, oldest first. Empty when the underlying
* store has no durable history (e.g. a broker-backed remote store).
*/
listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] {
return this.#store.listUsageHistory?.(query) ?? [];
}
ingestUsageHeaders(
provider: Provider,
headers: Record<string, string>,
@@ -4178,6 +4254,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
#getCacheIncludingExpiredStmt: Statement;
#upsertCacheStmt: Statement;
#deleteExpiredCacheStmt: Statement;
#insertUsageHistoryStmt: Statement;
#lastUsageHistoryStmt: Statement;
#listUsageHistoryStmt: Statement;
#updateUsageHistoryStmt: Statement;
#closed = false;
constructor(db: Database) {
@@ -4217,6 +4297,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
"INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
);
this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
this.#insertUsageHistoryStmt = this.#db.prepare(
"INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
);
this.#lastUsageHistoryStmt = this.#db.prepare(
"SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1",
);
this.#updateUsageHistoryStmt = this.#db.prepare(
"UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?",
);
this.#listUsageHistoryStmt = this.#db.prepare(
"SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC",
);
}
static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
@@ -4254,6 +4346,22 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
CREATE TABLE IF NOT EXISTS usage_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recorded_at INTEGER NOT NULL,
provider TEXT NOT NULL,
account_key TEXT NOT NULL,
email TEXT,
account_id TEXT,
limit_id TEXT NOT NULL,
label TEXT NOT NULL,
window_label TEXT,
used_fraction REAL,
status TEXT,
resets_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at);
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at);
`);
if (!this.#authCredentialsTableExists()) {
@@ -4661,6 +4769,80 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
}
}
recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
try {
for (const entry of entries) {
const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS);
const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as
| { id: number; recorded_at: number }
| undefined;
if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) {
this.#updateUsageHistoryStmt.run(
entry.recordedAt,
entry.email ?? null,
entry.accountId ?? null,
entry.label,
entry.windowLabel ?? null,
entry.usedFraction ?? null,
entry.status ?? null,
entry.resetsAt ?? null,
last.id,
);
continue;
}
this.#insertUsageHistoryStmt.run(
entry.recordedAt,
entry.provider,
entry.accountKey,
entry.email ?? null,
entry.accountId ?? null,
entry.limitId,
entry.label,
entry.windowLabel ?? null,
entry.usedFraction ?? null,
entry.status ?? null,
entry.resetsAt ?? null,
);
}
} catch {
// History is best-effort; never break the usage fetch path.
}
}
listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] {
try {
const provider = query?.provider ?? null;
const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{
recorded_at: number;
provider: string;
account_key: string;
email: string | null;
account_id: string | null;
limit_id: string;
label: string;
window_label: string | null;
used_fraction: number | null;
status: string | null;
resets_at: number | null;
}>;
return rows.map(row => ({
recordedAt: row.recorded_at,
provider: row.provider as Provider,
accountKey: row.account_key,
email: row.email ?? undefined,
accountId: row.account_id ?? undefined,
limitId: row.limit_id,
label: row.label,
windowLabel: row.window_label ?? undefined,
usedFraction: row.used_fraction ?? undefined,
status: (row.status ?? undefined) as UsageHistoryEntry["status"],
resetsAt: row.resets_at ?? undefined,
}));
} catch {
return [];
}
}
// ─── Convenience methods for CLI ────────────────────────────────────────
/**
@@ -4744,6 +4926,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
this.#getCacheIncludingExpiredStmt.finalize();
this.#upsertCacheStmt.finalize();
this.#deleteExpiredCacheStmt.finalize();
this.#insertUsageHistoryStmt.finalize();
this.#lastUsageHistoryStmt.finalize();
this.#listUsageHistoryStmt.finalize();
this.#updateUsageHistoryStmt.finalize();
this.#db.close();
}
}
+49
View File
@@ -86,6 +86,55 @@ export interface UsageReport {
raw?: unknown;
}
/**
* Resolve a limit's used fraction (0..1; >1 means overage) from whichever
* amount fields the provider populated. Precedence mirrors the usage UIs:
* explicit fraction > used/limit > percent-unit used > inverted remaining.
*/
export function resolveUsedFraction(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;
}
/**
* One recorded usage-limit snapshot: a single limit window of one account at
* a point in time. The usage cache itself is latest-snapshot-only; history
* rows are appended by the auth storage layer whenever a fresh report is
* fetched, so limit utilization stays inspectable over time.
*/
export interface UsageHistoryEntry {
/** Epoch ms the report was fetched. */
recordedAt: number;
provider: Provider;
/** Stable credential identity key (account/email/project derived). */
accountKey: string;
email?: string;
accountId?: string;
/** {@link UsageLimit.id} of the recorded window. */
limitId: string;
/** Human label of the limit. */
label: string;
windowLabel?: string;
/** Used fraction (0..1) when resolvable. */
usedFraction?: number;
status?: UsageStatus;
/** Epoch ms the window resets, when known. */
resetsAt?: number;
}
/** Filter for reading recorded usage history. */
export interface UsageHistoryQuery {
provider?: string;
/** Inclusive lower bound on {@link UsageHistoryEntry.recordedAt} (epoch ms). */
sinceMs?: number;
}
// ─── Zod schemas (wire-shape validation for the broker `/v1/usage` endpoint) ─
export const usageUnitSchema = z.enum(["percent", "tokens", "requests", "usd", "minutes", "bytes", "unknown"]);
@@ -0,0 +1,188 @@
/**
* Usage history contracts:
*
* 1. The SQLite store downsamples history to at most one row per hour per
* account window — a snapshot landing in the same hour bucket as the
* series' latest row overwrites it in place (latest value wins).
* 2. Series are independent per (provider, account, limit window).
* 3. `listUsageHistory` filters by provider / sinceMs and returns rows
* oldest-first.
* 4. `cleanExpiredCache` purges expired cache rows but NEVER usage history
* (the hourly cap is the only storage bound; nothing else is pruned).
* 5. AuthStorage appends one history row per limit, attributed to the
* fetched credential, whenever a fresh usage report lands.
*/
import { Database } from "bun:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage";
import type { UsageHistoryEntry, UsageReport } from "@oh-my-pi/pi-ai/usage";
import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude";
const HOUR = 3_600_000;
// Hour-aligned base so bucket boundaries in the tests are explicit.
const T0 = Math.floor(Date.parse("2026-06-12T10:00:00Z") / HOUR) * HOUR;
function entry(overrides: Partial<UsageHistoryEntry>): UsageHistoryEntry {
return {
recordedAt: T0,
provider: "anthropic",
accountKey: "oauth|account:account-1|email:a@example.com",
email: "a@example.com",
accountId: "account-1",
limitId: "anthropic:5h",
label: "5 Hour",
windowLabel: "5 Hour",
usedFraction: 0.1,
status: "ok",
resetsAt: T0 + 5 * HOUR,
...overrides,
};
}
describe("SqliteAuthCredentialStore usage history", () => {
let store: SqliteAuthCredentialStore;
beforeEach(() => {
store = new SqliteAuthCredentialStore(new Database(":memory:"));
});
afterEach(() => {
store.close();
});
it("downsamples to one row per hour per series: same-bucket snapshots overwrite in place", () => {
store.recordUsageSnapshots([entry({ recordedAt: T0, usedFraction: 0.1 })]);
store.recordUsageSnapshots([entry({ recordedAt: T0 + 10 * 60_000, usedFraction: 0.5, status: "warning" })]);
const sameBucket = store.listUsageHistory();
expect(sameBucket).toHaveLength(1);
expect(sameBucket[0]?.recordedAt).toBe(T0 + 10 * 60_000);
expect(sameBucket[0]?.usedFraction).toBe(0.5);
expect(sameBucket[0]?.status).toBe("warning");
store.recordUsageSnapshots([entry({ recordedAt: T0 + HOUR + 60_000, usedFraction: 0.7 })]);
const nextBucket = store.listUsageHistory();
expect(nextBucket).toHaveLength(2);
expect(nextBucket.map(row => row.usedFraction)).toEqual([0.5, 0.7]);
});
it("keeps independent series per account and per limit window", () => {
store.recordUsageSnapshots([
entry({ usedFraction: 0.2 }),
entry({ limitId: "anthropic:7d", label: "7 Day", windowLabel: "7 Day", usedFraction: 0.4 }),
entry({
accountKey: "oauth|account:account-2|email:b@example.com",
email: "b@example.com",
usedFraction: 0.9,
}),
]);
const rows = store.listUsageHistory();
expect(rows).toHaveLength(3);
expect(new Set(rows.map(row => `${row.accountKey}:${row.limitId}`)).size).toBe(3);
});
it("filters by provider and sinceMs, oldest first", () => {
store.recordUsageSnapshots([
entry({ recordedAt: T0 + 2 * HOUR, usedFraction: 0.6 }),
entry({ provider: "openai-codex", limitId: "codex:5h", recordedAt: T0 }),
entry({ recordedAt: T0, usedFraction: 0.2 }),
]);
expect(store.listUsageHistory({ provider: "openai-codex" })).toHaveLength(1);
const anthropic = store.listUsageHistory({ provider: "anthropic" });
expect(anthropic.map(row => row.recordedAt)).toEqual([T0, T0 + 2 * HOUR]);
const recent = store.listUsageHistory({ sinceMs: T0 + HOUR });
expect(recent).toHaveLength(1);
expect(recent[0]?.usedFraction).toBe(0.6);
});
it("cleanExpiredCache purges expired cache rows but never usage history", () => {
store.setCache("usage_cache:report:test", "{}", Math.floor(Date.now() / 1000) - 60);
// Ancient row — must survive cleanup; there is no retention pruning.
store.recordUsageSnapshots([entry({ recordedAt: T0 - 365 * 24 * HOUR })]);
store.cleanExpiredCache();
expect(store.getCache("usage_cache:report:test", { includeExpired: true })).toBeNull();
expect(store.listUsageHistory()).toHaveLength(1);
});
});
describe("AuthStorage usage history recording", () => {
let store: SqliteAuthCredentialStore;
let storage: AuthStorage;
beforeEach(async () => {
store = new SqliteAuthCredentialStore(new Database(":memory:"));
store.upsertAuthCredentialForProvider("anthropic", {
type: "oauth",
access: "oat-1",
refresh: "refresh-1",
expires: Date.now() + HOUR,
accountId: "account-1",
email: "a@example.com",
});
// Restrict the resolver to anthropic so AuthStorage doesn't fan out real
// network fetches for providers with *_API_KEY env vars on the test host.
storage = new AuthStorage(store, {
usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined),
});
await storage.reload();
});
afterEach(() => {
storage.close();
vi.restoreAllMocks();
});
it("appends one row per limit on a fresh fetch, attributed to the credential", async () => {
const fetchedAt = Date.now();
const report: UsageReport = {
provider: "anthropic",
fetchedAt,
limits: [
{
id: "anthropic:5h",
label: "5 Hour",
scope: { provider: "anthropic", windowId: "5h" },
window: { id: "5h", label: "5 Hour", resetsAt: fetchedAt + 5 * HOUR },
amount: { usedFraction: 0.42, unit: "percent" },
status: "ok",
},
{
id: "anthropic:7d",
label: "7 Day",
scope: { provider: "anthropic", windowId: "7d" },
window: { id: "7d", label: "7 Day" },
amount: { used: 84, limit: 100, unit: "percent" },
status: "warning",
},
],
metadata: { email: "a@example.com" },
};
vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => report);
await storage.fetchUsageReports();
const rows = storage.listUsageHistory();
expect(rows).toHaveLength(2);
const fiveHour = rows.find(row => row.limitId === "anthropic:5h");
expect(fiveHour?.provider).toBe("anthropic");
expect(fiveHour?.usedFraction).toBe(0.42);
expect(fiveHour?.email).toBe("a@example.com");
expect(fiveHour?.windowLabel).toBe("5 Hour");
expect(fiveHour?.resetsAt).toBe(fetchedAt + 5 * HOUR);
expect(fiveHour?.recordedAt).toBe(fetchedAt);
// Stable identity key derived from the credential, not the report.
expect(fiveHour?.accountKey).toContain("email:a@example.com");
// used/limit fallback resolves a fraction even without usedFraction.
const sevenDay = rows.find(row => row.limitId === "anthropic:7d");
expect(sevenDay?.usedFraction).toBeCloseTo(0.84);
expect(sevenDay?.status).toBe("warning");
});
});
+5 -1
View File
@@ -1,9 +1,13 @@
# Changelog
## [Unreleased]
### Added
- Added `--history` mode to `omp usage` to show recorded usage-limit history instead of a single live snapshot
- Added `--days` (`-d`) support for `omp usage --history` to control the history window (default 7 days)
- Added historical usage output for `omp usage --history`, including per-limit sparklines with latest and peak percentages
- Added `--json` support in `omp usage --history` to emit timestamped usage-history snapshot data
- Added guidance and exit code 1 when no usage history is available for the selected window
- Added an anchored Subagents HUD above the editor (next to the Todos block) listing every running subagent as `Id: description`; rows appear on spawn and the block clears itself when the last subagent finishes
- Added `/reset-usage` command to spend a saved Codex rate-limit reset by running `/reset-usage <active|account id|email>`, with a TUI selector flow for interactive reset redemption
- Added `snapcompact.systemPrompt` enum modes `none`, `agents-md`, and `all`, so users can disable system-prompt imaging, image only loaded AGENTS.md/context-file instruction sections, or image the full system prompt
+185 -16
View File
@@ -7,7 +7,14 @@
* 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 {
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";
@@ -19,6 +26,10 @@ 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. */
@@ -139,20 +150,9 @@ function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountId
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);
const fraction = resolveUsedFraction(limit);
if (fraction === undefined) return "unknown";
if (fraction >= 1) return "exhausted";
if (fraction >= 0.8) return "warning";
@@ -208,7 +208,7 @@ function describeAmount(limit: UsageLimit): string {
} else if (absoluteUnit && amount.remaining !== undefined) {
parts.push(`${formatUnitValue(amount.remaining, amount.unit)}${UNIT_SUFFIX[amount.unit]} left`);
}
const fraction = resolveFraction(limit);
const fraction = resolveUsedFraction(limit);
if (fraction !== undefined) {
parts.push(`${(fraction * 100).toFixed(1)}% used`);
} else if (amount.remainingFraction !== undefined) {
@@ -219,7 +219,7 @@ function describeAmount(limit: UsageLimit): string {
}
function renderBar(limit: UsageLimit): string {
const fraction = resolveFraction(limit);
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);
@@ -377,7 +377,7 @@ export function computeProviderWindowStats(reports: UsageReport[]): ProviderWind
for (const report of reports) {
const accountMax = new Map<string, number>();
for (const limit of report.limits) {
const fraction = resolveFraction(limit);
const fraction = resolveUsedFraction(limit);
if (fraction === undefined) continue;
const durationMs = limit.window?.durationMs;
const key =
@@ -484,6 +484,144 @@ export function formatUsageBreakdown(
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();
@@ -543,6 +681,37 @@ function redactReportForJson(
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({
@@ -15,6 +15,11 @@ export default class Usage extends Command {
description: "Redact account emails/ids (shortest unique prefix) for sharing screenshots",
default: false,
}),
history: Flags.boolean({
description: "Show recorded usage-limit history (hourly snapshots) instead of a live snapshot",
default: false,
}),
days: Flags.integer({ char: "d", description: "History window in days (with --history)", default: 7 }),
};
static examples = [
@@ -22,6 +27,7 @@ export default class Usage extends Command {
"# Only Anthropic accounts\n omp usage --provider anthropic",
"# Redact account identifiers for screenshots\n omp usage --redact",
"# Machine-readable output\n omp usage --json",
"# Usage-limit trend over the last 30 days\n omp usage --history --days 30",
];
async run(): Promise<void> {
@@ -30,6 +36,8 @@ export default class Usage extends Command {
json: flags.json,
provider: flags.provider,
redact: flags.redact,
history: flags.history,
days: flags.days,
});
}
}
@@ -6,6 +6,7 @@ import {
collectUnreportedAccounts,
computeProviderWindowStats,
formatUsageBreakdown,
formatUsageHistory,
type UsageAccountIdentity,
} from "@oh-my-pi/pi-coding-agent/cli/usage-cli";
@@ -184,3 +185,47 @@ describe("formatUsageBreakdown", () => {
for (const mask of redaction.values()) expect(text).toContain(mask);
});
});
describe("formatUsageHistory", () => {
const NOW = Date.now();
const SINCE = NOW - 7 * 24 * HOUR;
function historyEntry(recordedAt: number, usedFraction: number | undefined, overrides?: Record<string, unknown>) {
return {
recordedAt,
provider: "anthropic",
accountKey: "oauth|email:dummy.primary@example.test",
email: "dummy.primary@example.test",
limitId: "anthropic:5h",
label: "Session",
windowLabel: "5 Hour",
usedFraction,
status: "ok" as const,
...overrides,
};
}
const entries = [
historyEntry(SINCE + HOUR, 0.2),
historyEntry(SINCE + 30 * HOUR, 0.95),
historyEntry(NOW - HOUR, 0.4),
];
it("renders one series per account window with latest and peak percentages", () => {
const text = stripVTControlCharacters(formatUsageHistory(entries, SINCE, NOW));
expect(text).toContain("Anthropic");
expect(text).toContain("dummy.primary@example.test");
// Window label is appended when the limit label doesn't carry it.
expect(text).toContain("Session (5 Hour)");
expect(text).toContain("latest 40.0%");
expect(text).toContain("peak 95.0%");
expect(text).toContain("3 snapshots");
});
it("redacts account labels through the provided map", () => {
const redaction = buildRedactionMap(["dummy.primary@example.test"]);
const text = stripVTControlCharacters(formatUsageHistory(entries, SINCE, NOW, redaction));
expect(text).not.toContain("dummy.primary@example.test");
expect(text).toContain("du*");
});
});