@byokit/usage 0.2.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 (2026-10-01)
6
+
7
+
8
+
9
+ - SECURITY: Claude subscription usage may read credentials only from app-managed folders under the passed stateDir, without refresh or credential writes; default logins and folder escapes are refused, tokens stay in one request and never enter output, errors, logs or stored readings.
10
+ - Share the bounded Codex app-server client between identity and subscription usage reads.
11
+ - Apply shared poll health, scoped quota and hard-limit semantics to managed Claude usage, including cancellable host pacing.
12
+
13
+ ## 0.3.0 (2026-10-01)
14
+
15
+
16
+
17
+ - FIX: Subscription hard-limit flags now override positive percentage room, including blocks without quota windows; cached reset times never clear them.
18
+ - FIX: Claude subscription normalized and scoped quota rows now take precedence over legacy aggregates; missing usage remains unknown.
19
+ - Expose observation age and poll outcomes separately; retain last-good figures through failed polls without changing account health.
20
+ - Add cancellable host origin pacing and account-scoped retry policies for rate limits, refresh failures and transient outcomes.
21
+ - Document reset-time units when passing normalized subscription readings to account selection.
22
+
5
23
  ## 0.2.0 (2026-09-30)
6
24
 
7
25
 
package/README.md CHANGED
@@ -6,10 +6,10 @@ left, estimates no cost and never rotates an account.
6
6
 
7
7
  ```ts
8
8
  import { usage, roomOf } from '@byokit/usage';
9
- // Supplied by the host's signed-in account.
9
+ const reader = usage({ stateDir: '/app/state/usage' });
10
+ // Pass the token and account id from your app-owned sign-in store.
10
11
  declare const token: string;
11
12
  declare const account: { id: string };
12
- const reader = usage({ stateDir: '/app/state/usage' });
13
13
  const source = { provider: 'codex' as const, access: token, accountId: account.id };
14
14
  const reading = await reader.read(source);
15
15
  const room = roomOf(reading, Date.now());
@@ -27,14 +27,30 @@ Sources:
27
27
  - `{ provider: 'codex', bin, home, env? }` retains the explicit local app-server source.
28
28
  - `{ provider: 'claude', credentialsFile, configFile?, statuslineFile? }` is a
29
29
  read-only adapter for files the app explicitly supplies. It reads `claudeAiOauth`
30
- and optionally `oauthAccount.accountUuid`. A valid statusline snapshot younger
31
- than five minutes precedes the endpoint. An expired token is never sent.
32
- - `{ provider: 'claude', accountUuid, read, connected? }` delegates to the app's
33
- reader. `read({ nowMs, signal })` returns `{ raw?, code?, retryAfterMs? }` using
34
- the same Claude payload dialect. It has a ten-second deadline, with the signal
30
+ and optionally `oauthAccount.accountUuid`. A statusline snapshot uses its body `fetched_at` (epoch milliseconds or ISO date),
31
+ never file mtime. Known snapshots younger than five minutes precede the endpoint;
32
+ undated/future snapshots remain visible with unknown/future age. An expired token is never sent.
33
+ - `{ provider: 'claude', accountUuid, read, origin?, connected? }` delegates to the app's
34
+ reader. `read({ nowMs, signal })` returns `{ raw?, code?, retryAfterMs?, at?, limited? }` using
35
+ the same Claude payload dialect. The host supplies the actual observation `at`;
36
+ omitted means unknown age, including engine-cached figures. `limited: true` can
37
+ report an authoritative block without fabricating a window. `origin` is the
38
+ host reader's optional HTTP origin for pacing. It has a ten-second deadline, with the signal
35
39
  aborted at expiry. The optional synchronous `connected()` hook controls whether
36
40
  last-good readings remain visible; exceptions count as disconnected.
37
41
 
42
+ `read(source, { nowMs?, signal? })` returns `{ provider, windows, at?, limited?, poll?, code? }`.
43
+ `at` is source observation time; it is absent when unavailable. `poll` contains
44
+ last attempt time, `outcome` (`ok` or a safe failure code) and optional `retryAt`.
45
+ All timestamps are epoch milliseconds. A failed poll keeps the figures and their
46
+ original observation time. Poll 429 (`rate-limited`), host renewal failure
47
+ (`refresh-failed`) and credential refusals are distinct; usage never changes
48
+ account health or renews credentials.
49
+
50
+ The managed-folder source is `{ provider: 'claude', folder, headers: { 'anthropic-beta', 'User-Agent' } }`. Paths must be absolute; removed or empty keys mean disconnected. Claude headers are app-passed. The Claude folder must resolve inside the reader's `stateDir` as `<stateDir>/claude/<hex>`, matching managed CLI account folders. Default `.claude` roots, paths outside the root, and symlinked folders or credential files are refused. Use the managed plans root as the usage reader's stateDir; its quota store coexists with the account roster. The default login's usage adapter remains with the host.
51
+
52
+ Approved exception: the `./cli` entry reads and runs only app-managed per-account folders under `stateDir` and the absolute CLI binaries the app passes; it never touches the person's default login; tokens never leave the device and are never logged. Under the same managed-folder boundary, usage may read `.credentials.json` only for a single Claude subscription usage request. It does not refresh, write, rename or copy credentials; expired or malformed credentials return `expired` or `not-connected`, requiring sign-in again. The token is held in memory only for that request and never enters readings, stored quotas, errors or logs. Folder and credential metadata supply the cache fingerprint without loading a token, and credential changes invalidate the cached account reading.
53
+
38
54
  `read(source, { nowMs? })` returns `{ provider, windows, at, code? }`; `at` and all
39
55
  `resetsAt` fields are **epoch milliseconds** in 0.2.0. This changes 0.1.0's seconds
40
56
  reset convention. Windows include kind, used percent, optional duration in minutes,
@@ -43,12 +59,33 @@ reset time, limit label and limited flag. Parsers are exported for host integrat
43
59
  `zaiWindows`, `copilotWindows`, `grokWindows`, `minimaxWindows`, `geminiWindows`,
44
60
  `kimiWindows(raw, nowMs)`.
45
61
 
46
- `roomOf(reading, nowMs)` returns `{ left, span, resetsAt?, at }` using the tightest
47
- window. Span maps session/week/month and maps rolling/custom to `tightest`. Missing
48
- windows, readings older than 24 hours, future readings, or disconnected/expired/auth/
49
- no-plan readings give `{ left: 'unknown', at }`. A temporary rate limit or failed
50
- update can still show the last-good room and its original timestamp. Auto selection
51
- belongs to the accounts kit; usage only reports room.
62
+ Windows include kind, optional reported `usedPercent`, duration, reset, limit,
63
+ `limited` and `scope: { model?, surface? }`. Missing usage is unknown, never zero.
64
+ Claude `limits[]` session/weekly-all rows override corresponding legacy aggregates,
65
+ even if incomplete; dynamic weekly-scoped rows retain model and surface. Legacy
66
+ `five_hour`/`seven_day` are fallback for absent aggregate kinds. These are synthetic
67
+ contract fixtures; fresh live provider payload qualification has not been run.
68
+
69
+ Parsers are exported: `claudeWindows`, `codexWindows` (app-server),
70
+ `codexTokenWindows(raw, nowMs?)`, `codexHardLimit`, `goWindows`, `zaiWindows`,
71
+ `copilotWindows`, `grokWindows`, `minimaxWindows`, `geminiWindows`, `kimiWindows`.
72
+ Codex absolute reset takes precedence; relative seconds require a captured clock.
73
+ Hard flags do not replace the reported percentage or invent an absent window.
74
+ Hosts using exported Codex parsers must carry `codexHardLimit(raw)` into the reading
75
+ as `limited` to represent windowless blocks.
76
+
77
+ `roomOf(reading, nowMs)` returns `left`, observation `at?`, `ageMs?`, `freshness`
78
+ (`fresh`, `stale`, `future`, `unknown`), `poll?`, and the tightest row's `scope?`.
79
+ Numeric results include `span` and optional reset. Authoritative hard blocks give
80
+ zero eligibility room even without a percentage/window, with `limited: true`;
81
+ a predicted reset never clears a block. Otherwise undated/future/older-than-24h
82
+ readings and disconnected/expired/auth/no-plan readings give unknown room.
83
+ Incomplete windows cannot establish positive room; known exhaustion still stands.
84
+ Scope is conservative across all windows until hosts implement model demand for
85
+ every model/surface a run can use, including subagents and fallbacks.
86
+ Temporary poll failures may retain eligible last-good room and its age; a poll
87
+ failure itself never exhausts or moves an account. Auto's shared input contract is
88
+ `fixtures/conformance/usage-typescript.json` and runtime spec section 13.
52
89
 
53
90
  `connected(source)` and `account(source)` are synchronous. `account` returns a salted
54
91
  fingerprint of a non-secret host id, credential account UUID, token subject, or explicit
@@ -59,34 +96,45 @@ disk store is used. Tokens never become persisted fingerprint inputs.
59
96
 
60
97
  Good reads have no code. Bad sources throw `UsageError` (`code: 'bad-source'`); other
61
98
  failures resolve codes without bodies or secrets. `lastKnown(source, { nowMs? })`
62
- returns a connected account's last-good reading for up to 24 hours. Reads have a
63
- 60-second floor and concurrent deduplication per provider/account. The default 429
64
- backoff honors Retry-After with a five-minute minimum. `UsageOptions.now` supplies
99
+ returns a connected account's last-good figures and most recent poll outcome.
100
+ Ordinary dated readings expire after 24 hours; authoritative blocks stay until a
101
+ new successful read replaces them. Undated figures remain available for display
102
+ with unknown room. Reads have a
103
+ 60-second floor and concurrent deduplication per provider/account. Default 429
104
+ backoff honors Retry-After with a five-minute minimum. Unknown/transient and
105
+ refresh failures back off exponentially from one minute to one hour; these are
106
+ kit defaults, not universal provider policy. Failure counters are separate by
107
+ outcome and account, cleared by a successful quota poll. `UsageOptions.now` supplies
65
108
  the default clock; a per-call clock overrides it. An injected `fetch` wins; otherwise
66
109
  global fetch is resolved on each read.
67
110
 
68
111
  The host may supply public synchronous persistence and backoff hooks:
69
112
 
70
113
  ```ts
