@byokit/usage 0.2.0 → 0.3.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,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 (2026-10-01)
6
+
7
+
8
+
9
+ - FIX: Subscription hard-limit flags now override positive percentage room, including blocks without quota windows; cached reset times never clear them.
10
+ - FIX: Claude subscription normalized and scoped quota rows now take precedence over legacy aggregates; missing usage remains unknown.
11
+ - Expose observation age and poll outcomes separately; retain last-good figures through failed polls without changing account health.
12
+ - Add cancellable host origin pacing and account-scoped retry policies for rate limits, refresh failures and transient outcomes.
13
+ - Document reset-time units when passing normalized subscription readings to account selection.
14
+
5
15
  ## 0.2.0 (2026-09-30)
6
16
 
7
17
 
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,28 +27,53 @@ 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
 
38
- `read(source, { nowMs? })` returns `{ provider, windows, at, code? }`; `at` and all
39
- `resetsAt` fields are **epoch milliseconds** in 0.2.0. This changes 0.1.0's seconds
40
- reset convention. Windows include kind, used percent, optional duration in minutes,
41
- reset time, limit label and limited flag. Parsers are exported for host integrations:
42
- `claudeWindows`, `codexWindows` (app-server), `codexTokenWindows`, `goWindows`,
43
- `zaiWindows`, `copilotWindows`, `grokWindows`, `minimaxWindows`, `geminiWindows`,
44
- `kimiWindows(raw, nowMs)`.
45
-
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.
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
+ Windows include kind, optional reported `usedPercent`, duration, reset, limit,
51
+ `limited` and `scope: { model?, surface? }`. Missing usage is unknown, never zero.
52
+ Claude `limits[]` session/weekly-all rows override corresponding legacy aggregates,
53
+ even if incomplete; dynamic weekly-scoped rows retain model and surface. Legacy
54
+ `five_hour`/`seven_day` are fallback for absent aggregate kinds. These are synthetic
55
+ contract fixtures; fresh live provider payload qualification has not been run.
56
+
57
+ Parsers are exported: `claudeWindows`, `codexWindows` (app-server),
58
+ `codexTokenWindows(raw, nowMs?)`, `codexHardLimit`, `goWindows`, `zaiWindows`,
59
+ `copilotWindows`, `grokWindows`, `minimaxWindows`, `geminiWindows`, `kimiWindows`.
60
+ Codex absolute reset takes precedence; relative seconds require a captured clock.
61
+ Hard flags do not replace the reported percentage or invent an absent window.
62
+ Hosts using exported Codex parsers must carry `codexHardLimit(raw)` into the reading
63
+ as `limited` to represent windowless blocks.
64
+
65
+ `roomOf(reading, nowMs)` returns `left`, observation `at?`, `ageMs?`, `freshness`
66
+ (`fresh`, `stale`, `future`, `unknown`), `poll?`, and the tightest row's `scope?`.
67
+ Numeric results include `span` and optional reset. Authoritative hard blocks give
68
+ zero eligibility room even without a percentage/window, with `limited: true`;
69
+ a predicted reset never clears a block. Otherwise undated/future/older-than-24h
70
+ readings and disconnected/expired/auth/no-plan readings give unknown room.
71
+ Incomplete windows cannot establish positive room; known exhaustion still stands.
72
+ Scope is conservative across all windows until hosts implement model demand for
73
+ every model/surface a run can use, including subagents and fallbacks.
74
+ Temporary poll failures may retain eligible last-good room and its age; a poll
75
+ failure itself never exhausts or moves an account. Auto's shared input contract is
76
+ `fixtures/conformance/usage-typescript.json` and runtime spec section 13.
52
77
 
53
78
  `connected(source)` and `account(source)` are synchronous. `account` returns a salted
54
79
  fingerprint of a non-secret host id, credential account UUID, token subject, or explicit
@@ -59,34 +84,45 @@ disk store is used. Tokens never become persisted fingerprint inputs.
59
84
 
60
85
  Good reads have no code. Bad sources throw `UsageError` (`code: 'bad-source'`); other
61
86
  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
