@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.
@@ -0,0 +1,77 @@
1
+ /** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
2
+
3
+ /**
4
+ * Maps a Codex rate-limit window's duration (in minutes) to a stable id/label.
5
+ * The Codex TUI labels windows the same way: ~300 min is the 5-hour window,
6
+ * ~10080 min is the weekly window.
7
+ *
8
+ * @param {number | undefined} minutes
9
+ * @param {string} fallbackId
10
+ * @returns {{ id: string; label: string }}
11
+ */
12
+ function labelForMinutes(minutes, fallbackId) {
13
+ if (minutes === undefined) return { id: fallbackId, label: fallbackId };
14
+ if (minutes <= 60) return { id: "hourly", label: `${minutes}-minute` };
15
+ if (minutes < 1440) return { id: "5h", label: `${Math.round(minutes / 60)}-hour` };
16
+ if (minutes < 20160) return { id: "weekly", label: "weekly" };
17
+ return { id: "monthly", label: "monthly" };
18
+ }
19
+
20
+ /**
21
+ * Builds a percent window from a Codex `{ used_percent, window_minutes, reset_at }`
22
+ * block. `reset_at` is unix seconds.
23
+ *
24
+ * @param {unknown} block
25
+ * @param {string} fallbackId
26
+ * @returns {UsageWindow | undefined}
27
+ */
28
+ function windowFrom(block, fallbackId) {
29
+ if (!block || typeof block !== "object") return undefined;
30
+ const b = /** @type {Record<string, unknown>} */ (block);
31
+ const usedPercent = typeof b.used_percent === "number" ? b.used_percent : undefined;
32
+ if (usedPercent === undefined) return undefined;
33
+ const minutes = typeof b.window_minutes === "number" ? b.window_minutes : undefined;
34
+ const { id, label } = labelForMinutes(minutes, fallbackId);
35
+ let resetsAt;
36
+ if (typeof b.reset_at === "number" && Number.isFinite(b.reset_at)) {
37
+ resetsAt = new Date(b.reset_at * 1000).toISOString();
38
+ }
39
+ return { id, label, unit: "percent", usedPercent, resetsAt };
40
+ }
41
+
42
+ /**
43
+ * Normalizes the Codex usage payload (from `GET /backend-api/wham/usage`, or the
44
+ * `codex.rate_limits` event) into windows plus plan and credit metadata. The
45
+ * rate-limit object may sit at the top level or under a `rate_limits` key; both
46
+ * shapes are accepted.
47
+ *
48
+ * @param {unknown} payload
49
+ * @returns {{ windows: UsageWindow[]; planType?: string; credits?: { hasCredits: boolean; unlimited: boolean; balance?: string } }}
50
+ */
51
+ export function parseCodexUsage(payload) {
52
+ if (!payload || typeof payload !== "object") return { windows: [] };
53
+ const p = /** @type {Record<string, unknown>} */ (payload);
54
+ const rl = /** @type {Record<string, unknown>} */ (
55
+ p.rate_limits && typeof p.rate_limits === "object" ? p.rate_limits : p
56
+ );
57
+ const windows = [];
58
+ const primary = windowFrom(rl.primary, "primary");
59
+ if (primary) windows.push(primary);
60
+ const secondary = windowFrom(rl.secondary, "secondary");
61
+ if (secondary) windows.push(secondary);
62
+
63
+ const planRaw = p.plan_type ?? rl.plan_type;
64
+ const planType = typeof planRaw === "string" ? planRaw : undefined;
65
+
66
+ const creditsRaw = p.credits ?? rl.credits;
67
+ let credits;
68
+ if (creditsRaw && typeof creditsRaw === "object") {
69
+ const c = /** @type {Record<string, unknown>} */ (creditsRaw);
70
+ credits = {
71
+ hasCredits: Boolean(c.has_credits),
72
+ unlimited: Boolean(c.unlimited),
73
+ balance: typeof c.balance === "string" ? c.balance : undefined,
74
+ };
75
+ }
76
+ return { windows, planType, credits };
77
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Parses a Go-style duration string into seconds. OpenAI's rate-limit reset
3
+ * headers use this format, e.g. `"1s"`, `"6m0s"`, `"1h2m3s"`, `"800ms"`.
4
+ *
5
+ * Returns `undefined` for input it cannot parse, so callers can omit a reset
6
+ * time rather than render a wrong one.
7
+ *
8
+ * @param {string | null | undefined} value
9
+ * @returns {number | undefined}
10
+ */
11
+ export function parseDurationSeconds(value) {
12
+ if (value == null) return undefined;
13
+ const trimmed = value.trim();
14
+ if (trimmed === "") return undefined;
15
+ // Plain number of seconds, e.g. "30".
16
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
17
+ const n = Number(trimmed);
18
+ return Number.isFinite(n) ? n : undefined;
19
+ }
20
+ const re = /(\d+(?:\.\d+)?)(ms|us|µs|ns|s|m|h)/g;
21
+ let total = 0;
22
+ let matched = false;
23
+ let match;
24
+ while ((match = re.exec(trimmed)) !== null) {
25
+ matched = true;
26
+ const amount = Number(match[1]);
27
+ switch (match[2]) {
28
+ case "h": total += amount * 3600; break;
29
+ case "m": total += amount * 60; break;
30
+ case "s": total += amount; break;
31
+ case "ms": total += amount / 1000; break;
32
+ case "us":
33
+ case "µs": total += amount / 1_000_000; break;
34
+ case "ns": total += amount / 1_000_000_000; break;
35
+ }
36
+ }
37
+ return matched ? total : undefined;
38
+ }
@@ -0,0 +1,60 @@
1
+ import { parseDurationSeconds } from "./parseDurationSeconds.js";
2
+
3
+ /** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
4
+
5
+ /**
6
+ * @param {string | null | undefined} value
7
+ * @returns {number | undefined}
8
+ */
9
+ function int(value) {
10
+ if (value == null) return undefined;
11
+ const n = parseInt(value, 10);
12
+ return Number.isNaN(n) ? undefined : n;
13
+ }
14
+
15
+ /**
16
+ * @param {(name: string) => string | null | undefined} get
17
+ * @param {string} suffix
18
+ * @param {string} id
19
+ * @param {string} label
20
+ * @param {number} nowMs
21
+ * @returns {UsageWindow | undefined}
22
+ */
23
+ function countWindow(get, suffix, id, label, nowMs) {
24
+ const limit = int(get(`x-ratelimit-limit-${suffix}`));
25
+ const remaining = int(get(`x-ratelimit-remaining-${suffix}`));
26
+ if (limit === undefined && remaining === undefined) return undefined;
27
+ const resetSeconds = parseDurationSeconds(get(`x-ratelimit-reset-${suffix}`));
28
+ const used = limit !== undefined && remaining !== undefined ? limit - remaining : undefined;
29
+ return {
30
+ id,
31
+ label,
32
+ unit: "count",
33
+ limit,
34
+ remaining,
35
+ used,
36
+ resetsAt: resetSeconds !== undefined
37
+ ? new Date(nowMs + resetSeconds * 1000).toISOString()
38
+ : undefined,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Parses OpenAI rate-limit response headers into usage windows. OpenAI's reset
44
+ * headers are Go-duration strings relative to "now", so `nowMs` is added to
45
+ * produce an absolute ISO reset time (pass a fixed value in tests).
46
+ *
47
+ * Pass a getter, e.g. `(name) => response.headers.get(name)`.
48
+ *
49
+ * @param {(name: string) => string | null | undefined} get
50
+ * @param {number} [nowMs]
51
+ * @returns {UsageWindow[]}
52
+ */
53
+ export function parseOpenAiRateLimitHeaders(get, nowMs = Date.now()) {
54
+ const windows = [];
55
+ const requests = countWindow(get, "requests", "requests-per-min", "requests/min", nowMs);
56
+ if (requests) windows.push(requests);
57
+ const tokens = countWindow(get, "tokens", "tokens-per-min", "tokens/min", nowMs);
58
+ if (tokens) windows.push(tokens);
59
+ return windows;
60
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Published daily request caps for Google providers, keyed by tier. Google does
3
+ * not expose a live "remaining quota" surface to a personal-login or API-key
4
+ * client, so any usage estimate must subtract local request counts from these
5
+ * documented caps. The numbers move (and the personal Code Assist tiers in the
6
+ * Gemini CLI stop serving 2026-06-18), so they live here as data, not logic.
7
+ *
8
+ * @type {Record<string, { label: string; requestsPerDay: number; rpm?: number }>}
9
+ */
10
+ export const PUBLISHED_CAPS = {
11
+ "code-assist-free": { label: "Code Assist (free)", requestsPerDay: 1000, rpm: 60 },
12
+ "ai-pro": { label: "Google AI Pro", requestsPerDay: 1500 },
13
+ "ai-ultra": { label: "Google AI Ultra", requestsPerDay: 2000 },
14
+ "gemini-api-free": { label: "Gemini API (free tier)", requestsPerDay: 250 },
15
+ "code-assist-standard": { label: "Code Assist Standard", requestsPerDay: 1500 },
16
+ "code-assist-enterprise": { label: "Code Assist Enterprise", requestsPerDay: 2000 },
17
+ };
18
+
19
+ /**
20
+ * Looks up a published cap by tier id. Returns `undefined` for unknown tiers so
21
+ * the caller can degrade to "unknown" rather than invent a number.
22
+ *
23
+ * @param {string | undefined} tier
24
+ * @returns {{ label: string; requestsPerDay: number; rpm?: number } | undefined}
25
+ */
26
+ export function publishedCapForTier(tier) {
27
+ if (!tier) return undefined;
28
+ return PUBLISHED_CAPS[tier];
29
+ }
@@ -0,0 +1,69 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Reads the Claude Code subscription OAuth token for an account. Tries the
7
+ * account's `configDir/.credentials.json` first (the cross-platform location
8
+ * when `CLAUDE_CONFIG_DIR` is set), then falls back to the macOS Keychain item
9
+ * `Claude Code-credentials`.
10
+ *
11
+ * Returns `null` when no credential can be read, so the adapter degrades to a
12
+ * "none" report rather than throwing. The token is returned only to mint an
13
+ * outbound Authorization header; callers must never log or persist it.
14
+ *
15
+ * @param {{ configDir?: string }} account
16
+ * @param {NodeJS.Platform} [platform]
17
+ * @returns {{ accessToken: string; expiresAt?: number } | null}
18
+ */
19
+ export function readClaudeCredentials(account, platform = process.platform) {
20
+ if (account.configDir) {
21
+ const path = join(account.configDir, ".credentials.json");
22
+ if (existsSync(path)) {
23
+ const parsed = parseCredentials(readFileSafe(path));
24
+ if (parsed) return parsed;
25
+ }
26
+ }
27
+ if (platform === "darwin") {
28
+ const result = spawnSync(
29
+ "security",
30
+ ["find-generic-password", "-s", "Claude Code-credentials", "-w"],
31
+ { stdio: ["ignore", "pipe", "ignore"], timeout: 4_000 },
32
+ );
33
+ if (result.status === 0) {
34
+ const parsed = parseCredentials(result.stdout?.toString("utf8") ?? "");
35
+ if (parsed) return parsed;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * @param {string} path
43
+ * @returns {string}
44
+ */
45
+ function readFileSafe(path) {
46
+ try {
47
+ return readFileSync(path, "utf8");
48
+ } catch {
49
+ return "";
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {string} raw
55
+ * @returns {{ accessToken: string; expiresAt?: number } | null}
56
+ */
57
+ function parseCredentials(raw) {
58
+ if (!raw.trim()) return null;
59
+ try {
60
+ const json = JSON.parse(raw);
61
+ const oauth = json?.claudeAiOauth;
62
+ const accessToken = oauth?.accessToken;
63
+ if (typeof accessToken !== "string" || accessToken === "") return null;
64
+ const expiresAt = typeof oauth?.expiresAt === "number" ? oauth.expiresAt : undefined;
65
+ return { accessToken, expiresAt };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { decodeJwtClaims } from "./decodeJwtClaims.js";
4
+
5
+ /**
6
+ * Reads the Codex ChatGPT-subscription OAuth token for an account from
7
+ * `configDir/auth.json` (the per-account `CODEX_HOME`). The ChatGPT account id
8
+ * comes from `tokens.account_id`, or failing that the `chatgpt_account_id`
9
+ * claim inside the `id_token` JWT.
10
+ *
11
+ * Returns `null` when no credential can be read or the account uses an API key
12
+ * instead of ChatGPT auth. The token is returned only to mint an outbound
13
+ * Authorization header.
14
+ *
15
+ * @param {{ configDir?: string }} account
16
+ * @returns {{ accessToken: string; accountId?: string } | null}
17
+ */
18
+ export function readCodexCredentials(account) {
19
+ if (!account.configDir) return null;
20
+ const path = join(account.configDir, "auth.json");
21
+ if (!existsSync(path)) return null;
22
+ let json;
23
+ try {
24
+ json = JSON.parse(readFileSync(path, "utf8"));
25
+ } catch {
26
+ return null;
27
+ }
28
+ const tokens = json?.tokens;
29
+ const accessToken = tokens?.access_token;
30
+ if (typeof accessToken !== "string" || accessToken === "") return null;
31
+ let accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
32
+ if (!accountId) {
33
+ const claims = decodeJwtClaims(tokens?.id_token);
34
+ const authClaim = /** @type {Record<string, unknown> | undefined} */ (
35
+ claims["https://api.openai.com/auth"]
36
+ );
37
+ const fromClaim = authClaim?.chatgpt_account_id;
38
+ if (typeof fromClaim === "string") accountId = fromClaim;
39
+ }
40
+ return { accessToken, accountId };
41
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { accountsRoot } from "@smithers-orchestrator/accounts";
4
+
5
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
6
+ /** @typedef {{ version: 1; entries: Record<string, { report: UsageReport }> }} UsageCacheFile */
7
+
8
+ /**
9
+ * Path to the on-disk usage cache. Lives next to `accounts.json` under the
10
+ * Smithers root so it honors `SMITHERS_HOME` in tests and CI.
11
+ *
12
+ * @param {NodeJS.ProcessEnv} [env]
13
+ * @returns {string}
14
+ */
15
+ export function usageCachePath(env = process.env) {
16
+ return join(accountsRoot(env), "usage-cache.json");
17
+ }
18
+
19
+ /**
20
+ * Reads the usage cache, returning an empty cache when the file is missing or
21
+ * malformed (a cold cache is the normal startup state, not an error).
22
+ *
23
+ * @param {NodeJS.ProcessEnv} [env]
24
+ * @returns {UsageCacheFile}
25
+ */
26
+ export function readUsageCache(env = process.env) {
27
+ const path = usageCachePath(env);
28
+ if (!existsSync(path)) return { version: 1, entries: {} };
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
31
+ if (parsed && typeof parsed === "object" && parsed.entries && typeof parsed.entries === "object") {
32
+ return { version: 1, entries: parsed.entries };
33
+ }
34
+ } catch {
35
+ // fall through to empty cache
36
+ }
37
+ return { version: 1, entries: {} };
38
+ }
39
+
40
+ /**
41
+ * Writes the usage cache atomically with mode 0600.
42
+ *
43
+ * @param {UsageCacheFile} contents
44
+ * @param {NodeJS.ProcessEnv} [env]
45
+ * @returns {string} the path written
46
+ */
47
+ export function writeUsageCache(contents, env = process.env) {
48
+ const path = usageCachePath(env);
49
+ mkdirSync(accountsRoot(env), { recursive: true });
50
+ const tmp = `${path}.${process.pid}.tmp`;
51
+ writeFileSync(tmp, JSON.stringify(contents, null, 2), { mode: 0o600 });
52
+ // rename is atomic on the same filesystem
53
+ renameSync(tmp, path);
54
+ return path;
55
+ }