71
- import { usage, type UsageStore, type BackoffPolicy } from '@byokit/usage';
72
- declare const appStore: UsageStore;
73
- declare const appBackoff: BackoffPolicy;
114
+ import { usage, type UsageStore, type BackoffState } from '@byokit/usage';
115
+ // Replace these maps with your app's durable store to retain state across restart.
116
+ const appStore: UsageStore = {
117
+ get(provider, fingerprint) { return saved.get(`${provider}/${fingerprint}`); },
118
+ put(provider, fingerprint, reading) { saved.set(`${provider}/${fingerprint}`, reading); },
119
+ };
120
+ const saved = new Map<string, import('@byokit/usage').StoredReading>();
121
+ const appBackoff = new Map<string, BackoffState | number>();
74
122
  const reader = usage({
75
123
  store: {
76
124
  get(provider, fingerprint) { return appStore.get(provider, fingerprint); },
77
125
  put(provider, fingerprint, reading) { appStore.put(provider, fingerprint, reading); },
78
126
  },
79
127
  backoff: {
80
- get(provider, fingerprint) { return appBackoff.get(provider, fingerprint); },
81
- set(provider, fingerprint, untilMs) { appBackoff.set(provider, fingerprint, untilMs); },
82
- delayMs(retryAfterMs) { return Math.max(300_000, retryAfterMs ?? 0); },
128
+ get(provider, fingerprint) { return appBackoff.get(`${provider}/${fingerprint}`); },
129
+ set(provider, fingerprint, untilMs, state) { appBackoff.set(`${provider}/${fingerprint}`, state ?? untilMs); },
130
+ delayMs(retryAfterMs, { outcome, failures }) { return Math.max(300_000, retryAfterMs ?? 0); },
83
131
  },
84
132
  });
