@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 +10 -0
- package/README.md +93 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +90 -35
- package/dist/providers.d.ts +19 -9
- package/dist/providers.js +47 -12
- package/dist/quota.d.ts +2 -1
- package/dist/quota.js +8 -4
- package/dist/room.d.ts +1 -1
- package/dist/room.js +20 -6
- package/dist/store.d.ts +3 -1
- package/dist/store.js +17 -10
- package/dist/types.d.ts +51 -11
- package/dist/windows.d.ts +4 -2
- package/dist/windows.js +38 -8
- package/dist/words.json +2 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
39
|
-
`
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
81
|
-
set(provider, fingerprint, untilMs) { appBackoff.set(provider
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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,
|
|
245
|
+
options.backoff?.set(source.provider, id.key, 0);
|
|
198
246
|
}
|
|
199
|
-
catch { /*
|
|
247
|
+
catch { /* expired host policy remains bounded */ }
|
|
200
248
|
}
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
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
|
|
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
|
|
267
|
+
return { ...(previous ?? empty(answer.code ?? 'unavailable')), code: answer.code, poll };
|
|
213
268
|
})();
|
|
214
269
|
inFlight.set(key, task);
|
|
215
270
|
try {
|
package/dist/providers.d.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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 &&
|
|
180
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
1
|
+
/** Figures retain their observation age; poll failure does not imply exhaustion. */
|
|
2
2
|
export function roomOf(reading, nowMs) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
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'
|
|
17
|
+
return { ...meta, left: 'unknown' };
|
|
8
18
|
const span = tight.kind === 'weekly' ? 'week' : tight.kind === 'monthly' ? 'month' : tight.kind === 'session' ? 'session' : 'tightest';
|
|
9
|
-
|
|
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
|
|
47
|
+
usedPercent?: number;
|
|
48
|
+
scope?: Scope;
|
|
47
49
|
minutes?: number;
|
|
48
50
|
resetsAt?: number;
|
|
49
51
|
limited?: boolean;
|
|
50
52
|
};
|
|
51
|
-
export type
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|
|
92
|
-
delayMs?(retryAfterMs: number | undefined
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
}
|