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