@smithers-orchestrator/usage 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 William Cory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @smithers-orchestrator/usage
2
+
3
+ Report how much rate limit or subscription quota each registered Smithers account
4
+ has consumed. Powers `smithers usage`.
5
+
6
+ ```
7
+ $ smithers usage
8
+ ACCOUNT PROVIDER PLAN WINDOW USED RESETS IN
9
+ claude-work claude-code max 5-hour session 33% 2h 41m
10
+ claude-work claude-code max weekly 13% 5d 3h
11
+ codex-main codex pro 5-hour 12% 4h 02m
12
+ codex-main codex pro weekly 40% 6d 1h
13
+ openai-ci openai-api — requests/min 820/1000 left 0m 42s
14
+ ```
15
+
16
+ ## What it reads, per provider
17
+
18
+ The numbers live in three incompatible shapes, so every adapter normalizes to one
19
+ `UsageReport`.
20
+
21
+ | Provider | Source | Auth read from |
22
+ | --- | --- | --- |
23
+ | `claude-code` | `GET api.anthropic.com/api/oauth/usage` (5h + weekly %) | `<configDir>/.credentials.json` or macOS Keychain `Claude Code-credentials` |
24
+ | `codex` | `GET chatgpt.com/backend-api/wham/usage` (5h + weekly %) | `<configDir>/auth.json` |
25
+ | `anthropic-api` | live `anthropic-ratelimit-*` headers off `count_tokens` | account `apiKey` |
26
+ | `openai-api` | live `x-ratelimit-*` headers off a `max_tokens:1` POST | account `apiKey` |
27
+ | `gemini` / `antigravity` / `gemini-api` | none yet (Google exposes no live quota) | — |
28
+ | `kimi`, others | none | — |
29
+
30
+ The subscription endpoints (`claude-code`, `codex`) are undocumented: they are the
31
+ same endpoints the official CLIs call, reachable by reading the CLI's own OAuth
32
+ token off disk. They are best-effort — any failure degrades to a `source: "none"`
33
+ report with a readable reason, never an exception.
34
+
35
+ ## Design
36
+
37
+ - `getAccountUsage(account)` is the dispatcher. It switches on `account.provider`,
38
+ mirroring `accountToProviderEnv` in `@smithers-orchestrator/accounts`.
39
+ - `getUsageForAccounts(accounts, opts)` fans out in parallel through an on-disk
40
+ cache (`usage-cache.json`). Cached reports come back with `stale: true`. The
41
+ cache enforces a hard 180s floor for `claude-code` because its usage endpoint
42
+ 429s aggressively below that.
43
+ - Credentials are read on the host that owns them. Only the normalized
44
+ `UsageReport` ever leaves the process — no token is returned or logged.
45
+
46
+ ## Safety
47
+
48
+ - The Claude usage endpoint requires `User-Agent: claude-code/<ver>` and
49
+ `anthropic-beta: oauth-2025-04-20`. Override the UA with
50
+ `SMITHERS_CLAUDE_CODE_UA` if the installed version matters.
51
+ - `--fresh` bypasses the soft cache but never the hard per-provider floor.
52
+
53
+ See `.smithers/specs/usage-and-limits.md` for the full design and the phased plan
54
+ (gateway RPC, studio dashboard, Google local-estimate accounting).
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@smithers-orchestrator/usage",
3
+ "version": "0.23.0",
4
+ "description": "Report how much rate limit or subscription quota each registered Smithers account has consumed, across Claude, Codex, OpenAI, Anthropic, and Google providers.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "import": "./src/index.js",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./*": {
14
+ "types": "./src/index.d.ts",
15
+ "import": "./src/*.js",
16
+ "default": "./src/*.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "src/"
21
+ ],
22
+ "dependencies": {
23
+ "@smithers-orchestrator/errors": "0.23.0",
24
+ "@smithers-orchestrator/accounts": "0.23.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "latest",
28
+ "typescript": "~5.9.3"
29
+ },
30
+ "scripts": {
31
+ "test": "bun test tests",
32
+ "typecheck": "tsc -p tsconfig.json --noEmit",
33
+ "build": "tsup --dts-only"
34
+ }
35
+ }
@@ -0,0 +1,33 @@
1
+ import type { AccountProvider } from "@smithers-orchestrator/accounts";
2
+ import type { UsageSource } from "./UsageSource";
3
+ import type { UsageWindow } from "./UsageWindow";
4
+
5
+ /**
6
+ * Normalized usage for a single registered account. Every adapter — subscription
7
+ * utilization, API-key headers, local estimate — produces this same shape so the
8
+ * CLI, gateway, and UI render one model.
9
+ */
10
+ export type UsageReport = {
11
+ /** The account's label in `~/.smithers/accounts.json`. */
12
+ accountLabel: string;
13
+ /** The account's provider. */
14
+ provider: AccountProvider;
15
+ /** How this account authenticates. */
16
+ authMode: "subscription" | "api-key";
17
+ /** Where the numbers came from. */
18
+ source: UsageSource;
19
+ /** Quota windows, possibly empty when `source` is `none`. */
20
+ windows: UsageWindow[];
21
+ /** Plan/tier label if the provider reports one, e.g. "max", "pro". */
22
+ planType?: string;
23
+ /** Pay-as-you-go credit balance, if the provider reports one (Codex). */
24
+ credits?: { hasCredits: boolean; unlimited: boolean; balance?: string };
25
+ /** ISO-8601 timestamp of when this report was produced. */
26
+ fetchedAt: string;
27
+ /** True when served from cache past its soft TTL. */
28
+ stale: boolean;
29
+ /** True when the windows are locally estimated, not provider-authoritative. */
30
+ estimate: boolean;
31
+ /** Human-readable reason when `source` is `none` or a probe failed. */
32
+ error?: string;
33
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Where a usage report's numbers came from.
3
+ *
4
+ * - `oauth` — an authenticated subscription usage endpoint (Claude, Codex).
5
+ * - `headers` — live rate-limit response headers from an API-key request.
6
+ * - `local` — estimated locally from token logs (Google providers).
7
+ * - `none` — the provider exposes no usage surface, or the probe failed.
8
+ */
9
+ export type UsageSource = "oauth" | "headers" | "local" | "none";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * One quota window for an account: a 5-hour session, a weekly cap, a per-minute
3
+ * request bucket, and so on.
4
+ *
5
+ * The `unit` decides which fields are meaningful:
6
+ * - `percent` — subscription utilization; read `usedPercent` (0–100).
7
+ * - `count` — API-key buckets; read `limit`, `remaining`, `used`.
8
+ * - `estimated` — locally estimated; read `usedPercent`/`used`/`limit`, treat as
9
+ * a lower bound, never as authoritative.
10
+ */
11
+ export type UsageWindow = {
12
+ /** Stable id, e.g. "5h" | "weekly" | "requests-per-min" | "tokens-per-min". */
13
+ id: string;
14
+ /** Human label, e.g. "5-hour session". */
15
+ label: string;
16
+ /** Which fields below are meaningful. */
17
+ unit: "percent" | "count" | "estimated";
18
+ /** 0–100. Set for `percent` and `estimated`. */
19
+ usedPercent?: number;
20
+ /** Absolute amount consumed. Set for `count` and `estimated`. */
21
+ used?: number;
22
+ /** Absolute cap. Set for `count` and `estimated`. */
23
+ limit?: number;
24
+ /** `limit - used`. Set for `count`. */
25
+ remaining?: number;
26
+ /** ISO-8601 timestamp when this window rolls over. */
27
+ resetsAt?: string;
28
+ };
@@ -0,0 +1,51 @@
1
+ import { parseAnthropicRateLimitHeaders } from "./parseAnthropicRateLimitHeaders.js";
2
+
3
+ /** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
4
+
5
+ const COUNT_TOKENS_URL = "https://api.anthropic.com/v1/messages/count_tokens";
6
+
7
+ /** The count_tokens probe needs a valid model id but generates no output tokens. */
8
+ const PROBE_MODEL = process.env.SMITHERS_ANTHROPIC_PROBE_MODEL ?? "claude-sonnet-4-20250514";
9
+
10
+ /**
11
+ * Reads live Anthropic rate-limit headers for an API-key account. Uses the
12
+ * `count_tokens` endpoint, which returns the rate-limit header family without
13
+ * producing output tokens.
14
+ *
15
+ * @param {{ apiKey?: string }} account
16
+ * @returns {Promise<UsageProbe>}
17
+ */
18
+ export async function anthropicHeaderUsage(account) {
19
+ const apiKey = account.apiKey;
20
+ if (!apiKey) {
21
+ return { source: "none", error: "Account has no API key set" };
22
+ }
23
+ try {
24
+ const res = await fetch(COUNT_TOKENS_URL, {
25
+ method: "POST",
26
+ headers: {
27
+ "x-api-key": apiKey,
28
+ "anthropic-version": "2023-06-01",
29
+ "content-type": "application/json",
30
+ },
31
+ body: JSON.stringify({ model: PROBE_MODEL, messages: [{ role: "user", content: "hi" }] }),
32
+ signal: AbortSignal.timeout(6_000),
33
+ });
34
+ if (res.status === 401) {
35
+ return { source: "none", error: "ANTHROPIC_API_KEY rejected (401)" };
36
+ }
37
+ const get = (name) => res.headers.get(name);
38
+ const windows = parseAnthropicRateLimitHeaders(get);
39
+ if (res.status === 429) {
40
+ const retryAfter = res.headers.get("retry-after");
41
+ return {
42
+ source: "headers",
43
+ windows,
44
+ error: `Rate limited (429)${retryAfter ? ` — retry after ${retryAfter}s` : ""}`,
45
+ };
46
+ }
47
+ return { source: "headers", windows };
48
+ } catch (err) {
49
+ return { source: "none", error: `Anthropic header probe failed: ${err instanceof Error ? err.message : String(err)}` };
50
+ }
51
+ }
@@ -0,0 +1,43 @@
1
+ import { API_KEY_PROVIDERS } from "@smithers-orchestrator/accounts";
2
+
3
+ /** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
4
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
5
+ /** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
6
+
7
+ /**
8
+ * The partial result an adapter returns. The dispatcher wraps it with the
9
+ * account identity and timestamp to form a complete {@link UsageReport}.
10
+ *
11
+ * @typedef {object} UsageProbe
12
+ * @property {import("./UsageSource.ts").UsageSource} source
13
+ * @property {UsageWindow[]} [windows]
14
+ * @property {string} [planType]
15
+ * @property {{ hasCredits: boolean; unlimited: boolean; balance?: string }} [credits]
16
+ * @property {boolean} [estimate]
17
+ * @property {string} [error]
18
+ */
19
+
20
+ /**
21
+ * Assembles a full usage report from an account and an adapter probe. Keeps the
22
+ * adapters free of repeated identity/timestamp boilerplate.
23
+ *
24
+ * @param {Account} account
25
+ * @param {UsageProbe} probe
26
+ * @param {{ nowIso?: string }} [options]
27
+ * @returns {UsageReport}
28
+ */
29
+ export function buildUsageReport(account, probe, options = {}) {
30
+ return {
31
+ accountLabel: account.label,
32
+ provider: account.provider,
33
+ authMode: API_KEY_PROVIDERS.has(account.provider) ? "api-key" : "subscription",
34
+ source: probe.source,
35
+ windows: probe.windows ?? [],
36
+ planType: probe.planType,
37
+ credits: probe.credits,
38
+ fetchedAt: options.nowIso ?? new Date().toISOString(),
39
+ stale: false,
40
+ estimate: probe.estimate ?? false,
41
+ error: probe.error,
42
+ };
43
+ }
@@ -0,0 +1,52 @@
1
+ import { parseClaudeOauthUsage } from "./parseClaudeOauthUsage.js";
2
+ import { readClaudeCredentials } from "./readClaudeCredentials.js";
3
+
4
+ /** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
5
+
6
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
7
+
8
+ /**
9
+ * The User-Agent must start with `claude-code/`; without it the endpoint drops
10
+ * the caller into an aggressively rate-limited bucket. Overridable for when the
11
+ * real installed version matters.
12
+ */
13
+ const USER_AGENT = process.env.SMITHERS_CLAUDE_CODE_UA ?? "claude-code/2.0.0";
14
+
15
+ /**
16
+ * Probes the Claude Code subscription usage endpoint for an account's 5-hour and
17
+ * weekly utilization. Undocumented and best-effort: any failure degrades to a
18
+ * `none` report with a readable reason.
19
+ *
20
+ * @param {{ configDir?: string }} account
21
+ * @returns {Promise<UsageProbe>}
22
+ */
23
+ export async function claudeOauthUsage(account) {
24
+ const creds = readClaudeCredentials(account);
25
+ if (!creds) {
26
+ return { source: "none", error: "No Claude OAuth credentials in configDir or Keychain" };
27
+ }
28
+ try {
29
+ const res = await fetch(USAGE_URL, {
30
+ headers: {
31
+ Authorization: `Bearer ${creds.accessToken}`,
32
+ "anthropic-beta": "oauth-2025-04-20",
33
+ "User-Agent": USER_AGENT,
34
+ "Content-Type": "application/json",
35
+ },
36
+ signal: AbortSignal.timeout(6_000),
37
+ });
38
+ if (res.status === 401) {
39
+ return { source: "none", error: "Claude OAuth token rejected (401); run `claude` to refresh" };
40
+ }
41
+ if (res.status === 429) {
42
+ return { source: "none", error: "Claude usage endpoint rate limited (429); try again shortly" };
43
+ }
44
+ if (!res.ok) {
45
+ return { source: "none", error: `Claude usage endpoint returned ${res.status}` };
46
+ }
47
+ const payload = await res.json();
48
+ return { source: "oauth", windows: parseClaudeOauthUsage(payload) };
49
+ } catch (err) {
50
+ return { source: "none", error: `Claude usage probe failed: ${err instanceof Error ? err.message : String(err)}` };
51
+ }
52
+ }
@@ -0,0 +1,45 @@
1
+ import { parseCodexUsage } from "./parseCodexUsage.js";
2
+ import { readCodexCredentials } from "./readCodexCredentials.js";
3
+
4
+ /** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
5
+
6
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
7
+
8
+ /**
9
+ * Probes the Codex ChatGPT-subscription usage endpoint for an account's 5-hour
10
+ * and weekly windows. This is the same data the Codex `/status` view shows and
11
+ * does not spend a turn. Undocumented and best-effort.
12
+ *
13
+ * @param {{ configDir?: string }} account
14
+ * @returns {Promise<UsageProbe>}
15
+ */
16
+ export async function codexWhamUsage(account) {
17
+ const creds = readCodexCredentials(account);
18
+ if (!creds) {
19
+ return { source: "none", error: "No Codex ChatGPT credentials in configDir/auth.json" };
20
+ }
21
+ try {
22
+ /** @type {Record<string, string>} */
23
+ const headers = {
24
+ Authorization: `Bearer ${creds.accessToken}`,
25
+ "User-Agent": "codex-cli",
26
+ Accept: "application/json",
27
+ };
28
+ if (creds.accountId) headers["ChatGPT-Account-Id"] = creds.accountId;
29
+ const res = await fetch(USAGE_URL, {
30
+ headers,
31
+ signal: AbortSignal.timeout(6_000),
32
+ });
33
+ if (res.status === 401) {
34
+ return { source: "none", error: "Codex token rejected (401); run `codex` to refresh" };
35
+ }
36
+ if (!res.ok) {
37
+ return { source: "none", error: `Codex usage endpoint returned ${res.status}` };
38
+ }
39
+ const payload = await res.json();
40
+ const { windows, planType, credits } = parseCodexUsage(payload);
41
+ return { source: "oauth", windows, planType, credits };
42
+ } catch (err) {
43
+ return { source: "none", error: `Codex usage probe failed: ${err instanceof Error ? err.message : String(err)}` };
44
+ }
45
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Decodes the claims (the middle segment) of a JWT without verifying its
3
+ * signature. Used to read the `chatgpt_account_id` claim out of the Codex
4
+ * `id_token` when `auth.json` does not carry `tokens.account_id` directly.
5
+ *
6
+ * Verification is intentionally skipped: the token already authenticated the
7
+ * user with the provider, and we only read a non-secret routing claim from it.
8
+ *
9
+ * Returns an empty object for anything that is not a decodable JWT.
10
+ *
11
+ * @param {string | null | undefined} token
12
+ * @returns {Record<string, unknown>}
13
+ */
14
+ export function decodeJwtClaims(token) {
15
+ if (typeof token !== "string") return {};
16
+ const parts = token.split(".");
17
+ if (parts.length < 2) return {};
18
+ try {
19
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
20
+ const json = Buffer.from(payload, "base64").toString("utf8");
21
+ const claims = JSON.parse(json);
22
+ return claims && typeof claims === "object" ? claims : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
@@ -0,0 +1,16 @@
1
+ import { humanizeDurationShort } from "./humanizeDurationShort.js";
2
+
3
+ /**
4
+ * Formats an ISO reset timestamp as a relative "resets in" string. Returns an
5
+ * empty string when there is no timestamp, and `"now"` when it is in the past.
6
+ *
7
+ * @param {string | undefined} resetsAt
8
+ * @param {number} [nowMs]
9
+ * @returns {string}
10
+ */
11
+ export function formatRelativeReset(resetsAt, nowMs = Date.now()) {
12
+ if (!resetsAt) return "";
13
+ const ms = Date.parse(resetsAt);
14
+ if (Number.isNaN(ms)) return "";
15
+ return humanizeDurationShort((ms - nowMs) / 1000);
16
+ }
@@ -0,0 +1,59 @@
1
+ import { formatRelativeReset } from "./formatRelativeReset.js";
2
+
3
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
4
+ /** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
5
+
6
+ /**
7
+ * Renders the "used" cell for one window.
8
+ *
9
+ * @param {UsageWindow} w
10
+ * @returns {string}
11
+ */
12
+ function usedCell(w) {
13
+ if (w.unit === "percent") {
14
+ return w.usedPercent === undefined ? "" : `${Math.round(w.usedPercent)}%`;
15
+ }
16
+ if (w.unit === "estimated") {
17
+ if (w.used !== undefined && w.limit !== undefined) return `~${w.used}/${w.limit}`;
18
+ if (w.usedPercent !== undefined) return `~${Math.round(w.usedPercent)}%`;
19
+ return "~?";
20
+ }
21
+ // count
22
+ if (w.remaining !== undefined && w.limit !== undefined) return `${w.remaining}/${w.limit} left`;
23
+ if (w.remaining !== undefined) return `${w.remaining} left`;
24
+ if (w.used !== undefined && w.limit !== undefined) return `${w.used}/${w.limit}`;
25
+ return "";
26
+ }
27
+
28
+ /**
29
+ * Renders an array of usage reports as an aligned text table. Pure: pass a fixed
30
+ * `nowMs` in tests to get deterministic "resets in" values.
31
+ *
32
+ * @param {UsageReport[]} reports
33
+ * @param {number} [nowMs]
34
+ * @returns {string}
35
+ */
36
+ export function formatUsageReports(reports, nowMs = Date.now()) {
37
+ if (reports.length === 0) {
38
+ return "No accounts registered. Add one with `smithers agents add`.";
39
+ }
40
+ const header = ["ACCOUNT", "PROVIDER", "PLAN", "WINDOW", "USED", "RESETS IN"];
41
+ /** @type {string[][]} */
42
+ const rows = [header];
43
+ for (const r of reports) {
44
+ const plan = r.planType ?? (r.authMode === "api-key" ? "—" : "");
45
+ if (r.windows.length === 0) {
46
+ const note = r.error ?? (r.source === "none" ? "not supported" : "");
47
+ rows.push([r.accountLabel, r.provider, plan, "—", note, ""]);
48
+ continue;
49
+ }
50
+ for (const w of r.windows) {
51
+ const used = usedCell(w) + (w.unit === "estimated" ? " (est)" : "");
52
+ rows.push([r.accountLabel, r.provider, plan, w.label, used, formatRelativeReset(w.resetsAt, nowMs)]);
53
+ }
54
+ }
55
+ const widths = header.map((_, col) => Math.max(...rows.map((row) => row[col].length)));
56
+ return rows
57
+ .map((row) => row.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd())
58
+ .join("\n");
59
+ }
@@ -0,0 +1,50 @@
1
+ import { anthropicHeaderUsage } from "./anthropicHeaderUsage.js";
2
+ import { buildUsageReport } from "./buildUsageReport.js";
3
+ import { claudeOauthUsage } from "./claudeOauthUsage.js";
4
+ import { codexWhamUsage } from "./codexWhamUsage.js";
5
+ import { googleUsage } from "./googleUsage.js";
6
+ import { openaiHeaderUsage } from "./openaiHeaderUsage.js";
7
+
8
+ /** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
9
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
10
+
11
+ /**
12
+ * Routes an account to its usage adapter and returns a normalized report. This
13
+ * switch mirrors `accountToProviderEnv` in the accounts package so the two stay
14
+ * structurally aligned. Adapters never throw; they degrade to a `none` report.
15
+ *
16
+ * Credentials are read on the host that owns them and only the normalized report
17
+ * leaves this function — no token is ever returned or logged.
18
+ *
19
+ * @param {Account} account
20
+ * @returns {Promise<UsageReport>}
21
+ */
22
+ export async function getAccountUsage(account) {
23
+ const probe = await probeFor(account);
24
+ return buildUsageReport(account, probe);
25
+ }
26
+
27
+ /**
28
+ * @param {Account} account
29
+ * @returns {Promise<import("./buildUsageReport.js").UsageProbe>}
30
+ */
31
+ async function probeFor(account) {
32
+ switch (account.provider) {
33
+ case "claude-code":
34
+ return claudeOauthUsage(account);
35
+ case "codex":
36
+ return codexWhamUsage(account);
37
+ case "anthropic-api":
38
+ return anthropicHeaderUsage(account);
39
+ case "openai-api":
40
+ return openaiHeaderUsage(account);
41
+ case "gemini":
42
+ case "antigravity":
43
+ case "gemini-api":
44
+ return googleUsage(account);
45
+ case "kimi":
46
+ return { source: "none", error: "Kimi exposes no usage endpoint yet" };
47
+ default:
48
+ return { source: "none", error: `Usage not supported for provider "${account.provider}"` };
49
+ }
50
+ }
@@ -0,0 +1,75 @@
1
+ import { getAccountUsage } from "./getAccountUsage.js";
2
+ import { readUsageCache, writeUsageCache } from "./usageCache.js";
3
+
4
+ /** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
5
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
6
+
7
+ /**
8
+ * Soft refresh interval: within this age a cached report is reused on a normal
9
+ * run. `--fresh` bypasses it.
10
+ *
11
+ * @param {string} provider
12
+ * @returns {number}
13
+ */
14
+ function refreshIntervalMs(provider) {
15
+ switch (provider) {
16
+ case "claude-code": return 180_000;
17
+ case "codex": return 60_000;
18
+ default: return 30_000;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Hard floor: never re-probe faster than this, even with `--fresh`. The Claude
24
+ * usage endpoint 429s aggressively below 180s, so we protect it unconditionally.
25
+ *
26
+ * @param {string} provider
27
+ * @returns {number}
28
+ */
29
+ function hardFloorMs(provider) {
30
+ return provider === "claude-code" ? 180_000 : 0;
31
+ }
32
+
33
+ /**
34
+ * Gathers usage for many accounts in parallel, served through the on-disk cache.
35
+ * Cached reports come back with `stale: true`. The cache is read once and written
36
+ * once, so parallel probes never race on the file.
37
+ *
38
+ * @param {Account[]} accounts
39
+ * @param {{ fresh?: boolean; env?: NodeJS.ProcessEnv; nowMs?: number }} [options]
40
+ * @returns {Promise<UsageReport[]>}
41
+ */
42
+ export async function getUsageForAccounts(accounts, options = {}) {
43
+ const { fresh = false, env = process.env, nowMs = Date.now() } = options;
44
+ const cache = readUsageCache(env);
45
+ const decisions = accounts.map((account) => {
46
+ const entry = cache.entries[account.label];
47
+ const fetchedAt = entry?.report?.fetchedAt;
48
+ const parsed = typeof fetchedAt === "string" ? nowMs - Date.parse(fetchedAt) : Number.NaN;
49
+ const age = Number.isFinite(parsed) ? parsed : Infinity;
50
+ const useCache = Number.isFinite(parsed) && (
51
+ age < hardFloorMs(account.provider) ||
52
+ (!fresh && age < refreshIntervalMs(account.provider))
53
+ );
54
+ return { account, entry, useCache };
55
+ });
56
+ const reports = await Promise.all(decisions.map(async (d) => {
57
+ if (d.useCache && d.entry) return { ...d.entry.report, stale: true };
58
+ return getAccountUsage(d.account);
59
+ }));
60
+ let changed = false;
61
+ reports.forEach((report, i) => {
62
+ if (!decisions[i].useCache) {
63
+ cache.entries[report.accountLabel] = { report };
64
+ changed = true;
65
+ }
66
+ });
67
+ if (changed) {
68
+ try {
69
+ writeUsageCache(cache, env);
70
+ } catch {
71
+ // a cache write failure must not break the command
72
+ }
73
+ }
74
+ return reports;
75
+ }
@@ -0,0 +1,20 @@
1
+ /** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
2
+
3
+ /**
4
+ * Google (Gemini, Antigravity, Gemini API) exposes no live "remaining quota"
5
+ * surface to a personal-login or API-key client: there are no rate-limit
6
+ * response headers, only a 429 `RESOURCE_EXHAUSTED` after the wall is hit. The
7
+ * documented path forward is local token-log accounting against published caps
8
+ * (see `publishedCaps.js`), which depends on run-history integration and lands
9
+ * in a later phase. Until then this reports `none` honestly rather than inventing
10
+ * a number.
11
+ *
12
+ * @param {{ provider: string }} account
13
+ * @returns {Promise<UsageProbe>}
14
+ */
15
+ export async function googleUsage(account) {
16
+ return {
17
+ source: "none",
18
+ error: "Google exposes no live usage endpoint; local estimate not yet wired",
19
+ };
20
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Formats a number of seconds as a short human duration, e.g. `"2h 41m"`,
3
+ * `"5d 3h"`, `"42s"`. Used for "resets in" columns. Negative input renders as
4
+ * `"now"`.
5
+ *
6
+ * @param {number} seconds
7
+ * @returns {string}
8
+ */
9
+ export function humanizeDurationShort(seconds) {
10
+ if (!Number.isFinite(seconds) || seconds <= 0) return "now";
11
+ const total = Math.round(seconds);
12
+ const days = Math.floor(total / 86400);
13
+ const hours = Math.floor((total % 86400) / 3600);
14
+ const minutes = Math.floor((total % 3600) / 60);
15
+ const secs = total % 60;
16
+ if (days > 0) return `${days}d ${hours}h`;
17
+ if (hours > 0) return `${hours}h ${minutes}m`;
18
+ if (minutes > 0) return `${minutes}m ${secs}s`;
19
+ return `${secs}s`;
20
+ }