85
133
  ```
86
134
 
87
- `UsageStore` holds only `{ at, windows }`; only whitelisted normalized fields cross
135
+ `UsageStore` holds `{ at?, windows, limited?, poll? }`; only whitelisted normalized fields cross
88
136
  this boundary. Exceptions from host hooks do not expose data or fail a provider read.
89
- Internal 429 backoff remains effective if a host backoff hook fails. The 60-second
137
+ Internal backoff remains effective if a host backoff hook fails. The 60-second
90
138
  minimum retry interval applies even if a policy selects a shorter delay. By default,
91
139
  `stateDir` selects an atomic disk store (0700 directory, 0600 file, 256 KB cap), or
92
140
  without `stateDir` an in-memory store is used. `memoryUsageStore()` is exported.
@@ -126,7 +174,7 @@ they are never loaded automatically.
126
174
 
127
175
  `fileUsageStore(absoluteStateDir)` exposes the same bounded atomic disk store as
128
176
  `stateDir`; invalid directory paths throw `UsageError`. Its keys must be 64-character
129
- hex fingerprints, and it persists only `{ at, windows }` with normalized fields.
177
+ hex fingerprints, and it persists normalized observations, hard blocks and poll metadata.
130
178
  `fingerprint(salt)` returns `(provider, nonSecretIdentity) => string`; use the same
131
179
  salt as the reader and never supply a token as the identity.
132
180
  `memoryBackoffPolicy()` supplies shared per-provider/account rests, keeps the later
@@ -136,6 +184,23 @@ past dates clamp to zero), and `backoffDelayMs(retryAfterMs)` to apply the defau
136
184
  five-minute minimum. These helpers also support an app-owned Claude `read` hook
137
185
  without duplicating fingerprint, persistence or retry logic.
138
186
 
187
+ `BackoffPolicy.set(provider, fingerprint, untilMs, state?)` receives a normalized
188
+ `state` with `{ untilMs, at, outcome, failures }` on a retryable failure, and zero
189
+ eligibility time without state after success. Persist and return that state from
190
+ `get` to preserve outcome and retry eligibility across restart. Legacy numeric
191
+ `get` values still work; their reason is unavailable when no stored poll supplies it.
192
+ `delayMs(retryAfterMs, { outcome, failures })` chooses host policy; a valid server
193
+ Retry-After is always a lower bound, alongside the existing one-minute floor.
194
+
195
+ `UsageOptions.pace({ provider, account, origin, signal })` is an optional async host
196
+ hook before each HTTP usage request (including provider discovery/fallback reads).
197
+ The host can share an origin-keyed queue across reader instances. Distinct origins
198
+ are independent; the kit adds no global queue or fixed origin spacing. Account is
199
+ a fingerprint, never a token. Host Claude readers opt in with `source.origin`.
200
+ The hook shares the request deadline and can be cancelled by `ReadOptions.signal`;
201
+ no request is sent when pacing fails or is cancelled. In-flight duplicate callers
202
+ share the first caller's operation/signal. There is no polling timer or inference ping.
203
+
139
204
  Isolation: there is no home/path discovery or environment read. Only absolute files
140
205
  and the Codex binary explicitly supplied by the app are opened/run. Credential files
141
206
  are bounded regular files, with final symlinks rejected. The spawn uses argv and an
@@ -143,7 +208,9 @@ environment built from the host's explicit `env` plus `CODEX_HOME=home`; pass PA
143
208
  and HOME explicitly when needed. No tokens in logs, errors, readings or public hooks;
144
209
  no credential write-back, telemetry, automatic refresh or reset-credit spend.
145
210
 
146
- Built-in requests send `User-Agent: byokit/usage/0.2.0`, never another app's identity.
211
+ Built-in requests send `User-Agent: byokit/usage/0.3.0`, never another app's identity.
212
+
213
+ Token and explicit-file sources send `User-Agent: byokit/usage/0.2.0`, never another app's identity.
147
214
  A refusal returns a code; with no last-good quota, room is unknown. Fixed endpoints
148
215
  are Anthropic `api/oauth/usage`, ChatGPT `backend-api/wham/usage`, GitHub
149
216
  `copilot_internal/user`, Grok `v1/billing` (weekly credits then monthly when needed),
@@ -210,3 +277,13 @@ calls with unknown total counts, member/day/week results expose `unknownCalls`,
210
277
  `tokens` is the known subtotal, and week `remaining` is omitted. Cap and price
211
278
  policy remain the host's. All times, durations and quota reset timestamps are
212
279
  milliseconds. There is no transport, credential discovery or automatic rotation.
280
+
281
+ When passing normalized windows to `@byokit/accounts`' structural helper, use
282
+ `roomOf(reading.windows, reading.at, 'milliseconds')`. Its two-argument form is for
283
+ legacy reset seconds; normalized usage windows in 0.2.0+ already use milliseconds.
284
+ Alternatively, this package's `roomOf(reading, nowMs)` returns a structural `Room`
285
+ that the accounts chooser accepts directly. Preserve the original measurement time.
286
+
287
+ `identity(codexSource)` shares the app-server transport, calls `account/read` with a 15-second deadline, never opens a credential file, and returns only `{signedIn,email?,plan?}`. Managed-folder HTTP usage carries only the app-passed headers plus Bearer authorization and JSON accept; it uses the same bounded HTTP transport.
288
+
289
+ Managed-folder Claude usage uses the shared poll-health and normalized quota pipeline, including scoped hard blocks, unknown usage, last-good observation times, account retry policies and cancellable host origin pacing.
@@ -0,0 +1,12 @@
1
+ import { providerHttp, type Answer } from './providers.ts';
2
+ import type { Source } from './types.ts';
3
+ type ClaudeSource = Extract<Source, {
4
+ folder: string;
5
+ }>;
6
+ /** No ambient HOME discovery. Both the lexical and real folder must be managed by this root. */
7
+ export declare function managedClaudeFolder(folder: string, stateDir: string): boolean;
8
+ /** Metadata only, so account/cache probes never load a token. */
9
+ export declare function claudeCredential(folder: string): import("fs").Stats | undefined;
10
+ /** The credential is held only for this request; no refresh, writes or recovery sidecars. */
11
+ export declare function claudeUsage(source: ClaudeSource, fetcher: typeof fetch, nowMs: number, pacing?: Parameters<typeof providerHttp>[5]): Promise<Answer>;
12
+ export {};
package/dist/claude.js ADDED
@@ -0,0 +1,47 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import { readJson } from "./store.js";
4
+ import { record } from "./windows.js";
5
+ import { providerHttp } from "./providers.js";
6
+ /** No ambient HOME discovery. Both the lexical and real folder must be managed by this root. */
7
+ export function managedClaudeFolder(folder, stateDir) {
8
+ const root = resolve(stateDir);
9
+ const candidate = resolve(folder);
10
+ if (root.split(sep).some((part) => ['.claude', '.codex', '.pi'].includes(part)))
11
+ return false;
12
+ const parent = join(root, 'claude');
13
+ if (!candidate.startsWith(parent + sep) || !/^[a-f0-9]+$/.test(candidate.slice(parent.length + 1)))
14
+ return false;
15
+ try {
16
+ if (![root, parent, candidate].every((path) => { const s = lstatSync(path); return s.isDirectory() && !s.isSymbolicLink(); }))
17
+ return false;
18
+ const realRoot = realpathSync(root);
19
+ if (realRoot.split(sep).some((part) => ['.claude', '.codex', '.pi'].includes(part)))
20
+ return false;
21
+ const realFolder = realpathSync(candidate);
22
+ return realFolder.startsWith(join(realRoot, 'claude') + sep);
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** Metadata only, so account/cache probes never load a token. */
29
+ export function claudeCredential(folder) {
30
+ try {
31
+ const stat = lstatSync(join(folder, '.credentials.json'));
32
+ return stat.isFile() && !stat.isSymbolicLink() && stat.size <= 64 * 1024 ? stat : undefined;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /** The credential is held only for this request; no refresh, writes or recovery sidecars. */
39
+ export async function claudeUsage(source, fetcher, nowMs, pacing) {
40
+ const raw = readJson(join(source.folder, '.credentials.json'), 64 * 1024);
41
+ const oauth = record(raw) && record(raw.claudeAiOauth) ? raw.claudeAiOauth : undefined;
42
+ if (!oauth || typeof oauth.accessToken !== 'string' || !oauth.accessToken.trim() || oauth.accessToken.length > 16384 || /[\x00-\x20\x7f]/.test(oauth.accessToken))
43
+ return { code: 'not-connected' };
44
+ if (typeof oauth.expiresAt !== 'number' || !Number.isFinite(oauth.expiresAt) || oauth.expiresAt <= nowMs)
45
+ return { code: 'expired' };
46
+ return providerHttp('https://api.anthropic.com/api/oauth/usage', oauth.accessToken, fetcher, nowMs, { headers: { 'anthropic-beta': source.headers['anthropic-beta'], 'User-Agent': source.headers['User-Agent'] } }, pacing);
47
+ }
@@ -0,0 +1,11 @@
1
+ import type { Source } from './types.ts';
2
+ export type Identity = {
3
+ signedIn: boolean;
4
+ email?: string;
5
+ plan?: string;
6
+ };
7
+ /** Explicit identity fields only: never walk arbitrary token-bearing objects. */
8
+ export declare function publicIdentity(raw: unknown, signedIn: boolean): Identity;
9
+ export declare function codexIdentity(source: Extract<Source, {
10
+ bin: string;
11
+ }>): Promise<Identity>;
@@ -0,0 +1,16 @@
1
+ import { codexRequest } from "./providers.js";
2
+ import { record } from "./windows.js";
3
+ /** Explicit identity fields only: never walk arbitrary token-bearing objects. */
4
+ export function publicIdentity(raw, signedIn) {
5
+ if (!signedIn || !record(raw))
6
+ return { signedIn: false };
7
+ const short = (value) => typeof value === 'string' && value.length > 0 && value.length <= 320 && !/[\x00-\x1f\x7f]/.test(value);
8
+ const email = short(raw.email) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw.email) ? raw.email : undefined;
9
+ const plan = [raw.planType, raw.subscriptionType, raw.plan, raw.tier, raw.planName].find((value) => short(value) && /^[a-zA-Z][a-zA-Z0-9 _+-]{0,63}$/.test(value));
10
+ return { signedIn: true, ...(email === undefined ? {} : { email }), ...(typeof plan === 'string' ? { plan } : {}) };
11
+ }
12
+ export async function codexIdentity(source) {
13
+ const answer = await codexRequest(source, 'account/read');
14
+ const account = record(answer.raw) ? answer.raw.account : undefined;
15
+ return publicIdentity(account, record(account));
16
+ }
package/dist/index.d.ts CHANGED
@@ -1,12 +1,18 @@
1
- import { type Usage, type UsageOptions } from './types.ts';
1
+ import { type Source, type Usage, type UsageOptions } from './types.ts';
2
+ import { type Identity } from './identity.ts';
2
3
  export * from './types.ts';
4
+ export type { Identity } from './identity.ts';
5
+ /** Identity runs only the named binary, never opens a credential file. */
6
+ export declare function identity(source: Extract<Source, {
7
+ bin: string;
8
+ }>): Promise<Identity>;
3
9
  export { callLedger, normalizeTokens, priceCall, type CallLedger, type CallInput, type CallRecord, type CallQuery, type NormalizedTokens, type ModelPrice, type PriceTable, type CallCost } from './calls.ts';
4
10
  export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError, type TokenLedger, type TokenLedgerStore, type TokenLedgerOptions, type TokenEntry, type TokenQuery } from './ledger.ts';
5
11
  export { roomOf } from './room.ts';
6
12
  export { fingerprint, store as fileUsageStore, memoryUsageStore } from './store.ts';
7
13
  export { retryAfterMs, backoffDelayMs, memoryBackoffPolicy } from './backoff.ts';
8
14
  export { claudeWindows, codexWindows, goWindows, zaiWindows, type CodexRateLimitResult } from './windows.ts';
9
- export { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from './quota.ts';
15
+ export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from './quota.ts';
10
16
  export { WORDS, words, usageWords, type WordKey } from './words.ts';
11
17
  /** One reader owns per-account backoff and concurrent-read deduplication. */
12
18
  export declare function usage(options: UsageOptions): Usage;
package/dist/index.js CHANGED
@@ -1,27 +1,41 @@
1
- import { backoffDelayMs } from "./backoff.js";
2
1
  import { accessSync, constants, statSync } from 'node:fs';
3
2
  import { createHash } from 'node:crypto';
4
3
  import { isAbsolute, join } from 'node:path';
5
4
  import { claudeAuth, claudeUsage, codexUsage, customClaude, providerGet } from "./providers.js";
6
- import { fingerprint, readJson, store, memoryUsageStore, safeWindows } from "./store.js";
5
+ import { fingerprint, readJson, store, memoryUsageStore, safeWindows, safePoll } from "./store.js";
7
6
  import { claudeWindows, codexWindows, goWindows, record, zaiWindows } from "./windows.js";
8
- import { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
7
+ import { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
9
8
  import { UsageError } from "./types.js";
9
+ import { codexIdentity } from "./identity.js";
10
+ import { claudeUsage as managedClaudeUsage, claudeCredential, managedClaudeFolder } from "./claude.js";
10
11
  export * from "./types.js";
12
+ /** Identity runs only the named binary, never opens a credential file. */
13
+ export async function identity(source) {
14
+ if (!record(source) || !('bin' in source) || 'folder' in source || 'credentialsFile' in source || 'read' in source)
15
+ throw new UsageError();
16
+ validate(source);
17
+ return codexIdentity(source);
18
+ }
11
19
  export { callLedger, normalizeTokens, priceCall } from "./calls.js";
12
20
  export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError } from "./ledger.js";
13
21
  export { roomOf } from "./room.js";
14
22
  export { fingerprint, store as fileUsageStore, memoryUsageStore } from "./store.js";
15
23
  export { retryAfterMs, backoffDelayMs, memoryBackoffPolicy } from "./backoff.js";
16
24
  export { claudeWindows, codexWindows, goWindows, zaiWindows } from "./windows.js";
17
- export { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
25
+ export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
18
26
  export { WORDS, words, usageWords } from "./words.js";
19
27
  const providers = ['claude', 'codex', 'opencode', 'zai', 'copilot', 'grok', 'minimax', 'gemini', 'kimi'];
20
28
  const validText = (v) => typeof v === 'string' && !v.includes('\0') && !/[\r\n]/.test(v) && v.length <= 16384;
21
- function validate(source) {
29
+ function validate(source, stateDir) {
22
30
  if (!record(source) || !providers.includes(source.provider))
23
31
  throw new UsageError();
24
- if ('credentialsFile' in source) {
32
+ if ('folder' in source) {
33
+ if (source.provider !== 'claude' || !validText(source.folder) || !isAbsolute(source.folder) || !stateDir || !managedClaudeFolder(source.folder, stateDir))
34
+ throw new UsageError();
35
+ if (!record(source.headers) || !['anthropic-beta', 'User-Agent'].every((key) => validText(source.headers[key]) && source.headers[key].length <= 1024))
36
+ throw new UsageError();
37
+ }
38
+ else if ('credentialsFile' in source) {
25
39
  if (source.provider !== 'claude' || ![source.credentialsFile, ...[source.configFile, source.statuslineFile].filter((v) => v !== undefined)].every((v) => validText(v) && isAbsolute(v)))
26
40
  throw new UsageError();
27
41
  }
@@ -44,6 +58,16 @@ function validate(source) {
44
58
  if ('access' in source && source.provider === 'codex' && (!validText(source.accountId) || !source.accountId))
45
59
  throw new UsageError();
46
60
  }
61
+ if ('origin' in source && source.origin !== undefined) {
62
+ try {
63
+ const url = new URL(source.origin);
64
+ if (!['https:', 'http:'].includes(url.protocol) || url.origin !== source.origin)
65
+ throw new Error();
66
+ }
67
+ catch {
68
+ throw new UsageError();
69
+ }
70
+ }
47
71
  const fields = source;
48
72
  for (const field of ['accountId', 'accountUuid', 'project'])
49
73
  if (fields[field] !== undefined && (!validText(fields[field]) || !fields[field]))
@@ -72,9 +96,12 @@ export function usage(options) {
72
96
  const backoffs = new Map();
73
97
  const inFlight = new Map();
74
98
  const attempts = new Map();
99
+ const failures = new Map();
75
100
  const now = (opts) => opts?.nowMs ?? (options.now ?? Date.now)();
76
101
  function connected(source) {
77
- validate(source);
102
+ validate(source, options.stateDir);
103
+ if ('folder' in source)
104
+ return claudeCredential(source.folder) !== undefined;
78
105
  if ('credentialsFile' in source)
79
106
  return claudeAuth(source) !== undefined;
80
107
  if ('read' in source) {
@@ -97,9 +124,13 @@ export function usage(options) {
97
124
  return ('access' in source ? source.access : source.key).trim() !== '';
98
125
  }
99
126
  function account(source) {
100
- validate(source);
127
+ validate(source, options.stateDir);
101
128
  let id;
102
- if ('credentialsFile' in source)
129
+ if ('folder' in source) {
130
+ const stat = claudeCredential(source.folder);
131
+ id = stat ? `${source.folder}\0${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` : undefined;
132
+ }
133
+ else if ('credentialsFile' in source)
103
134
  id = claudeAuth(source)?.account;
104
135
  else if ('read' in source)
105
136
  id = source.accountUuid;
@@ -123,7 +154,7 @@ export function usage(options) {
123
154
  function windows(source, raw, clock) {
124
155
  switch (source.provider) {
125
156
  case 'claude': return claudeWindows(raw);
126
- case 'codex': return 'bin' in source ? codexWindows(raw) : codexTokenWindows(raw);
157
+ case 'codex': return 'bin' in source ? codexWindows(raw) : codexTokenWindows(raw, clock);
127
158
  case 'opencode': return goWindows(record(raw) ? raw.usage : undefined);
128
159
  case 'zai': return zaiWindows(record(raw) && record(raw.data) ? raw.data.limits : undefined);
129
160
  case 'copilot': return copilotWindows(raw);
@@ -140,76 +171,121 @@ export function usage(options) {
140
171
  const clock = now(opts);
141
172
  try {
142
173
  const stored = (id.stable ? disk : transient).get(source.provider, id.key);
143
- if (!stored || !Number.isFinite(stored.at) || clock < stored.at || clock - stored.at > 86_400_000)
174
+ if (!stored)
175
+ return undefined;
176
+ if (!stored.limited && !stored.windows.some((w) => w.limited) && stored.at !== undefined && (clock < stored.at || clock - stored.at > 86_400_000))
144
177
  return undefined;
145
178
  const rows = safeWindows(source.provider, stored.windows);
146
- return rows.length ? { provider: source.provider, windows: rows, at: stored.at } : undefined;
179
+ const poll = attempts.get(`${source.provider}\0${id.key}`) ?? safePoll(stored.poll);
180
+ return rows.length || stored.limited ? { provider: source.provider, windows: rows, ...(stored.at !== undefined ? { at: stored.at } : {}),
181
+ ...(stored.limited ? { limited: true } : {}), ...(poll ? { poll, ...(poll.outcome !== 'ok' ? { code: poll.outcome } : {}) } : {}) } : undefined;
147
182
  }
148
183
  catch {
149
184
  return undefined;
150
185
  }
151
186
  }
152
187
  async function read(source, opts) {
153
- validate(source);
188
+ validate(source, options.stateDir);
154
189
  const clock = now(opts);
155
- const empty = (code) => ({ provider: source.provider, windows: [], at: clock, code });
190
+ const empty = (code) => ({ provider: source.provider, windows: [], code, poll: { at: clock, outcome: code ?? 'unavailable' } });
156
191
  if (!connected(source))
157
192
  return empty('not-connected');
158
193
  const id = identity(source);
159
194
  const key = `${source.provider}\0${id.key}`;
160
195
  const previous = lastKnown(source, { nowMs: clock });
161
- if (previous && clock - previous.at < 60_000)
196
+ if (previous && !previous.code && previous.at !== undefined && clock >= previous.at && clock - previous.at < 60_000)
162
197
  return previous;
163
198
  let until = backoffs.get(key) ?? 0;
164
199
  try {
165
- if (id.stable)
166
- until = Math.max(until, options.backoff?.get(source.provider, id.key) ?? 0);
200
+ const saved = id.stable ? options.backoff?.get(source.provider, id.key) : undefined;
201
+ if (typeof saved === 'number' && Number.isFinite(saved))
202
+ until = Math.max(until, saved);
203
+ else if (saved && typeof saved !== 'number' && Number.isFinite(saved.untilMs) && Number.isFinite(saved.at) && ['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'].includes(saved.outcome)) {
204
+ until = Math.max(until, saved.untilMs);
205
+ if (!attempts.has(key))
206
+ attempts.set(key, { at: saved.at, outcome: saved.outcome, retryAt: saved.untilMs });
207
+ if (!failures.has(`${key}\0${saved.outcome}`) && Number.isSafeInteger(saved.failures) && saved.failures > 0)
208
+ failures.set(`${key}\0${saved.outcome}`, saved.failures);
209
+ }
167
210
  }
168
211
  catch { /* keep internal backoff */ }
169
- if (until > clock)
170
- return previous ? { ...previous, code: 'rate-limited' } : empty('rate-limited');
212
+ if (until > clock) {
213
+ const poll = attempts.get(key) ?? previous?.poll ?? { at: clock, outcome: 'unavailable', retryAt: until };
214
+ return { ...(previous ?? empty(poll.outcome === 'ok' ? 'unavailable' : poll.outcome)), code: poll.outcome === 'ok' ? undefined : poll.outcome, poll };
215
+ }
171
216
  const pending = inFlight.get(key);
172
217
  if (pending)
173
218
  return pending;
174
- const attempt = attempts.get(key);
219
+ const attempt = attempts.get(key) ?? previous?.poll;
175
220
  if (attempt && clock >= attempt.at && clock - attempt.at < 60_000)
176
- return previous ? { ...previous, code: attempt.code } : empty(attempt.code ?? 'unavailable');
221
+ return previous ? { ...previous, code: attempt.outcome === 'ok' ? undefined : attempt.outcome, poll: attempt } : empty(attempt.outcome === 'ok' ? 'unavailable' : attempt.outcome);
177
222
  const task = (async () => {
178
223
  let answer;
224
+ const pacing = { hook: options.pace, provider: source.provider, account: id.key, signal: opts?.signal };
179
225
  try {
180
- answer = 'read' in source ? await customClaude(source, clock) : 'credentialsFile' in source ? await claudeUsage(source, options.fetch ?? globalThis.fetch, clock)
181
- : 'bin' in source ? await codexUsage(source) : await providerGet(source, options.fetch ?? globalThis.fetch, clock);
226
+ answer = 'folder' in source ? await managedClaudeUsage(source, options.fetch ?? globalThis.fetch, clock, pacing) : 'read' in source ? await customClaude(source, clock, pacing) : 'credentialsFile' in source ? await claudeUsage(source, options.fetch ?? globalThis.fetch, clock, pacing)
227
+ : 'bin' in source ? await codexUsage(source) : await providerGet(source, options.fetch ?? globalThis.fetch, clock, pacing);
182
228
  }
183
229
  catch {
184
230
  answer = { code: 'unavailable' };
185
231
  }
186
- if (answer.code === 'rate-limited') {
187
- let delay = backoffDelayMs(answer.retryAfterMs);
188
- try {
189
- const selected = options.backoff?.delayMs?.(answer.retryAfterMs);
190
- if (selected !== undefined && Number.isFinite(selected) && selected >= 0)
191
- delay = selected;
232
+ const rows = safeWindows(source.provider, windows(source, answer.raw, clock));
233
+ const limited = !answer.code && (answer.limited === true || source.provider === 'codex' && codexHardLimit(answer.raw));
234
+ if (!answer.code && !rows.length && !limited)
235
+ answer = { code: 'incomplete' };
236
+ const poll = { at: clock, outcome: answer.code ?? 'ok' };
237
+ if (answer.code) {
238
+ const failureKey = `${key}\0${answer.code}`;
239
+ const count = (failures.get(failureKey) ?? 0) + 1;
240
+ failures.set(failureKey, count);
241
+ if (['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'].includes(answer.code)) {
242
+ const retry = Number.isFinite(answer.retryAfterMs) && answer.retryAfterMs >= 0 ? answer.retryAfterMs : undefined;
243
+ let delay = answer.code === 'rate-limited' ? 300_000 : Math.min(3_600_000, 60_000 * 2 ** Math.min(count - 1, 6));
244
+ try {
245
+ const selected = options.backoff?.delayMs?.(retry, { outcome: answer.code, failures: count });
246
+ if (selected !== undefined && Number.isFinite(selected) && selected >= 0)
247
+ delay = selected;
248
+ }
249
+ catch { /* internal policy stands */ }
250
+ delay = Math.max(60_000, delay, retry ?? 0);
251
+ poll.retryAt = clock + delay;
252
+ backoffs.set(key, poll.retryAt);
253
+ try {
254
+ if (id.stable)
255
+ options.backoff?.set(source.provider, id.key, poll.retryAt, { untilMs: poll.retryAt, at: clock, outcome: answer.code, failures: count });
256
+ }
257
+ catch { /* internal backoff stands */ }
192
258
  }
193
- catch { /* default */ }
194
- backoffs.set(key, clock + delay);
259
+ }
260
+ else {
261
+ for (const outcome of ['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'])
262
+ failures.delete(`${key}\0${outcome}`);
263
+ backoffs.delete(key);
195
264
  try {
196
265
  if (id.stable)
197
- options.backoff?.set(source.provider, id.key, clock + delay);
266
+ options.backoff?.set(source.provider, id.key, 0);
198
267
  }
199
- catch { /* internal backoff stands */ }
268
+ catch { /* expired host policy remains bounded */ }
200
269
  }
201
- const rows = safeWindows(source.provider, windows(source, answer.raw, clock));
202
- if (!answer.code && !rows.length)
203
- answer = { code: 'incomplete' };
204
- attempts.set(key, { at: clock, code: answer.code });
205
- if (rows.length && !answer.code) {
270
+ attempts.set(key, poll);
271
+ if ((rows.length || limited) && !answer.code) {
272
+ // Explicit host/snapshot time is authoritative, including unknown or future time.
273
+ const observed = 'at' in answer ? answer.at : clock;
274
+ const at = typeof observed === 'number' && Number.isFinite(observed) ? observed : undefined;
275
+ const reading = { provider: source.provider, windows: rows, ...(at !== undefined ? { at } : {}), ...(limited ? { limited: true } : {}), poll };
206
276
  try {
207
- (id.stable ? disk : transient).put(source.provider, id.key, { at: clock, windows: rows });
277
+ (id.stable ? disk : transient).put(source.provider, id.key, { at: reading.at, windows: rows, ...(limited ? { limited: true } : {}), poll });
208
278
  }
209
279
  catch { /* reads survive a store failure */ }
210
- return { provider: source.provider, windows: rows, at: clock };
280
+ return reading;
281
+ }
282
+ if (previous) {
283
+ try {
284
+ (id.stable ? disk : transient).put(source.provider, id.key, { at: previous.at, windows: previous.windows, ...(previous.limited ? { limited: true } : {}), poll });
285
+ }
286
+ catch { /* last-good remains in memory */ }
211
287
  }
212
- return previous ? { ...previous, code: answer.code } : empty(answer.code ?? 'unavailable');
288
+ return { ...(previous ?? empty(answer.code ?? 'unavailable')), code: answer.code, poll };
213
289
  })();
214
290
  inFlight.set(key, task);
215
291
  try {
@@ -1,22 +1,41 @@
1
- import type { Code, Source } from './types.ts';
2
- export interface Answer {
3
- raw?: unknown;
4
- code?: Code;
5
- retryAfterMs?: number;
6
- }
1
+ import type { Source, SourceAnswer, PacingHook } from './types.ts';
2
+ export type Answer = SourceAnswer;
7
3
  type TokenSource = Extract<Source, {
8
4
  access: string;
9
5
  } | {
10
6
  key: string;
11
7
  }>;
12
- export declare function providerGet(source: TokenSource, fetcher: typeof fetch, nowMs: number): Promise<Answer>;
8
+ /** One bounded request; credentials and response bodies never become errors. */
9
+ export declare function providerHttp(url: string, key: string, fetcher: typeof fetch, nowMs: number, extra?: {
10
+ headers?: Record<string, string>;
11
+ body?: unknown;
12
+ }, pacing?: {
13
+ hook?: PacingHook;
14
+ provider: Source['provider'];
15
+ account: string;
16
+ signal?: AbortSignal;
17
+ }): Promise<Answer>;
18
+ export declare function providerGet(source: TokenSource, fetcher: typeof fetch, nowMs: number, pacing?: {
19
+ hook?: PacingHook;
20
+ provider: Source['provider'];
21
+ account: string;
22
+ signal?: AbortSignal;
23
+ }): Promise<Answer>;
13
24
  /** A host hook gets the same deadline and failure envelope as built-in sources. */
14
25
  export declare function customClaude(source: Extract<Source, {
15
26
  read: unknown;
16
- }>, nowMs: number): Promise<Answer>;
27
+ }>, nowMs: number, pacing?: {
28
+ hook?: PacingHook;
29
+ account: string;
30
+ signal?: AbortSignal;
31
+ }): Promise<Answer>;
17
32
  export declare function codexUsage(source: Extract<Source, {
18
33
  bin: string;
19
34
  }>): Promise<Answer>;
35
+ /** The single bounded app-server transport used for identity and usage. */
36
+ export declare function codexRequest(source: Extract<Source, {
37
+ bin: string;
38
+ }>, method: 'account/read' | 'account/rateLimits/read', timeoutMs?: number): Promise<Answer>;
20
39
  /** Re-read the tool's credentials so its own renewal is picked up; never renew or write them. */
21
40
  export declare function claudeAuth(source: Extract<Source, {
22
41
  credentialsFile: string;
@@ -27,5 +46,10 @@ export declare function claudeAuth(source: Extract<Source, {
27
46
  } | undefined;
28
47
  export declare function claudeUsage(source: Extract<Source, {
29
48
  credentialsFile: string;
30
- }>, fetcher: typeof fetch, nowMs: number): Promise<Answer>;
49
+ }>, fetcher: typeof fetch, nowMs: number, pacing?: {
50
+ hook?: PacingHook;
51
+ provider: Source['provider'];
52
+ account: string;
53
+ signal?: AbortSignal;
54
+ }): Promise<Answer>;
31
55
  export {};
package/dist/providers.js CHANGED
@@ -5,10 +5,23 @@ import { claudeWindows, record } from "./windows.js";
5
5
  import { grokWindows } from "./quota.js";
6
6
  const USER_AGENT = 'byokit/usage/0.2.0';
7
7
  /** One bounded request; credentials and response bodies never become errors. */
8
- async function request(url, key, fetcher, nowMs, extra = {}) {
8
+ export async function providerHttp(url, key, fetcher, nowMs, extra = {}, pacing) {
9
9
  const controller = new AbortController();
10
- const timer = setTimeout(() => controller.abort(), 10_000);
10
+ const abort = () => controller.abort();
11
+ pacing?.signal?.addEventListener('abort', abort, { once: true });
12
+ if (pacing?.signal?.aborted)
13
+ controller.abort();
14
+ const timer = setTimeout(abort, 10_000);
11
15
  try {
16
+ if (pacing?.hook)
17
+ await Promise.race([pacing.hook({ provider: pacing.provider, account: pacing.account, origin: new URL(url).origin, signal: controller.signal }), new Promise((_, reject) => {
18
+ if (controller.signal.aborted)
19
+ reject(new Error());
20
+ else
21
+ controller.signal.addEventListener('abort', () => reject(new Error()), { once: true });
22
+ })]);
23
+ if (controller.signal.aborted)
24
+ return { code: 'unavailable' };
12
25
  const response = await fetcher(url, { headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'User-Agent': USER_AGENT,
13
26
  ...(extra.body !== undefined ? { 'content-type': 'application/json' } : {}), ...extra.headers },
14
27
  ...(extra.body !== undefined ? { method: 'POST', body: JSON.stringify(extra.body) } : {}), redirect: 'error', signal: controller.signal });
@@ -51,11 +64,12 @@ async function request(url, key, fetcher, nowMs, extra = {}) {
51
64
  }
52
65
  finally {
53
66
  clearTimeout(timer);
67
+ pacing?.signal?.removeEventListener('abort', abort);
54
68
  }
55
69
  }
56
- export async function providerGet(source, fetcher, nowMs) {
70
+ export async function providerGet(source, fetcher, nowMs, pacing) {
57
71
  const key = 'access' in source ? source.access : source.key;
58
- const get = (url, extra) => request(url, key, fetcher, nowMs, extra);
72
+ const get = (url, extra) => providerHttp(url, key, fetcher, nowMs, extra, pacing);
59
73
  switch (source.provider) {
60
74
  case 'claude': return get('https://api.anthropic.com/api/oauth/usage', { headers: { 'anthropic-beta': 'oauth-2025-04-20' } });
61
75
  case 'codex': return get('https://chatgpt.com/backend-api/wham/usage', { headers: { 'ChatGPT-Account-Id': source.accountId } });
@@ -91,12 +105,28 @@ export async function providerGet(source, fetcher, nowMs) {
91
105
  }
92
106
  }
93
107
  /** A host hook gets the same deadline and failure envelope as built-in sources. */
94
- export async function customClaude(source, nowMs) {
108
+ export async function customClaude(source, nowMs, pacing) {
95
109
  const controller = new AbortController();
96
- let timer;
110
+ const abort = () => controller.abort();
111
+ pacing?.signal?.addEventListener('abort', abort, { once: true });
112
+ if (pacing?.signal?.aborted)
113
+ controller.abort();
114
+ const timer = setTimeout(abort, 10_000);
97
115
  try {
98
- return await Promise.race([source.read({ nowMs, signal: controller.signal }), new Promise((resolve) => {
99
- timer = setTimeout(() => { controller.abort(); resolve({ code: 'unavailable' }); }, 10_000);
116
+ const operation = async () => {
117
+ if (source.origin && pacing?.hook)
118
+ await pacing.hook({ provider: source.provider, account: pacing.account, origin: source.origin, signal: controller.signal });
119
+ if (controller.signal.aborted)
120
+ return { code: 'unavailable' };
121
+ const answer = await source.read({ nowMs, signal: controller.signal });
122
+ // Host readers may return cached figures; only the host knows observation time.
123
+ return { ...answer, at: answer.at };
124
+ };
125
+ return await Promise.race([operation(), new Promise((resolve) => {
126
+ if (controller.signal.aborted)
127
+ resolve({ code: 'unavailable' });
128
+ else
129
+ controller.signal.addEventListener('abort', () => resolve({ code: 'unavailable' }), { once: true });
100
130
  })]);
101
131
  }
102
132
  catch {
@@ -104,9 +134,14 @@ export async function customClaude(source, nowMs) {
104
134
  }
105
135
  finally {
106
136
  clearTimeout(timer);
137
+ pacing?.signal?.removeEventListener('abort', abort);
107
138
  }
108
139
  }
109
140
  export function codexUsage(source) {
141
+ return codexRequest(source, 'account/rateLimits/read', 20_000);
142
+ }
143
+ /** The single bounded app-server transport used for identity and usage. */
144
+ export function codexRequest(source, method, timeoutMs = 15_000) {
110
145
  return new Promise((resolve) => {
111
146
  const child = spawn(source.bin, ['app-server'], { env: { ...source.env, CODEX_HOME: source.home }, stdio: ['pipe', 'pipe', 'ignore'] });
112
147
  let buffer = '';
@@ -125,7 +160,7 @@ export function codexUsage(source) {
125
160
  }
126
161
  resolve(answer);
127
162
  };
128
- const timer = setTimeout(() => finish({ code: 'unavailable' }), 20_000);
163
+ const timer = setTimeout(() => finish({ code: 'unavailable' }), timeoutMs);
129
164
  child.once('error', () => finish({ code: 'unavailable' }));
130
165
  child.once('close', () => { clearTimeout(escalation); finish({ code: 'unavailable' }); });
131
166
  child.stdin.on('error', () => finish({ code: 'unavailable' }));
@@ -150,7 +185,7 @@ export function codexUsage(source) {
150
185
  if (!record(message))
151
186
  continue;
152
187
  if (message.id === 1)
153
- child.stdin.write(`${JSON.stringify({ id: 2, method: 'account/rateLimits/read', params: {} })}\n`);
188
+ child.stdin.write(`${JSON.stringify({ id: 2, method, params: {} })}\n`);
154
189
  if (message.id === 2)
155
190
  finish(record(message.result) ? { raw: message.result } : { code: 'incomplete' });
156
191
  }
@@ -174,14 +209,18 @@ export function claudeAuth(source) {
174
209
  return undefined;
175
210
  return { token, account: identity, ...(typeof credentials.expiresAt === 'number' && Number.isFinite(credentials.expiresAt) ? { expiresAt: credentials.expiresAt } : {}) };
176
211
  }
177
- export async function claudeUsage(source, fetcher, nowMs) {
212
+ export async function claudeUsage(source, fetcher, nowMs, pacing) {
178
213
  const snapshot = source.statuslineFile ? readJsonSnapshot(source.statuslineFile, 64 * 1024) : undefined;
179
- if (snapshot && nowMs >= snapshot.modified && nowMs - snapshot.modified < 300_000 && claudeWindows(snapshot.value).length)
180
- return { raw: snapshot.value };
214
+ if (snapshot && record(snapshot.value) && claudeWindows(snapshot.value).length) {
215
+ const rawTime = snapshot.value.fetched_at;
216
+ const at = typeof rawTime === 'number' ? rawTime : typeof rawTime === 'string' ? Date.parse(rawTime) : undefined;
217
+ if (at === undefined || !Number.isFinite(at) || nowMs < at || nowMs - at < 300_000)
218
+ return { raw: snapshot.value, at };
219
+ }
181
220
  const auth = claudeAuth(source);
182
221
  if (!auth)
183
222
  return { code: 'not-connected' };
184
223
  if (auth.expiresAt !== undefined && auth.expiresAt <= nowMs)
185
224
  return { code: 'expired' };
186
- return providerGet({ provider: 'claude', access: auth.token }, fetcher, nowMs);
225
+ return providerGet({ provider: 'claude', access: auth.token }, fetcher, nowMs, pacing);
187
226
  }
package/dist/quota.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Window } from './types.ts';
2
- export declare function codexTokenWindows(raw: unknown): Window[];
2
+ export declare function codexTokenWindows(raw: unknown, nowMs?: number): Window[];
3
+ export declare function codexHardLimit(raw: unknown): boolean;
3
4
  export declare function copilotWindows(raw: unknown): Window[];
4
5
  export declare function grokWindows(raw: unknown): Window[];
5
6
  export declare function minimaxWindows(raw: unknown): Window[];
package/dist/quota.js CHANGED
@@ -12,18 +12,22 @@ const ratio = (used, limit, remaining) => {
12
12
  const left = number(remaining);
13
13
  return cap !== undefined && cap > 0 && (consumed !== undefined || left !== undefined) ? 100 * (consumed ?? cap - left) / cap : undefined;
14
14
  };
15
- export function codexTokenWindows(raw) {
15
+ export function codexTokenWindows(raw, nowMs) {
16
16
  const source = obj(raw);
17
17
  const plan = obj(source.rate_limit);
18
- const group = (value, name) => ({ limitName: name,
19
- ...Object.fromEntries(['primary', 'secondary'].map((key) => {
18
+ const group = (value, name) => ({ limitName: name, limitReached: value.limit_reached === true,
19
+ ...Object.fromEntries(['primary', 'secondary'].filter((key) => record(value[`${key}_window`])).map((key) => {
20
20
  const w = obj(value[`${key}_window`]);
21
- return [key, { usedPercent: w.used_percent, windowDurationMins: typeof w.limit_window_seconds === 'number' ? w.limit_window_seconds / 60 : undefined, resetsAt: w.reset_at }];
21
+ return [key, { usedPercent: w.used_percent, windowDurationMins: typeof w.limit_window_seconds === 'number' ? w.limit_window_seconds / 60 : undefined, resetsAt: w.reset_at !== undefined ? w.reset_at : number(w.reset_after_seconds) !== undefined && number(w.reset_after_seconds) >= 0 && nowMs !== undefined ? (nowMs + number(w.reset_after_seconds) * 1000) / 1000 : undefined }];
22
22
  })) });
23
23
  return codexWindows({ rateLimitsByLimitId: Object.fromEntries([
24
24
  ['plan', group(plan, 'Codex')], ...rows(source.additional_rate_limits).map((v, i) => { const extra = obj(v); return [String(i), group(obj(extra.rate_limit), label(extra.limit_name))]; }),
25
25
  ]) });
26
26
  }
27
+ export function codexHardLimit(raw) {
28
+ const source = obj(raw);
29
+ return obj(source.rate_limit).limit_reached === true || obj(source.rateLimits).limitReached === true || Object.values(obj(source.rateLimitsByLimitId)).some((v) => obj(v).limitReached === true) || rows(source.additional_rate_limits).some((v) => obj(obj(v).rate_limit).limit_reached === true);
30
+ }
27
31
  export function copilotWindows(raw) {
28
32
  const source = obj(raw);
29
33
  return Object.entries(obj(source.quota_snapshots)).flatMap(([key, value]) => {
package/dist/room.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import type { Reading, Room } from './types.ts';
2
- /** Tightest known quota, valid for 24 hours. Refused/authless sources report unknown room. */
2
+ /** Figures retain their observation age; poll failure does not imply exhaustion. */
3
3
  export declare function roomOf(reading: Reading, nowMs: number): Room;
package/dist/room.js CHANGED
@@ -1,11 +1,25 @@
1
- /** Tightest known quota, valid for 24 hours. Refused/authless sources report unknown room. */
1
+ /** Figures retain their observation age; poll failure does not imply exhaustion. */
2
2
  export function roomOf(reading, nowMs) {
3
- if (!Number.isFinite(reading.at) || !Number.isFinite(nowMs) || nowMs < reading.at || nowMs - reading.at > 86_400_000 || ['not-connected', 'expired', 'auth', 'no-plan'].includes(reading.code ?? ''))
4
- return { left: 'unknown', at: reading.at };
5
- const tight = reading.windows.reduce((worst, window) => Number.isFinite(window.usedPercent) && (!worst || window.usedPercent > worst.usedPercent) ? window : worst, undefined);
3
+ const at = reading.at;
4
+ const ageMs = at !== undefined && Number.isFinite(at) && Number.isFinite(nowMs) && nowMs >= at ? nowMs - at : undefined;
5
+ const freshness = at === undefined || !Number.isFinite(at) || !Number.isFinite(nowMs) ? 'unknown' : nowMs < at ? 'future' : ageMs > 86_400_000 ? 'stale' : 'fresh';
6
+ const meta = { ...(at !== undefined ? { at } : {}), ...(ageMs !== undefined ? { ageMs } : {}), freshness,
7
+ ...(reading.poll ? { poll: reading.poll } : {}) };
8
+ const blocked = reading.windows.find((w) => w.limited === true);
9
+ // A reset prediction never clears an authoritative block, even in an old reading.
10
+ if (reading.limited || blocked)
11
+ return { ...meta, left: 0, span: 'tightest', limited: true,
12
+ ...(blocked?.scope ? { scope: blocked.scope } : {}), ...(blocked?.resetsAt !== undefined ? { resetsAt: blocked.resetsAt } : {}) };
13
+ if (freshness !== 'fresh' || ['not-connected', 'expired', 'auth', 'no-plan'].includes(reading.code ?? ''))
14
+ return { ...meta, left: 'unknown' };
15
+ const tight = reading.windows.reduce((worst, w) => typeof w.usedPercent === 'number' && Number.isFinite(w.usedPercent) && (!worst || w.usedPercent > worst.usedPercent) ? w : worst, undefined);
6
16
  if (!tight)
7
- return { left: 'unknown', at: reading.at };
17
+ return { ...meta, left: 'unknown' };
8
18
  const span = tight.kind === 'weekly' ? 'week' : tight.kind === 'monthly' ? 'month' : tight.kind === 'session' ? 'session' : 'tightest';
9
- return { left: Math.max(0, Math.min(100, 100 - tight.usedPercent)), span, at: reading.at,
19
+ const left = Math.max(0, Math.min(100, 100 - tight.usedPercent));
20
+ // Incomplete applicable windows cannot establish available room. Known exhaustion still stands.
21
+ if (left > 0 && reading.windows.some((w) => w.usedPercent === undefined))
22
+ return { ...meta, left: 'unknown' };
23
+ return { ...meta, left, span, ...(tight.scope ? { scope: tight.scope } : {}),
10
24
  ...(Number.isFinite(tight.resetsAt) ? { resetsAt: tight.resetsAt } : {}) };
11
25
  }
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Provider, StoredReading, UsageStore, Window } from './types.ts';
1
+ import type { Provider, StoredReading, UsageStore, Window, Poll } from './types.ts';
2
2
  export type Stored = StoredReading;
3
3
  /** Bounded regular files only; do not follow credential or state symlinks. */
4
4
  export declare function readJsonSnapshot(file: string, cap: number): {
@@ -7,6 +7,8 @@ export declare function readJsonSnapshot(file: string, cap: number): {
7
7
  } | undefined;
8
8
  export declare function readJson(file: string, cap: number): unknown;
9
9
  export declare function fingerprint(salt: string): (provider: Provider, value: string) => string;
10
+ /** Poll metadata is distinct from the observation timestamp. */
11
+ export declare function safePoll(raw: unknown): Poll | undefined;
10
12
  export declare function store(stateDir: string): UsageStore;
11
13
  /** Public stores receive only these quota fields, never raw provider payloads. */
12
14
  export declare function safeWindows(provider: Provider, raw: unknown): Window[];
package/dist/store.js CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID, scryptSync } from 'node:crypto';
2
2
  import { chmodSync, closeSync, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, constants } from 'node:fs';
3
3
  import { isAbsolute, join } from 'node:path';
4
4
  import { UsageError } from "./types.js";
5
- import { record } from "./windows.js";
5
+ import { record, quotaScope } from "./windows.js";
6
6
  /** Bounded regular files only; do not follow credential or state symlinks. */
7
7
  export function readJsonSnapshot(file, cap) {
8
8
  let fd;
@@ -44,6 +44,12 @@ export function fingerprint(salt) {
44
44
  return fp;
45
45
  };
46
46
  }
47
+ /** Poll metadata is distinct from the observation timestamp. */
48
+ export function safePoll(raw) {
49
+ if (!record(raw) || typeof raw.at !== 'number' || !Number.isFinite(raw.at) || !['ok', 'not-connected', 'expired', 'auth', 'no-plan', 'rate-limited', 'unavailable', 'incomplete', 'refresh-failed'].includes(String(raw.outcome)))
50
+ return undefined;
51
+ return { at: raw.at, outcome: raw.outcome, ...(typeof raw.retryAt === 'number' && Number.isFinite(raw.retryAt) ? { retryAt: raw.retryAt } : {}) };
52
+ }
47
53
  export function store(stateDir) {
48
54
  if (typeof stateDir !== 'string' || !isAbsolute(stateDir) || /[\0\r\n]/.test(stateDir))
49
55
  throw new UsageError();
@@ -59,8 +65,8 @@ export function store(stateDir) {
59
65
  continue;
60
66
  const readings = Object.create(null);
61
67
  for (const [fp, r] of Object.entries(entries)) {
62
- if (/^[a-f0-9]{64}$/.test(fp) && record(r) && typeof r.at === 'number' && Number.isFinite(r.at))
63
- readings[fp] = { at: r.at, windows: safeWindows(id, r.windows) };
68
+ if (/^[a-f0-9]{64}$/.test(fp) && record(r) && (r.at === undefined || typeof r.at === 'number' && Number.isFinite(r.at)))
69
+ readings[fp] = { ...(typeof r.at === 'number' ? { at: r.at } : {}), windows: safeWindows(id, r.windows), ...(r.limited === true ? { limited: true } : {}), ...(safePoll(r.poll) ? { poll: safePoll(r.poll) } : {}) };
64
70
  }
65
71
  plans[id] = readings;
66
72
  }
@@ -75,9 +81,9 @@ export function store(stateDir) {
75
81
  try {
76
82
  const plans = load();
77
83
  const entries = plans[id] ?? {};
78
- if (reading.at < (entries[fp]?.at ?? -Infinity))
84
+ if (reading.at !== undefined && reading.at < (entries[fp]?.at ?? -Infinity))
79
85
  return;
80
- entries[fp] = { at: reading.at, windows: safeWindows(id, reading.windows) };
86
+ entries[fp] = { at: reading.at, windows: safeWindows(id, reading.windows), ...(reading.limited ? { limited: true } : {}), ...(safePoll(reading.poll) ? { poll: safePoll(reading.poll) } : {}) };
81
87
  plans[id] = entries;
82
88
  const body = JSON.stringify({ plans });
83
89
  if (Buffer.byteLength(body) > 256 * 1024)
@@ -100,9 +106,10 @@ export function store(stateDir) {
100
106
  /** Public stores receive only these quota fields, never raw provider payloads. */
101
107
  export function safeWindows(provider, raw) {
102
108
  return (Array.isArray(raw) ? raw : []).slice(0, 64).flatMap((value) => {
103
- if (!record(value) || !['session', 'weekly', 'monthly', 'rolling', 'custom'].includes(String(value.kind)) || typeof value.usedPercent !== 'number' || !Number.isFinite(value.usedPercent))
109
+ if (!record(value) || !['session', 'weekly', 'monthly', 'rolling', 'custom'].includes(String(value.kind)) || value.usedPercent !== undefined && (typeof value.usedPercent !== 'number' || !Number.isFinite(value.usedPercent)))
104
110
  return [];
105
- return [{ provider, kind: value.kind, usedPercent: Math.max(0, Math.min(100, value.usedPercent)),
111
+ return [{ provider, kind: value.kind, ...(typeof value.usedPercent === 'number' ? { usedPercent: Math.max(0, Math.min(100, value.usedPercent)) } : {}),
112
+ ...(quotaScope(value.scope) ? { scope: quotaScope(value.scope) } : {}),
106
113
  ...(typeof value.minutes === 'number' && Number.isFinite(value.minutes) && value.minutes > 0 ? { minutes: value.minutes } : {}),
107
114
  ...(typeof value.resetsAt === 'number' && Number.isFinite(value.resetsAt) ? { resetsAt: value.resetsAt } : {}),
108
115
  ...(value.limited === true ? { limited: true } : {}),
@@ -111,7 +118,7 @@ export function safeWindows(provider, raw) {
111
118
  }
112
119
  export function memoryUsageStore() {
113
120
  const readings = new Map();
114
- return { get: (provider, account) => { const r = readings.get(`${provider}\0${account}`); return r ? { at: r.at, windows: safeWindows(provider, r.windows) } : undefined; },
115
- put: (provider, account, reading) => { const key = `${provider}\0${account}`; if (reading.at >= (readings.get(key)?.at ?? -Infinity))
116
- readings.set(key, { at: reading.at, windows: safeWindows(provider, reading.windows) }); } };
121
+ return { get: (provider, account) => { const r = readings.get(`${provider}\0${account}`); return r ? { at: r.at, windows: safeWindows(provider, r.windows), ...(r.limited ? { limited: true } : {}), ...(safePoll(r.poll) ? { poll: safePoll(r.poll) } : {}) } : undefined; },
122
+ put: (provider, account, reading) => { const key = `${provider}\0${account}`; if (reading.at === undefined || reading.at >= (readings.get(key)?.at ?? -Infinity))
123
+ readings.set(key, { at: reading.at, windows: safeWindows(provider, reading.windows), ...(reading.limited ? { limited: true } : {}), ...(safePoll(reading.poll) ? { poll: safePoll(reading.poll) } : {}) }); } };
117
124
  }
package/dist/types.d.ts CHANGED
@@ -4,6 +4,13 @@ type Identity = {
4
4
  accountId?: string;
5
5
  };
6
6
  export type Source = {
7
+ provider: 'claude';
8
+ folder: string;
9
+ headers: {
10
+ 'anthropic-beta': string;
11
+ 'User-Agent': string;
12
+ };
13
+ } | {
7
14
  provider: 'codex';
8
15
  bin: string;
9
16
  home: string;
@@ -20,6 +27,7 @@ export type Source = {
20
27
  provider: 'claude';
21
28
  accountUuid: string;
22
29
  read: ClaudeReader;
30
+ origin?: string;
23
31
  connected?: () => boolean;
24
32
  }) | {
25
33
  provider: 'claude';
@@ -43,34 +51,55 @@ export type Window = {
43
51
  provider: Provider;
44
52
  kind: Kind;
45
53
  limit?: string;
46
- usedPercent: number;
54
+ usedPercent?: number;
55
+ scope?: Scope;
47
56
  minutes?: number;
48
57
  resetsAt?: number;
49
58
  limited?: boolean;
50
59
  };
51
- export type Code = 'not-connected' | 'expired' | 'auth' | 'no-plan' | 'rate-limited' | 'unavailable' | 'incomplete';
60
+ export type Scope = {
61
+ model?: string;
62
+ surface?: string;
63
+ };
64
+ export type Poll = {
65
+ at: number;
66
+ outcome: Code | 'ok';
67
+ retryAt?: number;
68
+ };
69
+ export type Freshness = 'fresh' | 'stale' | 'future' | 'unknown';
70
+ export type Code = 'not-connected' | 'expired' | 'auth' | 'no-plan' | 'rate-limited' | 'unavailable' | 'incomplete' | 'refresh-failed';
52
71
  export type Reading = {
53
72
  provider: Provider;
54
73
  windows: Window[];
55
- at: number;
74
+ at?: number;
75
+ limited?: boolean;
76
+ poll?: Poll;
56
77
  code?: Code;
57
78
  };
58
79
  export type Room = {
80
+ at?: number;
81
+ ageMs?: number;
82
+ freshness: Freshness;
83
+ poll?: Poll;
84
+ scope?: Scope;
85
+ limited?: boolean;
86
+ } & ({
59
87
  left: number;
60
88
  span: 'session' | 'week' | 'month' | 'tightest';
61
89
  resetsAt?: number;
62
- at: number;
63
90
  } | {
64
91
  left: 'unknown';
65
- at?: number;
66
- };
92
+ });
67
93
  export type ReadOptions = {
68
94
  nowMs?: number;
95
+ signal?: AbortSignal;
69
96
  };
70
97
  export type SourceAnswer = {
71
98
  raw?: unknown;
72
99
  code?: Code;
73
100
  retryAfterMs?: number;
101
+ limited?: boolean;
102
+ at?: number;
74
103
  };
75
104
  /** The app owns credential reads/refresh and sends its own requests through this seam. */
76
105
  export type ClaudeReader = (options: {
@@ -78,19 +107,36 @@ export type ClaudeReader = (options: {
78
107
  signal: AbortSignal;
79
108
  }) => Promise<SourceAnswer>;
80
109
  export type StoredReading = {
81
- at: number;
110
+ at?: number;
82
111
  windows: Window[];
112
+ limited?: boolean;
113
+ poll?: Poll;
83
114
  };
84
115
  export interface UsageStore {
85
116
  get(provider: Provider, account: string): StoredReading | undefined;
86
117
  put(provider: Provider, account: string, reading: StoredReading): void;
87
118
  }
119
+ export type BackoffState = {
120
+ untilMs: number;
121
+ at: number;
122
+ outcome: Code;
123
+ failures: number;
124
+ };
88
125
  export interface BackoffPolicy {
89
- get(provider: Provider, account: string): number | undefined;
90
- set(provider: Provider, account: string, untilMs: number): void;
91
- /** Retry-After duration to wait; default at least five minutes. */
92
- delayMs?(retryAfterMs: number | undefined): number;
126
+ get(provider: Provider, account: string): number | BackoffState | undefined;
127
+ set(provider: Provider, account: string, untilMs: number, state?: BackoffState): void;
128
+ /** Retry-After duration to wait; context carries the current outcome and its consecutive failures. */
129
+ delayMs?(retryAfterMs: number | undefined, context: {
130
+ outcome: Code;
131
+ failures: number;
132
+ }): number;
93
133
  }
134
+ export type PacingHook = (request: {
135
+ provider: Provider;
136
+ account: string;
137
+ origin: string;
138
+ signal: AbortSignal;
139
+ }) => Promise<void>;
94
140
  export type UsageOptions = {
95
141
  stateDir?: string;
96
142
  store?: UsageStore;
@@ -98,6 +144,7 @@ export type UsageOptions = {
98
144
  salt?: string;
99
145
  fetch?: typeof fetch;
100
146
  now?: () => number;
147
+ pace?: PacingHook;
101
148
  };
102
149
  export interface Usage {
103
150
  read(source: Source, options?: ReadOptions): Promise<Reading>;
package/dist/windows.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- import type { Kind, Provider, Window } from './types.ts';
1
+ import type { Kind, Provider, Window, Scope } from './types.ts';
2
2
  export declare const record: (v: unknown) => v is Record<string, unknown>;
3
3
  export declare function window(provider: Provider, kind: Kind, used: unknown, minutes?: number, resetsAt?: number, limited?: boolean, limit?: string): Window[];
4
- /** Accepts both the statusline envelope and the provider's usage payload. */
4
+ /** Scope is an allowlisted descriptor, never arbitrary provider data. */
5
+ export declare function quotaScope(raw: unknown): Scope | undefined;
6
+ /** Normalized rows override legacy aggregates of the same kind, even if incomplete. */
5
7
  export declare function claudeWindows(raw: unknown): Window[];
6
8
  /** Accepts the `usage` member; monthly length is deliberately absent. */
7
9
  export declare function goWindows(raw: unknown): Window[];
package/dist/windows.js CHANGED
@@ -1,25 +1,53 @@
1
1
  export const record = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
2
2
  const obj = (v) => record(v) ? v : {};
3
3
  export function window(provider, kind, used, minutes, resetsAt, limited = false, limit) {
4
- if (typeof used !== 'number' || !Number.isFinite(used))
4
+ if (used !== undefined && (typeof used !== 'number' || !Number.isFinite(used))) {
5
+ if (!limited)
6
+ return [];
7
+ used = undefined;
8
+ }
9
+ if (used === undefined && !limited)
5
10
  return [];
6
- return [{ provider, kind, usedPercent: Math.max(0, Math.min(100, used)),
11
+ return [{ provider, kind, ...(typeof used === 'number' ? { usedPercent: Math.max(0, Math.min(100, used)) } : {}),
7
12
  ...(minutes !== undefined && Number.isFinite(minutes) && minutes > 0 ? { minutes } : {}),
8
13
  ...(Number.isFinite(resetsAt) ? { resetsAt } : {}), ...(limited ? { limited: true } : {}), ...(limit ? { limit } : {}) }];
9
14
  }
10
- /** Accepts both the statusline envelope and the provider's usage payload. */
15
+ /** Scope is an allowlisted descriptor, never arbitrary provider data. */
16
+ export function quotaScope(raw) {
17
+ const value = obj(raw);
18
+ const scope = {};
19
+ for (const key of ['model', 'surface']) {
20
+ if (typeof value[key] === 'string' && value[key].length > 0 && value[key].length <= 80 && !/[\x00-\x1f]/.test(value[key]))
21
+ scope[key] = value[key];
22
+ }
23
+ return Object.keys(scope).length ? scope : undefined;
24
+ }
25
+ /** Normalized rows override legacy aggregates of the same kind, even if incomplete. */
11
26
  export function claudeWindows(raw) {
12
27
  const source = obj(obj(raw).rate_limits ?? raw);
13
- return ['five_hour', 'seven_day'].flatMap((id) => {
28
+ const normalized = (Array.isArray(source.limits) ? source.limits : []).filter(record)
29
+ .filter((row) => ['session', 'weekly_all', 'weekly_scoped'].includes(String(row.kind)));
30
+ const rows = normalized.flatMap((w) => {
31
+ const used = typeof w.percent === 'number' && Number.isFinite(w.percent) ? Math.max(0, Math.min(100, w.percent)) : undefined;
32
+ const reset = typeof w.resets_at === 'number' ? w.resets_at * 1000 : Date.parse(String(w.resets_at));
33
+ const scope = quotaScope(w.scope);
34
+ return [{ provider: 'claude', kind: w.kind === 'session' ? 'session' : 'weekly',
35
+ ...(used !== undefined ? { usedPercent: used } : {}), ...(scope ? { scope } : {}),
36
+ ...(Number.isFinite(reset) ? { resetsAt: reset } : {}), ...(w.limit_reached === true ? { limited: true } : {}) }];
37
+ });
38
+ const legacy = ['five_hour', 'seven_day'].flatMap((id) => {
39
+ if (normalized.some((w) => w.kind === (id === 'five_hour' ? 'session' : 'weekly_all')))
40
+ return [];
14
41
  const w = obj(source[id]);
15
42
  return window('claude', id === 'five_hour' ? 'session' : 'weekly', Number.isFinite(w.utilization) ? w.utilization : w.used_percentage, id === 'five_hour' ? 300 : 10080, typeof w.resets_at === 'number' ? w.resets_at * 1000 : Date.parse(String(w.resets_at)));
16
43
  });
44
+ return [...rows, ...legacy];
17
45
  }
18
46
  /** Accepts the `usage` member; monthly length is deliberately absent. */
19
47
  export function goWindows(raw) {
20
48
  return ['rolling', 'weekly', 'monthly'].flatMap((kind) => {
21
49
  const w = obj(obj(raw)[kind]);
22
- if (typeof w.percent !== 'number' || w.percent < 0 || !['ok', 'rate-limited'].includes(String(w.status)))
50
+ if (typeof w.percent === 'number' && w.percent < 0 || !['ok', 'rate-limited'].includes(String(w.status)))
23
51
  return [];
24
52
  return window('opencode', kind, w.percent, kind === 'monthly' ? undefined : kind === 'weekly' ? 10080 : 300, Date.parse(String(w.resetsAt)), w.status === 'rate-limited');
25
53
  });
@@ -43,13 +71,15 @@ export function codexWindows(result) {
43
71
  return [];
44
72
  const name = String(raw.limitName ?? raw.limitId ?? 'Codex').replace(/[^\x20-\x7e]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'Codex';
45
73
  const rows = ['primary', 'secondary'].flatMap((key) => {
74
+ if (!record(raw[key]))
75
+ return [];
46
76
  const w = obj(raw[key]);
47
77
  const m = typeof w.windowDurationMins === 'number' && w.windowDurationMins > 0 && Number.isFinite(w.windowDurationMins) ? w.windowDurationMins : undefined;
48
- return window('codex', m === 300 ? 'session' : m === 10080 ? 'weekly' : m === 43200 ? 'monthly' : 'custom', w.usedPercent, m, typeof w.resetsAt === 'number' ? w.resetsAt * 1000 : undefined, false, name.toLowerCase() === 'codex' ? undefined : name);
78
+ return window('codex', m === 300 ? 'session' : m === 10080 ? 'weekly' : m === 43200 ? 'monthly' : 'custom', w.usedPercent, m, typeof w.resetsAt === 'number' ? w.resetsAt * 1000 : undefined, raw.limitReached === true || w.limitReached === true, name.toLowerCase() === 'codex' ? undefined : name);
49
79
  }).sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0));
50
80
  return rows.length ? [rows] : [];
51
81
  });
52
- const chosen = groups.flat().sort((a, b) => b.usedPercent - a.usedPercent).slice(0, 8);
82
+ const chosen = groups.flat().sort((a, b) => Number(b.limited === true) - Number(a.limited === true) || (b.usedPercent ?? -1) - (a.usedPercent ?? -1)).slice(0, 8);
53
83
  return groups.map((rows, ordinal) => ({ rows: rows.filter((w) => chosen.includes(w)), ordinal }))
54
- .filter((g) => g.rows.length).sort((a, b) => Math.max(...b.rows.map((w) => w.usedPercent)) - Math.max(...a.rows.map((w) => w.usedPercent)) || a.ordinal - b.ordinal).flatMap((g) => g.rows);
84
+ .filter((g) => g.rows.length).sort((a, b) => Math.max(...b.rows.map((w) => w.usedPercent ?? -1)) - Math.max(...a.rows.map((w) => w.usedPercent ?? -1)) || a.ordinal - b.ordinal).flatMap((g) => g.rows);
55
85
  }
package/dist/words.json CHANGED
@@ -5,5 +5,6 @@
5
5
  "code.no-plan": "This {name} account has no plan with limits.",
6
6
  "code.rate-limited": "{name} asked us to wait. Showing the last reading.",
7
7
  "code.unavailable": "{name} didn't answer. Trying again shortly.",
8
- "code.incomplete": "{name} answered in a way we can't read yet."
8
+ "code.incomplete": "{name} answered in a way we can't read yet.",
9
+ "code.refresh-failed": "This sign-in could not be renewed. Check it in its own app."
9
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/usage",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Read subscription usage windows per provider and account.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",