@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 +21 -0
- package/README.md +54 -0
- package/package.json +35 -0
- package/src/UsageReport.ts +33 -0
- package/src/UsageSource.ts +9 -0
- package/src/UsageWindow.ts +28 -0
- package/src/anthropicHeaderUsage.js +51 -0
- package/src/buildUsageReport.js +43 -0
- package/src/claudeOauthUsage.js +52 -0
- package/src/codexWhamUsage.js +45 -0
- package/src/decodeJwtClaims.js +26 -0
- package/src/formatRelativeReset.js +16 -0
- package/src/formatUsageReports.js +59 -0
- package/src/getAccountUsage.js +50 -0
- package/src/getUsageForAccounts.js +75 -0
- package/src/googleUsage.js +20 -0
- package/src/humanizeDurationShort.js +20 -0
- package/src/index.d.ts +442 -0
- package/src/index.js +21 -0
- package/src/openaiHeaderUsage.js +59 -0
- package/src/parseAnthropicRateLimitHeaders.js +57 -0
- package/src/parseClaudeOauthUsage.js +47 -0
- package/src/parseCodexUsage.js +77 -0
- package/src/parseDurationSeconds.js +38 -0
- package/src/parseOpenAiRateLimitHeaders.js +60 -0
- package/src/publishedCaps.js +29 -0
- package/src/readClaudeCredentials.js +69 -0
- package/src/readCodexCredentials.js +41 -0
- package/src/usageCache.js +55 -0
|
@@ -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
|
+
}
|