87
+ returns a connected account's last-good figures and most recent poll outcome.
88
+ Ordinary dated readings expire after 24 hours; authoritative blocks stay until a
89
+ new successful read replaces them. Undated figures remain available for display
90
+ with unknown room. Reads have a
91
+ 60-second floor and concurrent deduplication per provider/account. Default 429
92
+ backoff honors Retry-After with a five-minute minimum. Unknown/transient and
93
+ refresh failures back off exponentially from one minute to one hour; these are
94
+ kit defaults, not universal provider policy. Failure counters are separate by
95
+ outcome and account, cleared by a successful quota poll. `UsageOptions.now` supplies
65
96
  the default clock; a per-call clock overrides it. An injected `fetch` wins; otherwise
66
97
  global fetch is resolved on each read.
67
98
 
68
99
  The host may supply public synchronous persistence and backoff hooks:
69
100
 
70
101
  ```ts
71
- import { usage, type UsageStore, type BackoffPolicy } from '@byokit/usage';
72
- declare const appStore: UsageStore;
73
- declare const appBackoff: BackoffPolicy;
102
+ import { usage, type UsageStore, type BackoffState } from '@byokit/usage';
103
+ // Replace these maps with your app's durable store to retain state across restart.
104
+ const appStore: UsageStore = {
105
+ get(provider, fingerprint) { return saved.get(`${provider}/${fingerprint}`); },
106
+ put(provider, fingerprint, reading) { saved.set(`${provider}/${fingerprint}`, reading); },
107
+ };
108
+ const saved = new Map<string, import('@byokit/usage').StoredReading>();
109
+ const appBackoff = new Map<string, BackoffState | number>();
74
110
  const reader = usage({
75
111
  store: {
76
112
  get(provider, fingerprint) { return appStore.get(provider, fingerprint); },
77
113
  put(provider, fingerprint, reading) { appStore.put(provider, fingerprint, reading); },
78
114
  },
79
115
  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); },
116
+ get(provider, fingerprint) { return appBackoff.get(`${provider}/${fingerprint}`); },
117
+ set(provider, fingerprint, untilMs, state) { appBackoff.set(`${provider}/${fingerprint}`, state ?? untilMs); },
118
+ delayMs(retryAfterMs, { outcome, failures }) { return Math.max(300_000, retryAfterMs ?? 0); },
83
119
  },
84
120
  });
85
121
  ```
86
122
 
87
- `UsageStore` holds only `{ at, windows }`; only whitelisted normalized fields cross
123
+ `UsageStore` holds `{ at?, windows, limited?, poll? }`; only whitelisted normalized fields cross
88
124
  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
125
+ Internal backoff remains effective if a host backoff hook fails. The 60-second
90
126
  minimum retry interval applies even if a policy selects a shorter delay. By default,
91
127
  `stateDir` selects an atomic disk store (0700 directory, 0600 file, 256 KB cap), or
92
128
  without `stateDir` an in-memory store is used. `memoryUsageStore()` is exported.
@@ -126,7 +162,7 @@ they are never loaded automatically.
126
162
 
127
163
  `fileUsageStore(absoluteStateDir)` exposes the same bounded atomic disk store as
128
164
  `stateDir`; invalid directory paths throw `UsageError`. Its keys must be 64-character
129
- hex fingerprints, and it persists only `{ at, windows }` with normalized fields.
165
+ hex fingerprints, and it persists normalized observations, hard blocks and poll metadata.
130
166
  `fingerprint(salt)` returns `(provider, nonSecretIdentity) => string`; use the same
131
167
  salt as the reader and never supply a token as the identity.
132
168
  `memoryBackoffPolicy()` supplies shared per-provider/account rests, keeps the later
@@ -136,6 +172,23 @@ past dates clamp to zero), and `backoffDelayMs(retryAfterMs)` to apply the defau
136
172
  five-minute minimum. These helpers also support an app-owned Claude `read` hook
137
173
  without duplicating fingerprint, persistence or retry logic.
138
174
 
175
+ `BackoffPolicy.set(provider, fingerprint, untilMs, state?)` receives a normalized
176
+ `state` with `{ untilMs, at, outcome, failures }` on a retryable failure, and zero
177
+ eligibility time without state after success. Persist and return that state from
178
+ `get` to preserve outcome and retry eligibility across restart. Legacy numeric
179
+ `get` values still work; their reason is unavailable when no stored poll supplies it.
180
+ `delayMs(retryAfterMs, { outcome, failures })` chooses host policy; a valid server
181
+ Retry-After is always a lower bound, alongside the existing one-minute floor.
182
+
183
+ `UsageOptions.pace({ provider, account, origin, signal })` is an optional async host
184
+ hook before each HTTP usage request (including provider discovery/fallback reads).
185
+ The host can share an origin-keyed queue across reader instances. Distinct origins
186
+ are independent; the kit adds no global queue or fixed origin spacing. Account is
187
+ a fingerprint, never a token. Host Claude readers opt in with `source.origin`.
188
+ The hook shares the request deadline and can be cancelled by `ReadOptions.signal`;
189
+ no request is sent when pacing fails or is cancelled. In-flight duplicate callers
190
+ share the first caller's operation/signal. There is no polling timer or inference ping.
191
+
139
192
  Isolation: there is no home/path discovery or environment read. Only absolute files
