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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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*");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user