@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/src/index.d.ts ADDED
@@ -0,0 +1,442 @@
1
+ import * as _smithers_orchestrator_accounts from '@smithers-orchestrator/accounts';
2
+ import { AccountProvider } from '@smithers-orchestrator/accounts';
3
+
4
+ /**
5
+ * Where a usage report's numbers came from.
6
+ *
7
+ * - `oauth` — an authenticated subscription usage endpoint (Claude, Codex).
8
+ * - `headers` — live rate-limit response headers from an API-key request.
9
+ * - `local` — estimated locally from token logs (Google providers).
10
+ * - `none` — the provider exposes no usage surface, or the probe failed.
11
+ */
12
+ type UsageSource = "oauth" | "headers" | "local" | "none";
13
+
14
+ /**
15
+ * One quota window for an account: a 5-hour session, a weekly cap, a per-minute
16
+ * request bucket, and so on.
17
+ *
18
+ * The `unit` decides which fields are meaningful:
19
+ * - `percent` — subscription utilization; read `usedPercent` (0–100).
20
+ * - `count` — API-key buckets; read `limit`, `remaining`, `used`.
21
+ * - `estimated` — locally estimated; read `usedPercent`/`used`/`limit`, treat as
22
+ * a lower bound, never as authoritative.
23
+ */
24
+ type UsageWindow$4 = {
25
+ /** Stable id, e.g. "5h" | "weekly" | "requests-per-min" | "tokens-per-min". */
26
+ id: string;
27
+ /** Human label, e.g. "5-hour session". */
28
+ label: string;
29
+ /** Which fields below are meaningful. */
30
+ unit: "percent" | "count" | "estimated";
31
+ /** 0–100. Set for `percent` and `estimated`. */
32
+ usedPercent?: number;
33
+ /** Absolute amount consumed. Set for `count` and `estimated`. */
34
+ used?: number;
35
+ /** Absolute cap. Set for `count` and `estimated`. */
36
+ limit?: number;
37
+ /** `limit - used`. Set for `count`. */
38
+ remaining?: number;
39
+ /** ISO-8601 timestamp when this window rolls over. */
40
+ resetsAt?: string;
41
+ };
42
+
43
+ /**
44
+ * Normalized usage for a single registered account. Every adapter — subscription
45
+ * utilization, API-key headers, local estimate — produces this same shape so the
46
+ * CLI, gateway, and UI render one model.
47
+ */
48
+ type UsageReport$5 = {
49
+ /** The account's label in `~/.smithers/accounts.json`. */
50
+ accountLabel: string;
51
+ /** The account's provider. */
52
+ provider: AccountProvider;
53
+ /** How this account authenticates. */
54
+ authMode: "subscription" | "api-key";
55
+ /** Where the numbers came from. */
56
+ source: UsageSource;
57
+ /** Quota windows, possibly empty when `source` is `none`. */
58
+ windows: UsageWindow$4[];
59
+ /** Plan/tier label if the provider reports one, e.g. "max", "pro". */
60
+ planType?: string;
61
+ /** Pay-as-you-go credit balance, if the provider reports one (Codex). */
62
+ credits?: {
63
+ hasCredits: boolean;
64
+ unlimited: boolean;
65
+ balance?: string;
66
+ };
67
+ /** ISO-8601 timestamp of when this report was produced. */
68
+ fetchedAt: string;
69
+ /** True when served from cache past its soft TTL. */
70
+ stale: boolean;
71
+ /** True when the windows are locally estimated, not provider-authoritative. */
72
+ estimate: boolean;
73
+ /** Human-readable reason when `source` is `none` or a probe failed. */
74
+ error?: string;
75
+ };
76
+
77
+ /** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
78
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
79
+ /**
80
+ * Routes an account to its usage adapter and returns a normalized report. This
81
+ * switch mirrors `accountToProviderEnv` in the accounts package so the two stay
82
+ * structurally aligned. Adapters never throw; they degrade to a `none` report.
83
+ *
84
+ * Credentials are read on the host that owns them and only the normalized report
85
+ * leaves this function — no token is ever returned or logged.
86
+ *
87
+ * @param {Account} account
88
+ * @returns {Promise<UsageReport>}
89
+ */
90
+ declare function getAccountUsage(account: Account$2): Promise<UsageReport$4>;
91
+ type Account$2 = _smithers_orchestrator_accounts.Account;
92
+ type UsageReport$4 = UsageReport$5;
93
+
94
+ /**
95
+ * Gathers usage for many accounts in parallel, served through the on-disk cache.
96
+ * Cached reports come back with `stale: true`. The cache is read once and written
97
+ * once, so parallel probes never race on the file.
98
+ *
99
+ * @param {Account[]} accounts
100
+ * @param {{ fresh?: boolean; env?: NodeJS.ProcessEnv; nowMs?: number }} [options]
101
+ * @returns {Promise<UsageReport[]>}
102
+ */
103
+ declare function getUsageForAccounts(accounts: Account$1[], options?: {
104
+ fresh?: boolean;
105
+ env?: NodeJS.ProcessEnv;
106
+ nowMs?: number;
107
+ }): Promise<UsageReport$3[]>;
108
+ type Account$1 = _smithers_orchestrator_accounts.Account;
109
+ type UsageReport$3 = UsageReport$5;
110
+
111
+ /** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
112
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
113
+ /** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
114
+ /**
115
+ * The partial result an adapter returns. The dispatcher wraps it with the
116
+ * account identity and timestamp to form a complete {@link UsageReport}.
117
+ *
118
+ * @typedef {object} UsageProbe
119
+ * @property {import("./UsageSource.ts").UsageSource} source
120
+ * @property {UsageWindow[]} [windows]
121
+ * @property {string} [planType]
122
+ * @property {{ hasCredits: boolean; unlimited: boolean; balance?: string }} [credits]
123
+ * @property {boolean} [estimate]
124
+ * @property {string} [error]
125
+ */
126
+ /**
127
+ * Assembles a full usage report from an account and an adapter probe. Keeps the
128
+ * adapters free of repeated identity/timestamp boilerplate.
129
+ *
130
+ * @param {Account} account
131
+ * @param {UsageProbe} probe
132
+ * @param {{ nowIso?: string }} [options]
133
+ * @returns {UsageReport}
134
+ */
135
+ declare function buildUsageReport(account: Account, probe: UsageProbe$5, options?: {
136
+ nowIso?: string;
137
+ }): UsageReport$2;
138
+ type Account = _smithers_orchestrator_accounts.Account;
139
+ type UsageReport$2 = UsageReport$5;
140
+ /**
141
+ * The partial result an adapter returns. The dispatcher wraps it with the
142
+ * account identity and timestamp to form a complete {@link UsageReport}.
143
+ */
144
+ type UsageProbe$5 = {
145
+ source: UsageSource;
146
+ windows?: UsageWindow$4[] | undefined;
147
+ planType?: string | undefined;
148
+ credits?: {
149
+ hasCredits: boolean;
150
+ unlimited: boolean;
151
+ balance?: string;
152
+ } | undefined;
153
+ estimate?: boolean | undefined;
154
+ error?: string | undefined;
155
+ };
156
+
157
+ /**
158
+ * Renders an array of usage reports as an aligned text table. Pure: pass a fixed
159
+ * `nowMs` in tests to get deterministic "resets in" values.
160
+ *
161
+ * @param {UsageReport[]} reports
162
+ * @param {number} [nowMs]
163
+ * @returns {string}
164
+ */
165
+ declare function formatUsageReports(reports: UsageReport$1[], nowMs?: number): string;
166
+ type UsageReport$1 = UsageReport$5;
167
+
168
+ /**
169
+ * Formats an ISO reset timestamp as a relative "resets in" string. Returns an
170
+ * empty string when there is no timestamp, and `"now"` when it is in the past.
171
+ *
172
+ * @param {string | undefined} resetsAt
173
+ * @param {number} [nowMs]
174
+ * @returns {string}
175
+ */
176
+ declare function formatRelativeReset(resetsAt: string | undefined, nowMs?: number): string;
177
+
178
+ /**
179
+ * Formats a number of seconds as a short human duration, e.g. `"2h 41m"`,
180
+ * `"5d 3h"`, `"42s"`. Used for "resets in" columns. Negative input renders as
181
+ * `"now"`.
182
+ *
183
+ * @param {number} seconds
184
+ * @returns {string}
185
+ */
186
+ declare function humanizeDurationShort(seconds: number): string;
187
+
188
+ /**
189
+ * Normalizes the Claude Code subscription usage payload into usage windows. The
190
+ * payload powers the in-CLI `/usage` view: a 5-hour rolling window, a weekly
191
+ * window, and optional per-model weekly windows.
192
+ *
193
+ * @param {unknown} payload
194
+ * @returns {UsageWindow[]}
195
+ */
196
+ declare function parseClaudeOauthUsage(payload: unknown): UsageWindow$3[];
197
+ type UsageWindow$3 = UsageWindow$4;
198
+
199
+ /**
200
+ * Normalizes the Codex usage payload (from `GET /backend-api/wham/usage`, or the
201
+ * `codex.rate_limits` event) into windows plus plan and credit metadata. The
202
+ * rate-limit object may sit at the top level or under a `rate_limits` key; both
203
+ * shapes are accepted.
204
+ *
205
+ * @param {unknown} payload
206
+ * @returns {{ windows: UsageWindow[]; planType?: string; credits?: { hasCredits: boolean; unlimited: boolean; balance?: string } }}
207
+ */
208
+ declare function parseCodexUsage(payload: unknown): {
209
+ windows: UsageWindow$2[];
210
+ planType?: string;
211
+ credits?: {
212
+ hasCredits: boolean;
213
+ unlimited: boolean;
214
+ balance?: string;
215
+ };
216
+ };
217
+ type UsageWindow$2 = UsageWindow$4;
218
+
219
+ /**
220
+ * Parses Anthropic rate-limit response headers into usage windows. Anthropic
221
+ * returns RFC-3339 reset timestamps directly, so no clock math is needed.
222
+ *
223
+ * Pass a getter, e.g. `(name) => response.headers.get(name)`.
224
+ *
225
+ * @param {(name: string) => string | null | undefined} get
226
+ * @returns {UsageWindow[]}
227
+ */
228
+ declare function parseAnthropicRateLimitHeaders(get: (name: string) => string | null | undefined): UsageWindow$1[];
229
+ type UsageWindow$1 = UsageWindow$4;
230
+
231
+ /**
232
+ * Parses OpenAI rate-limit response headers into usage windows. OpenAI's reset
233
+ * headers are Go-duration strings relative to "now", so `nowMs` is added to
234
+ * produce an absolute ISO reset time (pass a fixed value in tests).
235
+ *
236
+ * Pass a getter, e.g. `(name) => response.headers.get(name)`.
237
+ *
238
+ * @param {(name: string) => string | null | undefined} get
239
+ * @param {number} [nowMs]
240
+ * @returns {UsageWindow[]}
241
+ */
242
+ declare function parseOpenAiRateLimitHeaders(get: (name: string) => string | null | undefined, nowMs?: number): UsageWindow[];
243
+ type UsageWindow = UsageWindow$4;
244
+
245
+ /**
246
+ * Parses a Go-style duration string into seconds. OpenAI's rate-limit reset
247
+ * headers use this format, e.g. `"1s"`, `"6m0s"`, `"1h2m3s"`, `"800ms"`.
248
+ *
249
+ * Returns `undefined` for input it cannot parse, so callers can omit a reset
250
+ * time rather than render a wrong one.
251
+ *
252
+ * @param {string | null | undefined} value
253
+ * @returns {number | undefined}
254
+ */
255
+ declare function parseDurationSeconds(value: string | null | undefined): number | undefined;
256
+
257
+ /**
258
+ * Decodes the claims (the middle segment) of a JWT without verifying its
259
+ * signature. Used to read the `chatgpt_account_id` claim out of the Codex
260
+ * `id_token` when `auth.json` does not carry `tokens.account_id` directly.
261
+ *
262
+ * Verification is intentionally skipped: the token already authenticated the
263
+ * user with the provider, and we only read a non-secret routing claim from it.
264
+ *
265
+ * Returns an empty object for anything that is not a decodable JWT.
266
+ *
267
+ * @param {string | null | undefined} token
268
+ * @returns {Record<string, unknown>}
269
+ */
270
+ declare function decodeJwtClaims(token: string | null | undefined): Record<string, unknown>;
271
+
272
+ /**
273
+ * Reads the Claude Code subscription OAuth token for an account. Tries the
274
+ * account's `configDir/.credentials.json` first (the cross-platform location
275
+ * when `CLAUDE_CONFIG_DIR` is set), then falls back to the macOS Keychain item
276
+ * `Claude Code-credentials`.
277
+ *
278
+ * Returns `null` when no credential can be read, so the adapter degrades to a
279
+ * "none" report rather than throwing. The token is returned only to mint an
280
+ * outbound Authorization header; callers must never log or persist it.
281
+ *
282
+ * @param {{ configDir?: string }} account
283
+ * @param {NodeJS.Platform} [platform]
284
+ * @returns {{ accessToken: string; expiresAt?: number } | null}
285
+ */
286
+ declare function readClaudeCredentials(account: {
287
+ configDir?: string;
288
+ }, platform?: NodeJS.Platform): {
289
+ accessToken: string;
290
+ expiresAt?: number;
291
+ } | null;
292
+
293
+ /**
294
+ * Reads the Codex ChatGPT-subscription OAuth token for an account from
295
+ * `configDir/auth.json` (the per-account `CODEX_HOME`). The ChatGPT account id
296
+ * comes from `tokens.account_id`, or failing that the `chatgpt_account_id`
297
+ * claim inside the `id_token` JWT.
298
+ *
299
+ * Returns `null` when no credential can be read or the account uses an API key
300
+ * instead of ChatGPT auth. The token is returned only to mint an outbound
301
+ * Authorization header.
302
+ *
303
+ * @param {{ configDir?: string }} account
304
+ * @returns {{ accessToken: string; accountId?: string } | null}
305
+ */
306
+ declare function readCodexCredentials(account: {
307
+ configDir?: string;
308
+ }): {
309
+ accessToken: string;
310
+ accountId?: string;
311
+ } | null;
312
+
313
+ /**
314
+ * Probes the Claude Code subscription usage endpoint for an account's 5-hour and
315
+ * weekly utilization. Undocumented and best-effort: any failure degrades to a
316
+ * `none` report with a readable reason.
317
+ *
318
+ * @param {{ configDir?: string }} account
319
+ * @returns {Promise<UsageProbe>}
320
+ */
321
+ declare function claudeOauthUsage(account: {
322
+ configDir?: string;
323
+ }): Promise<UsageProbe$4>;
324
+ type UsageProbe$4 = UsageProbe$5;
325
+
326
+ /**
327
+ * Probes the Codex ChatGPT-subscription usage endpoint for an account's 5-hour
328
+ * and weekly windows. This is the same data the Codex `/status` view shows and
329
+ * does not spend a turn. Undocumented and best-effort.
330
+ *
331
+ * @param {{ configDir?: string }} account
332
+ * @returns {Promise<UsageProbe>}
333
+ */
334
+ declare function codexWhamUsage(account: {
335
+ configDir?: string;
336
+ }): Promise<UsageProbe$3>;
337
+ type UsageProbe$3 = UsageProbe$5;
338
+
339
+ /**
340
+ * Reads live Anthropic rate-limit headers for an API-key account. Uses the
341
+ * `count_tokens` endpoint, which returns the rate-limit header family without
342
+ * producing output tokens.
343
+ *
344
+ * @param {{ apiKey?: string }} account
345
+ * @returns {Promise<UsageProbe>}
346
+ */
347
+ declare function anthropicHeaderUsage(account: {
348
+ apiKey?: string;
349
+ }): Promise<UsageProbe$2>;
350
+ type UsageProbe$2 = UsageProbe$5;
351
+
352
+ /**
353
+ * Reads live OpenAI rate-limit headers for an API-key account.
354
+ *
355
+ * @param {{ apiKey?: string }} account
356
+ * @returns {Promise<UsageProbe>}
357
+ */
358
+ declare function openaiHeaderUsage(account: {
359
+ apiKey?: string;
360
+ }): Promise<UsageProbe$1>;
361
+ type UsageProbe$1 = UsageProbe$5;
362
+
363
+ /** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
364
+ /**
365
+ * Google (Gemini, Antigravity, Gemini API) exposes no live "remaining quota"
366
+ * surface to a personal-login or API-key client: there are no rate-limit
367
+ * response headers, only a 429 `RESOURCE_EXHAUSTED` after the wall is hit. The
368
+ * documented path forward is local token-log accounting against published caps
369
+ * (see `publishedCaps.js`), which depends on run-history integration and lands
370
+ * in a later phase. Until then this reports `none` honestly rather than inventing
371
+ * a number.
372
+ *
373
+ * @param {{ provider: string }} account
374
+ * @returns {Promise<UsageProbe>}
375
+ */
376
+ declare function googleUsage(account: {
377
+ provider: string;
378
+ }): Promise<UsageProbe>;
379
+ type UsageProbe = UsageProbe$5;
380
+
381
+ /**
382
+ * Looks up a published cap by tier id. Returns `undefined` for unknown tiers so
383
+ * the caller can degrade to "unknown" rather than invent a number.
384
+ *
385
+ * @param {string | undefined} tier
386
+ * @returns {{ label: string; requestsPerDay: number; rpm?: number } | undefined}
387
+ */
388
+ declare function publishedCapForTier(tier: string | undefined): {
389
+ label: string;
390
+ requestsPerDay: number;
391
+ rpm?: number;
392
+ } | undefined;
393
+ /**
394
+ * Published daily request caps for Google providers, keyed by tier. Google does
395
+ * not expose a live "remaining quota" surface to a personal-login or API-key
396
+ * client, so any usage estimate must subtract local request counts from these
397
+ * documented caps. The numbers move (and the personal Code Assist tiers in the
398
+ * Gemini CLI stop serving 2026-06-18), so they live here as data, not logic.
399
+ *
400
+ * @type {Record<string, { label: string; requestsPerDay: number; rpm?: number }>}
401
+ */
402
+ declare const PUBLISHED_CAPS: Record<string, {
403
+ label: string;
404
+ requestsPerDay: number;
405
+ rpm?: number;
406
+ }>;
407
+
408
+ /** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
409
+ /** @typedef {{ version: 1; entries: Record<string, { report: UsageReport }> }} UsageCacheFile */
410
+ /**
411
+ * Path to the on-disk usage cache. Lives next to `accounts.json` under the
412
+ * Smithers root so it honors `SMITHERS_HOME` in tests and CI.
413
+ *
414
+ * @param {NodeJS.ProcessEnv} [env]
415
+ * @returns {string}
416
+ */
417
+ declare function usageCachePath(env?: NodeJS.ProcessEnv): string;
418
+ /**
419
+ * Reads the usage cache, returning an empty cache when the file is missing or
420
+ * malformed (a cold cache is the normal startup state, not an error).
421
+ *
422
+ * @param {NodeJS.ProcessEnv} [env]
423
+ * @returns {UsageCacheFile}
424
+ */
425
+ declare function readUsageCache(env?: NodeJS.ProcessEnv): UsageCacheFile;
426
+ /**
427
+ * Writes the usage cache atomically with mode 0600.
428
+ *
429
+ * @param {UsageCacheFile} contents
430
+ * @param {NodeJS.ProcessEnv} [env]
431
+ * @returns {string} the path written
432
+ */
433
+ declare function writeUsageCache(contents: UsageCacheFile, env?: NodeJS.ProcessEnv): string;
434
+ type UsageReport = UsageReport$5;
435
+ type UsageCacheFile = {
436
+ version: 1;
437
+ entries: Record<string, {
438
+ report: UsageReport;
439
+ }>;
440
+ };
441
+
442
+ export { PUBLISHED_CAPS, anthropicHeaderUsage, buildUsageReport, claudeOauthUsage, codexWhamUsage, decodeJwtClaims, formatRelativeReset, formatUsageReports, getAccountUsage, getUsageForAccounts, googleUsage, humanizeDurationShort, openaiHeaderUsage, parseAnthropicRateLimitHeaders, parseClaudeOauthUsage, parseCodexUsage, parseDurationSeconds, parseOpenAiRateLimitHeaders, publishedCapForTier, readClaudeCredentials, readCodexCredentials, readUsageCache, usageCachePath, writeUsageCache };
package/src/index.js ADDED
@@ -0,0 +1,21 @@
1
+ export { getAccountUsage } from "./getAccountUsage.js";
2
+ export { getUsageForAccounts } from "./getUsageForAccounts.js";
3
+ export { buildUsageReport } from "./buildUsageReport.js";
4
+ export { formatUsageReports } from "./formatUsageReports.js";
5
+ export { formatRelativeReset } from "./formatRelativeReset.js";
6
+ export { humanizeDurationShort } from "./humanizeDurationShort.js";
7
+ export { parseClaudeOauthUsage } from "./parseClaudeOauthUsage.js";
8
+ export { parseCodexUsage } from "./parseCodexUsage.js";
9
+ export { parseAnthropicRateLimitHeaders } from "./parseAnthropicRateLimitHeaders.js";
10
+ export { parseOpenAiRateLimitHeaders } from "./parseOpenAiRateLimitHeaders.js";
11
+ export { parseDurationSeconds } from "./parseDurationSeconds.js";
12
+ export { decodeJwtClaims } from "./decodeJwtClaims.js";
13
+ export { PUBLISHED_CAPS, publishedCapForTier } from "./publishedCaps.js";
14
+ export { readClaudeCredentials } from "./readClaudeCredentials.js";
15
+ export { readCodexCredentials } from "./readCodexCredentials.js";
16
+ export { claudeOauthUsage } from "./claudeOauthUsage.js";
17
+ export { codexWhamUsage } from "./codexWhamUsage.js";
18
+ export { anthropicHeaderUsage } from "./anthropicHeaderUsage.js";
19
+ export { openaiHeaderUsage } from "./openaiHeaderUsage.js";
20
+ export { googleUsage } from "./googleUsage.js";
21
+ export { usageCachePath, readUsageCache, writeUsageCache } from "./usageCache.js";
@@ -0,0 +1,59 @@
1
+ import { parseOpenAiRateLimitHeaders } from "./parseOpenAiRateLimitHeaders.js";
2
+
3
+ /** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
4
+
5
+ const CHAT_URL = "https://api.openai.com/v1/chat/completions";
6
+
7
+ /**
8
+ * `GET /v1/models` does not return rate-limit headers; only a model request
9
+ * does. This minimal completion costs one request and a single output token,
10
+ * which is the cheapest documented way to read the headers.
11
+ */
12
+ const PROBE_MODEL = process.env.SMITHERS_OPENAI_PROBE_MODEL ?? "gpt-4o-mini";
13
+
14
+ /**
15
+ * Reads live OpenAI rate-limit headers for an API-key account.
16
+ *
17
+ * @param {{ apiKey?: string }} account
18
+ * @returns {Promise<UsageProbe>}
19
+ */
20
+ export async function openaiHeaderUsage(account) {
21
+ const apiKey = account.apiKey;
22
+ if (!apiKey) {
23
+ return { source: "none", error: "Account has no API key set" };
24
+ }
25
+ try {
26
+ const res = await fetch(CHAT_URL, {
27
+ method: "POST",
28
+ headers: {
29
+ Authorization: `Bearer ${apiKey}`,
30
+ "content-type": "application/json",
31
+ },
32
+ body: JSON.stringify({
33
+ model: PROBE_MODEL,
34
+ max_tokens: 1,
35
+ messages: [{ role: "user", content: "hi" }],
36
+ }),
37
+ signal: AbortSignal.timeout(6_000),
38
+ });
39
+ if (res.status === 401) {
40
+ return { source: "none", error: "OPENAI_API_KEY rejected (401)" };
41
+ }
42
+ const get = (name) => res.headers.get(name);
43
+ const windows = parseOpenAiRateLimitHeaders(get);
44
+ if (res.status === 429) {
45
+ const retryAfter = res.headers.get("retry-after");
46
+ return {
47
+ source: "headers",
48
+ windows,
49
+ error: `Rate limited (429)${retryAfter ? ` — retry after ${retryAfter}s` : ""}`,
50
+ };
51
+ }
52
+ if (!res.ok && windows.length === 0) {
53
+ return { source: "none", error: `OpenAI returned ${res.status} with no rate-limit headers` };
54
+ }
55
+ return { source: "headers", windows };
56
+ } catch (err) {
57
+ return { source: "none", error: `OpenAI header probe failed: ${err instanceof Error ? err.message : String(err)}` };
58
+ }
59
+ }
@@ -0,0 +1,57 @@
1
+ /** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
2
+
3
+ /**
4
+ * @param {string | null | undefined} value
5
+ * @returns {number | undefined}
6
+ */
7
+ function int(value) {
8
+ if (value == null) return undefined;
9
+ const n = parseInt(value, 10);
10
+ return Number.isNaN(n) ? undefined : n;
11
+ }
12
+
13
+ /**
14
+ * @param {(name: string) => string | null | undefined} get
15
+ * @param {string} prefix
16
+ * @param {string} id
17
+ * @param {string} label
18
+ * @returns {UsageWindow | undefined}
19
+ */
20
+ function countWindow(get, prefix, id, label) {
21
+ const limit = int(get(`${prefix}-limit`));
22
+ const remaining = int(get(`${prefix}-remaining`));
23
+ if (limit === undefined && remaining === undefined) return undefined;
24
+ const reset = get(`${prefix}-reset`);
25
+ const used = limit !== undefined && remaining !== undefined ? limit - remaining : undefined;
26
+ return {
27
+ id,
28
+ label,
29
+ unit: "count",
30
+ limit,
31
+ remaining,
32
+ used,
33
+ resetsAt: typeof reset === "string" ? reset : undefined,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Parses Anthropic rate-limit response headers into usage windows. Anthropic
39
+ * returns RFC-3339 reset timestamps directly, so no clock math is needed.
40
+ *
41
+ * Pass a getter, e.g. `(name) => response.headers.get(name)`.
42
+ *
43
+ * @param {(name: string) => string | null | undefined} get
44
+ * @returns {UsageWindow[]}
45
+ */
46
+ export function parseAnthropicRateLimitHeaders(get) {
47
+ const windows = [];
48
+ const requests = countWindow(get, "anthropic-ratelimit-requests", "requests-per-min", "requests/min");
49
+ if (requests) windows.push(requests);
50
+ const tokens = countWindow(get, "anthropic-ratelimit-tokens", "tokens-per-min", "tokens/min");
51
+ if (tokens) windows.push(tokens);
52
+ const input = countWindow(get, "anthropic-ratelimit-input-tokens", "input-tokens-per-min", "input tokens/min");
53
+ if (input) windows.push(input);
54
+ const output = countWindow(get, "anthropic-ratelimit-output-tokens", "output-tokens-per-min", "output tokens/min");
55
+ if (output) windows.push(output);
56
+ return windows;
57
+ }
@@ -0,0 +1,47 @@
1
+ /** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
2
+
3
+ /**
4
+ * Builds one percent window from a `{ utilization, resets_at }` block as
5
+ * returned by `GET https://api.anthropic.com/api/oauth/usage`.
6
+ *
7
+ * @param {unknown} block
8
+ * @param {string} id
9
+ * @param {string} label
10
+ * @returns {UsageWindow | undefined}
11
+ */
12
+ function windowFrom(block, id, label) {
13
+ if (!block || typeof block !== "object") return undefined;
14
+ const util = /** @type {{ utilization?: unknown }} */ (block).utilization;
15
+ if (typeof util !== "number") return undefined;
16
+ const resetsAt = /** @type {{ resets_at?: unknown }} */ (block).resets_at;
17
+ return {
18
+ id,
19
+ label,
20
+ unit: "percent",
21
+ usedPercent: util,
22
+ resetsAt: typeof resetsAt === "string" ? resetsAt : undefined,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Normalizes the Claude Code subscription usage payload into usage windows. The
28
+ * payload powers the in-CLI `/usage` view: a 5-hour rolling window, a weekly
29
+ * window, and optional per-model weekly windows.
30
+ *
31
+ * @param {unknown} payload
32
+ * @returns {UsageWindow[]}
33
+ */
34
+ export function parseClaudeOauthUsage(payload) {
35
+ if (!payload || typeof payload !== "object") return [];
36
+ const p = /** @type {Record<string, unknown>} */ (payload);
37
+ const windows = [];
38
+ const fiveHour = windowFrom(p.five_hour, "5h", "5-hour session");
39
+ if (fiveHour) windows.push(fiveHour);
40
+ const weekly = windowFrom(p.seven_day, "weekly", "weekly");
41
+ if (weekly) windows.push(weekly);
42
+ const opus = windowFrom(p.seven_day_opus, "weekly-opus", "weekly (Opus)");
43
+ if (opus) windows.push(opus);
44
+ const sonnet = windowFrom(p.seven_day_sonnet, "weekly-sonnet", "weekly (Sonnet)");
45
+ if (sonnet) windows.push(sonnet);
46
+ return windows;
47
+ }