140
193
  and the Codex binary explicitly supplied by the app are opened/run. Credential files
141
194
  are bounded regular files, with final symlinks rejected. The spawn uses argv and an
@@ -143,7 +196,7 @@ environment built from the host's explicit `env` plus `CODEX_HOME=home`; pass PA
143
196
  and HOME explicitly when needed. No tokens in logs, errors, readings or public hooks;
144
197
  no credential write-back, telemetry, automatic refresh or reset-credit spend.
145
198
 
146
- Built-in requests send `User-Agent: byokit/usage/0.2.0`, never another app's identity.
199
+ Built-in requests send `User-Agent: byokit/usage/0.3.0`, never another app's identity.
147
200
  A refusal returns a code; with no last-good quota, room is unknown. Fixed endpoints
148
201
  are Anthropic `api/oauth/usage`, ChatGPT `backend-api/wham/usage`, GitHub
149
202
  `copilot_internal/user`, Grok `v1/billing` (weekly credits then monthly when needed),
@@ -210,3 +263,9 @@ calls with unknown total counts, member/day/week results expose `unknownCalls`,
210
263
  `tokens` is the known subtotal, and week `remaining` is omitted. Cap and price
211
264
  policy remain the host's. All times, durations and quota reset timestamps are
212
265
  milliseconds. There is no transport, credential discovery or automatic rotation.
266
+
267
+ When passing normalized windows to `@byokit/accounts`' structural helper, use
268
+ `roomOf(reading.windows, reading.at, 'milliseconds')`. Its two-argument form is for
269
+ legacy reset seconds; normalized usage windows in 0.2.0+ already use milliseconds.
270
+ Alternatively, this package's `roomOf(reading, nowMs)` returns a structural `Room`
271
+ that the accounts chooser accepts directly. Preserve the original measurement time.
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { roomOf } from './room.ts';
6
6
  export { fingerprint, store as fileUsageStore, memoryUsageStore } from './store.ts';
7
7
  export { retryAfterMs, backoffDelayMs, memoryBackoffPolicy } from './backoff.ts';
8
8
  export { claudeWindows, codexWindows, goWindows, zaiWindows, type CodexRateLimitResult } from './windows.ts';
9
- export { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from './quota.ts';
9
+ export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from './quota.ts';
10
10
  export { WORDS, words, usageWords, type WordKey } from './words.ts';
11
11
  /** One reader owns per-account backoff and concurrent-read deduplication. */
12
12
  export declare function usage(options: UsageOptions): Usage;
package/dist/index.js CHANGED
@@ -1,11 +1,10 @@
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";
10
9
  export * from "./types.js";
11
10
  export { callLedger, normalizeTokens, priceCall } from "./calls.js";
@@ -14,7 +13,7 @@ export { roomOf } from "./room.js";
14
13
  export { fingerprint, store as fileUsageStore, memoryUsageStore } from "./store.js";
15
14
  export { retryAfterMs, backoffDelayMs, memoryBackoffPolicy } from "./backoff.js";
16
15
  export { claudeWindows, codexWindows, goWindows, zaiWindows } from "./windows.js";
17
- export { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
16
+ export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
18
17
  export { WORDS, words, usageWords } from "./words.js";
19
18
  const providers = ['claude', 'codex', 'opencode', 'zai', 'copilot', 'grok', 'minimax', 'gemini', 'kimi'];
20
19
  const validText = (v) => typeof v === 'string' && !v.includes('\0') && !/[\r\n]/.test(v) && v.length <= 16384;
@@ -44,6 +43,16 @@ function validate(source) {
44
43
  if ('access' in source && source.provider === 'codex' && (!validText(source.accountId) || !source.accountId))
45
44
  throw new UsageError();
46
45
  }
46
+ if ('origin' in source && source.origin !== undefined) {
47
+ try {
48
+ const url = new URL(source.origin);
49
+ if (!['https:', 'http:'].includes(url.protocol) || url.origin !== source.origin)
50
+ throw new Error();
51
+ }
52
+ catch {
53
+ throw new UsageError();
54
+ }
55
+ }
47
56
  const fields = source;
48
57
  for (const field of ['accountId', 'accountUuid', 'project'])
49
58
  if (fields[field] !== undefined && (!validText(fields[field]) || !fields[field]))
@@ -72,6 +81,7 @@ export function usage(options) {
72
81
  const backoffs = new Map();
73
82
  const inFlight = new Map();
74
83
  const attempts = new Map();
84
+ const failures = new Map();
75
85
  const now = (opts) => opts?.nowMs ?? (options.now ?? Date.now)();
76
86
  function connected(source) {
77
87
  validate(source);
@@ -123,7 +133,7 @@ export function usage(options) {
123
133
  function windows(source, raw, clock) {
124
134
  switch (source.provider) {
125
135
  case 'claude': return claudeWindows(raw);
126
- case 'codex': return 'bin' in source ? codexWindows(raw) : codexTokenWindows(raw);
136
+ case 'codex': return 'bin' in source ? codexWindows(raw) : codexTokenWindows(raw, clock);
127
137
  case 'opencode': return goWindows(record(raw) ? raw.usage : undefined);
128
138
  case 'zai': return zaiWindows(record(raw) && record(raw.data) ? raw.data.limits : undefined);
129
139
  case 'copilot': return copilotWindows(raw);
@@ -140,10 +150,14 @@ export function usage(options) {
140
150
  const clock = now(opts);
141
151
  try {
142
152
  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)
153
+ if (!stored)
154
+ return undefined;
155
+ if (!stored.limited && !stored.windows.some((w) => w.limited) && stored.at !== undefined && (clock < stored.at || clock - stored.at > 86_400_000))
144
156
  return undefined;
145
157
  const rows = safeWindows(source.provider, stored.windows);
146
- return rows.length ? { provider: source.provider, windows: rows, at: stored.at } : undefined;
158
+ const poll = attempts.get(`${source.provider}\0${id.key}`) ?? safePoll(stored.poll);
159
+ return rows.length || stored.limited ? { provider: source.provider, windows: rows, ...(stored.at !== undefined ? { at: stored.at } : {}),
160
+ ...(stored.limited ? { limited: true } : {}), ...(poll ? { poll, ...(poll.outcome !== 'ok' ? { code: poll.outcome } : {}) } : {}) } : undefined;
147
161
  }
148
162
  catch {
149
163
  return undefined;
@@ -152,64 +166,105 @@ export function usage(options) {
152
166
  async function read(source, opts) {
153
167
  validate(source);
154
168
  const clock = now(opts);
155
- const empty = (code) => ({ provider: source.provider, windows: [], at: clock, code });
169
+ const empty = (code) => ({ provider: source.provider, windows: [], code, poll: { at: clock, outcome: code ?? 'unavailable' } });
156
170
  if (!connected(source))
157
171
  return empty('not-connected');
158
172
  const id = identity(source);
159
173
  const key = `${source.provider}\0${id.key}`;
160
174
  const previous = lastKnown(source, { nowMs: clock });
161
- if (previous && clock - previous.at < 60_000)
175
+ if (previous && !previous.code && previous.at !== undefined && clock >= previous.at && clock - previous.at < 60_000)
162
176
  return previous;
163
177
  let until = backoffs.get(key) ?? 0;
164
178
  try {
165
- if (id.stable)
166
- until = Math.max(until, options.backoff?.get(source.provider, id.key) ?? 0);
179
+ const saved = id.stable ? options.backoff?.get(source.provider, id.key) : undefined;
180
+ if (typeof saved === 'number' && Number.isFinite(saved))
181
+ until = Math.max(until, saved);
182
+ else if (saved && typeof saved !== 'number' && Number.isFinite(saved.untilMs) && Number.isFinite(saved.at) && ['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'].includes(saved.outcome)) {
183
+ until = Math.max(until, saved.untilMs);
184
+ if (!attempts.has(key))
185
+ attempts.set(key, { at: saved.at, outcome: saved.outcome, retryAt: saved.untilMs });
186
+ if (!failures.has(`${key}\0${saved.outcome}`) && Number.isSafeInteger(saved.failures) && saved.failures > 0)
187
+ failures.set(`${key}\0${saved.outcome}`, saved.failures);
188
+ }
167
189
  }
168
190
  catch { /* keep internal backoff */ }
169
- if (until > clock)
170
- return previous ? { ...previous, code: 'rate-limited' } : empty('rate-limited');
191
+ if (until > clock) {
192
+ const poll = attempts.get(key) ?? previous?.poll ?? { at: clock, outcome: 'unavailable', retryAt: until };
193
+ return { ...(previous ?? empty(poll.outcome === 'ok' ? 'unavailable' : poll.outcome)), code: poll.outcome === 'ok' ? undefined : poll.outcome, poll };
194
+ }
171
195
  const pending = inFlight.get(key);
172
196
  if (pending)
173
197
  return pending;
174
- const attempt = attempts.get(key);
198
+ const attempt = attempts.get(key) ?? previous?.poll;
175
199
  if (attempt && clock >= attempt.at && clock - attempt.at < 60_000)
176
- return previous ? { ...previous, code: attempt.code } : empty(attempt.code ?? 'unavailable');
200
+ return previous ? { ...previous, code: attempt.outcome === 'ok' ? undefined : attempt.outcome, poll: attempt } : empty(attempt.outcome === 'ok' ? 'unavailable' : attempt.outcome);
177
201
  const task = (async () => {
178
202
  let answer;
203
+ const pacing = { hook: options.pace, provider: source.provider, account: id.key, signal: opts?.signal };
179
204
  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);
205
+ answer = 'read' in source ? await customClaude(source, clock, pacing) : 'credentialsFile' in source ? await claudeUsage(source, options.fetch ?? globalThis.fetch, clock, pacing)
206
+ : 'bin' in source ? await codexUsage(source) : await providerGet(source, options.fetch ?? globalThis.fetch, clock, pacing);
182
207
  }
183
208
  catch {
184
209
  answer = { code: 'unavailable' };
185
210
  }
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;
211
+ const rows = safeWindows(source.provider, windows(source, answer.raw, clock));
212
+ const limited = !answer.code && (answer.limited === true || source.provider === 'codex' && codexHardLimit(answer.raw));
213
+ if (!answer.code && !rows.length && !limited)
214
+ answer = { code: 'incomplete' };
215
+ const poll = { at: clock, outcome: answer.code ?? 'ok' };
216
+ if (answer.code) {
217
+ const failureKey = `${key}\0${answer.code}`;
218
+ const count = (failures.get(failureKey) ?? 0) + 1;
219
+ failures.set(failureKey, count);
220
+ if (['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'].includes(answer.code)) {
221
+ const retry = Number.isFinite(answer.retryAfterMs) && answer.retryAfterMs >= 0 ? answer.retryAfterMs : undefined;
222
+ let delay = answer.code === 'rate-limited' ? 300_000 : Math.min(3_600_000, 60_000 * 2 ** Math.min(count - 1, 6));
223
+ try {
224
+ const selected = options.backoff?.delayMs?.(retry, { outcome: answer.code, failures: count });
225
+ if (selected !== undefined && Number.isFinite(selected) && selected >= 0)
226
+ delay = selected;
227
+ }
228
+ catch { /* internal policy stands */ }
229
+ delay = Math.max(60_000, delay, retry ?? 0);
230
+ poll.retryAt = clock + delay;
231
+ backoffs.set(key, poll.retryAt);
232
+ try {
233
+ if (id.stable)
234
+ options.backoff?.set(source.provider, id.key, poll.retryAt, { untilMs: poll.retryAt, at: clock, outcome: answer.code, failures: count });
235
+ }
236
+ catch { /* internal backoff stands */ }
192
237
  }
193
- catch { /* default */ }
194
- backoffs.set(key, clock + delay);
238
+ }
239
+ else {
240
+ for (const outcome of ['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'])
241
+ failures.delete(`${key}\0${outcome}`);
242
+ backoffs.delete(key);
195
243
  try {
196
244
  if (id.stable)
197
- options.backoff?.set(source.provider, id.key, clock + delay);
245
+ options.backoff?.set(source.provider, id.key, 0);
198
246
  }
199
- catch { /* internal backoff stands */ }
247
+ catch { /* expired host policy remains bounded */ }
200
248
  }
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) {
249
+ attempts.set(key, poll);
250
+ if ((rows.length || limited) && !answer.code) {
251
+ // Explicit host/snapshot time is authoritative, including unknown or future time.
252
+ const observed = 'at' in answer ? answer.at : clock;
253
+ const at = typeof observed === 'number' && Number.isFinite(observed) ? observed : undefined;
254
+ const reading = { provider: source.provider, windows: rows, ...(at !== undefined ? { at } : {}), ...(limited ? { limited: true } : {}), poll };
206
255
  try {
207
- (id.stable ? disk : transient).put(source.provider, id.key, { at: clock, windows: rows });
256
+ (id.stable ? disk : transient).put(source.provider, id.key, { at: reading.at, windows: rows, ...(limited ? { limited: true } : {}), poll });
208
257
  }
209
258
  catch { /* reads survive a store failure */ }
210
- return { provider: source.provider, windows: rows, at: clock };
259
+ return reading;
260
+ }
261
+ if (previous) {
262
+ try {
263
+ (id.stable ? disk : transient).put(source.provider, id.key, { at: previous.at, windows: previous.windows, ...(previous.limited ? { limited: true } : {}), poll });
264
+ }
265
+ catch { /* last-good remains in memory */ }
211
266
  }
212
- return previous ? { ...previous, code: answer.code } : empty(answer.code ?? 'unavailable');
267
+ return { ...(previous ?? empty(answer.code ?? 'unavailable')), code: answer.code, poll };
213
268
  })();
214
269
  inFlight.set(key, task);
215
270
  try {
@@ -1,19 +1,24 @@
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
+ export declare function providerGet(source: TokenSource, fetcher: typeof fetch, nowMs: number, pacing?: {
9
+ hook?: PacingHook;
10
+ provider: Source['provider'];
11
+ account: string;
12
+ signal?: AbortSignal;
13
+ }): Promise<Answer>;
13
14
  /** A host hook gets the same deadline and failure envelope as built-in sources. */
14
15
  export declare function customClaude(source: Extract<Source, {
15
16
  read: unknown;
16
- }>, nowMs: number): Promise<Answer>;
17
+ }>, nowMs: number, pacing?: {
18
+ hook?: PacingHook;
19
+ account: string;
20
+ signal?: AbortSignal;
21
+ }): Promise<Answer>;
17
22
  export declare function codexUsage(source: Extract<Source, {
18
23
  bin: string;
19
24
  }>): Promise<Answer>;
@@ -27,5 +32,10 @@ export declare function claudeAuth(source: Extract<Source, {
27
32
  } | undefined;
28
33
  export declare function claudeUsage(source: Extract<Source, {
29
34
  credentialsFile: string;
30
- }>, fetcher: typeof fetch, nowMs: number): Promise<Answer>;
35
+ }>, fetcher: typeof fetch, nowMs: number, pacing?: {
36
+ hook?: PacingHook;
37
+ provider: Source['provider'];
38
+ account: string;
39
+ signal?: AbortSignal;
40
+ }): Promise<Answer>;
31
41
  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
+ async function request(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) => request(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,6 +134,7 @@ 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) {
@@ -174,14 +205,18 @@ export function claudeAuth(source) {
174
205
  return undefined;
175
206
  return { token, account: identity, ...(typeof credentials.expiresAt === 'number' && Number.isFinite(credentials.expiresAt) ? { expiresAt: credentials.expiresAt } : {}) };
176
207
  }
177
- export async function claudeUsage(source, fetcher, nowMs) {
208
+ export async function claudeUsage(source, fetcher, nowMs, pacing) {
178
209
  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 };
210
+ if (snapshot && record(snapshot.value) && claudeWindows(snapshot.value).length) {
211
+ const rawTime = snapshot.value.fetched_at;
212
+ const at = typeof rawTime === 'number' ? rawTime : typeof rawTime === 'string' ? Date.parse(rawTime) : undefined;
213
+ if (at === undefined || !Number.isFinite(at) || nowMs < at || nowMs - at < 300_000)
214
+ return { raw: snapshot.value, at };
215
+ }
181
216
  const auth = claudeAuth(source);
182
217
  if (!auth)
183
218
  return { code: 'not-connected' };
184
219
  if (auth.expiresAt !== undefined && auth.expiresAt <= nowMs)
185
220
  return { code: 'expired' };
186
- return providerGet({ provider: 'claude', access: auth.token }, fetcher, nowMs);
221
+ return providerGet({ provider: 'claude', access: auth.token }, fetcher, nowMs, pacing);
187
222
  }
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
@@ -20,6 +20,7 @@ export type Source = {
20
20
  provider: 'claude';
21
21
  accountUuid: string;
22
22
  read: ClaudeReader;
23
+ origin?: string;
23
24
  connected?: () => boolean;
24
25
  }) | {
25
26
  provider: 'claude';
@@ -43,34 +44,55 @@ export type Window = {
43
44
  provider: Provider;
44
45
  kind: Kind;
45
46
  limit?: string;
46
- usedPercent: number;
47
+ usedPercent?: number;
48
+ scope?: Scope;
47
49
  minutes?: number;
48
50
  resetsAt?: number;
49
51
  limited?: boolean;
50
52
  };
51
- export type Code = 'not-connected' | 'expired' | 'auth' | 'no-plan' | 'rate-limited' | 'unavailable' | 'incomplete';
53
+ export type Scope = {
54
+ model?: string;
55
+ surface?: string;
56
+ };
57
+ export type Poll = {
58
+ at: number;
59
+ outcome: Code | 'ok';
60
+ retryAt?: number;
61
+ };
62
+ export type Freshness = 'fresh' | 'stale' | 'future' | 'unknown';
63
+ export type Code = 'not-connected' | 'expired' | 'auth' | 'no-plan' | 'rate-limited' | 'unavailable' | 'incomplete' | 'refresh-failed';
52
64
  export type Reading = {
53
65
  provider: Provider;
54
66
  windows: Window[];
55
- at: number;
67
+ at?: number;
68
+ limited?: boolean;
69
+ poll?: Poll;
56
70
  code?: Code;
57
71
  };
58
72
  export type Room = {
73
+ at?: number;
74
+ ageMs?: number;
75
+ freshness: Freshness;
76
+ poll?: Poll;
77
+ scope?: Scope;
78
+ limited?: boolean;
79
+ } & ({
59
80
  left: number;
60
81
  span: 'session' | 'week' | 'month' | 'tightest';
61
82
  resetsAt?: number;
62
- at: number;
63
83
  } | {
64
84
  left: 'unknown';
65
- at?: number;
66
- };
85
+ });
67
86
  export type ReadOptions = {
68
87
  nowMs?: number;
88
+ signal?: AbortSignal;
69
89
  };
70
90
  export type SourceAnswer = {
71
91
  raw?: unknown;
72
92
  code?: Code;
73
93
  retryAfterMs?: number;
94
+ limited?: boolean;
95
+ at?: number;
74
96
  };
75
97
  /** The app owns credential reads/refresh and sends its own requests through this seam. */
76
98
  export type ClaudeReader = (options: {
@@ -78,19 +100,36 @@ export type ClaudeReader = (options: {
78
100
  signal: AbortSignal;
79
101
  }) => Promise<SourceAnswer>;
80
102
  export type StoredReading = {
81
- at: number;
103
+ at?: number;
82
104
  windows: Window[];
105
+ limited?: boolean;
106
+ poll?: Poll;
83
107
  };
84
108
  export interface UsageStore {
85
109
  get(provider: Provider, account: string): StoredReading | undefined;
86
110
  put(provider: Provider, account: string, reading: StoredReading): void;
87
111
  }
112
+ export type BackoffState = {
113
+ untilMs: number;
114
+ at: number;
115
+ outcome: Code;
116
+ failures: number;
117
+ };
88
118
  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;
119
+ get(provider: Provider, account: string): number | BackoffState | undefined;
120
+ set(provider: Provider, account: string, untilMs: number, state?: BackoffState): void;
121
+ /** Retry-After duration to wait; context carries the current outcome and its consecutive failures. */
122
+ delayMs?(retryAfterMs: number | undefined, context: {
123
+ outcome: Code;
124
+ failures: number;
125
+ }): number;
93
126
  }
127
+ export type PacingHook = (request: {
128
+ provider: Provider;
129
+ account: string;
130
+ origin: string;
131
+ signal: AbortSignal;
132
+ }) => Promise<void>;
94
133
  export type UsageOptions = {
95
134
  stateDir?: string;
96
135
  store?: UsageStore;
@@ -98,6 +137,7 @@ export type UsageOptions = {
98
137
  salt?: string;
99
138
  fetch?: typeof fetch;
100
139
  now?: () => number;
140
+ pace?: PacingHook;
101
141
  };
102
142
  export interface Usage {
103
143
  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.3.0",
4
4
  "description": "Read subscription usage windows per provider and account.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",