@byokit/usage 0.1.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 +20 -0
- package/README.md +257 -11
- package/dist/backoff.d.ts +7 -0
- package/dist/backoff.js +26 -0
- package/dist/calls.d.ts +68 -0
- package/dist/calls.js +131 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +216 -57
- package/dist/ledger.d.ts +49 -0
- package/dist/ledger.js +87 -0
- package/dist/providers.d.ts +38 -9
- package/dist/providers.js +127 -14
- package/dist/quota.d.ts +8 -0
- package/dist/quota.js +91 -0
- package/dist/room.d.ts +3 -0
- package/dist/room.js +25 -0
- package/dist/store.d.ts +12 -9
- package/dist/store.js +41 -10
- package/dist/types.d.ts +114 -11
- package/dist/windows.d.ts +5 -2
- package/dist/windows.js +42 -12
- package/dist/words.json +2 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,118 +1,277 @@
|
|
|
1
1
|
import { accessSync, constants, statSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { isAbsolute, join } from 'node:path';
|
|
3
|
-
import { codexUsage, providerGet } from "./providers.js";
|
|
4
|
-
import { fingerprint, readJson, store } from "./store.js";
|
|
5
|
-
import { codexWindows, goWindows, record, zaiWindows } from "./windows.js";
|
|
4
|
+
import { claudeAuth, claudeUsage, codexUsage, customClaude, providerGet } from "./providers.js";
|
|
5
|
+
import { fingerprint, readJson, store, memoryUsageStore, safeWindows, safePoll } from "./store.js";
|
|
6
|
+
import { claudeWindows, codexWindows, goWindows, record, zaiWindows } from "./windows.js";
|
|
7
|
+
import { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
|
|
6
8
|
import { UsageError } from "./types.js";
|
|
7
9
|
export * from "./types.js";
|
|
10
|
+
export { callLedger, normalizeTokens, priceCall } from "./calls.js";
|
|
11
|
+
export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError } from "./ledger.js";
|
|
12
|
+
export { roomOf } from "./room.js";
|
|
13
|
+
export { fingerprint, store as fileUsageStore, memoryUsageStore } from "./store.js";
|
|
14
|
+
export { retryAfterMs, backoffDelayMs, memoryBackoffPolicy } from "./backoff.js";
|
|
8
15
|
export { claudeWindows, codexWindows, goWindows, zaiWindows } from "./windows.js";
|
|
16
|
+
export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
|
|
9
17
|
export { WORDS, words, usageWords } from "./words.js";
|
|
10
|
-
const
|
|
18
|
+
const providers = ['claude', 'codex', 'opencode', 'zai', 'copilot', 'grok', 'minimax', 'gemini', 'kimi'];
|
|
19
|
+
const validText = (v) => typeof v === 'string' && !v.includes('\0') && !/[\r\n]/.test(v) && v.length <= 16384;
|
|
11
20
|
function validate(source) {
|
|
12
|
-
if (!record(source))
|
|
21
|
+
if (!record(source) || !providers.includes(source.provider))
|
|
13
22
|
throw new UsageError();
|
|
14
|
-
if (
|
|
15
|
-
if (
|
|
23
|
+
if ('credentialsFile' in source) {
|
|
24
|
+
if (source.provider !== 'claude' || ![source.credentialsFile, ...[source.configFile, source.statuslineFile].filter((v) => v !== undefined)].every((v) => validText(v) && isAbsolute(v)))
|
|
25
|
+
throw new UsageError();
|
|
26
|
+
}
|
|
27
|
+
else if ('read' in source) {
|
|
28
|
+
if (source.provider !== 'claude' || typeof source.read !== 'function' || !validText(source.accountUuid) || !source.accountUuid || source.connected !== undefined && typeof source.connected !== 'function')
|
|
29
|
+
throw new UsageError();
|
|
30
|
+
}
|
|
31
|
+
else if ('bin' in source) {
|
|
32
|
+
if (source.provider !== 'codex' || !validText(source.bin) || !isAbsolute(source.bin) || !validText(source.home) || !isAbsolute(source.home))
|
|
16
33
|
throw new UsageError();
|
|
17
34
|
if (source.env !== undefined && (!record(source.env) || Object.entries(source.env).some(([k, v]) => !k || k.includes('=') || !validText(k) || !validText(v))))
|
|
18
35
|
throw new UsageError();
|
|
19
36
|
}
|
|
20
|
-
else
|
|
21
|
-
|
|
37
|
+
else {
|
|
38
|
+
if ('key' in source && !['opencode', 'zai'].includes(source.provider) || 'access' in source && ['opencode', 'zai'].includes(source.provider))
|
|
39
|
+
throw new UsageError();
|
|
40
|
+
const credential = 'access' in source ? source.access : 'key' in source ? source.key : undefined;
|
|
41
|
+
if (!validText(credential))
|
|
42
|
+
throw new UsageError();
|
|
43
|
+
if ('access' in source && source.provider === 'codex' && (!validText(source.accountId) || !source.accountId))
|
|
44
|
+
throw new UsageError();
|
|
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
|
+
}
|
|
56
|
+
const fields = source;
|
|
57
|
+
for (const field of ['accountId', 'accountUuid', 'project'])
|
|
58
|
+
if (fields[field] !== undefined && (!validText(fields[field]) || !fields[field]))
|
|
59
|
+
throw new UsageError();
|
|
60
|
+
}
|
|
61
|
+
/** Claims provide a cache identity only; this does not authenticate or trust a token. */
|
|
62
|
+
function subject(access) {
|
|
63
|
+
try {
|
|
64
|
+
const encoded = access.split('.')[1];
|
|
65
|
+
if (!encoded)
|
|
66
|
+
return undefined;
|
|
67
|
+
const claims = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
68
|
+
return record(claims) && validText(claims.sub) && claims.sub ? claims.sub : undefined;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
22
73
|
}
|
|
23
74
|
/** One reader owns per-account backoff and concurrent-read deduplication. */
|
|
24
75
|
export function usage(options) {
|
|
25
|
-
if (!validText(options.stateDir) || !isAbsolute(options.stateDir))
|
|
76
|
+
if (options.stateDir !== undefined && (!validText(options.stateDir) || !isAbsolute(options.stateDir)))
|
|
26
77
|
throw new UsageError();
|
|
27
|
-
const disk = store(options.stateDir);
|
|
78
|
+
const disk = options.store ?? (options.stateDir ? store(options.stateDir) : memoryUsageStore());
|
|
79
|
+
const transient = memoryUsageStore();
|
|
28
80
|
const fp = fingerprint(options.salt ?? 'byokit/usage/account');
|
|
29
|
-
const
|
|
81
|
+
const backoffs = new Map();
|
|
30
82
|
const inFlight = new Map();
|
|
31
83
|
const attempts = new Map();
|
|
84
|
+
const failures = new Map();
|
|
32
85
|
const now = (opts) => opts?.nowMs ?? (options.now ?? Date.now)();
|
|
33
86
|
function connected(source) {
|
|
34
87
|
validate(source);
|
|
35
|
-
if (
|
|
36
|
-
return source
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
88
|
+
if ('credentialsFile' in source)
|
|
89
|
+
return claudeAuth(source) !== undefined;
|
|
90
|
+
if ('read' in source) {
|
|
91
|
+
try {
|
|
92
|
+
return source.connected?.() ?? true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
40
97
|
}
|
|
41
|
-
|
|
42
|
-
|
|
98
|
+
if ('bin' in source) {
|
|
99
|
+
try {
|
|
100
|
+
accessSync(source.bin, constants.X_OK);
|
|
101
|
+
return statSync(source.bin).isFile();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
43
106
|
}
|
|
107
|
+
return ('access' in source ? source.access : source.key).trim() !== '';
|
|
44
108
|
}
|
|
45
109
|
function account(source) {
|
|
46
110
|
validate(source);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
111
|
+
let id;
|
|
112
|
+
if ('credentialsFile' in source)
|
|
113
|
+
id = claudeAuth(source)?.account;
|
|
114
|
+
else if ('read' in source)
|
|
115
|
+
id = source.accountUuid;
|
|
116
|
+
else if ('bin' in source) {
|
|
117
|
+
const raw = readJson(join(source.home, 'auth.json'), 64 * 1024);
|
|
118
|
+
const tokens = record(raw) && record(raw.tokens) ? raw.tokens : undefined;
|
|
119
|
+
id = typeof tokens?.account_id === 'string' ? tokens.account_id : `codex-home\0${source.home}`;
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
id = source.accountId ?? (source.provider === 'claude' ? source.accountUuid : undefined) ?? ('access' in source ? subject(source.access) : undefined);
|
|
123
|
+
return id && id.length <= 16384 ? fp(source.provider, id) : undefined;
|
|
124
|
+
}
|
|
125
|
+
function identity(source) {
|
|
126
|
+
const stable = account(source);
|
|
127
|
+
if (stable)
|
|
128
|
+
return { key: stable, stable: true };
|
|
129
|
+
// Opaque credentials without an identity can be read, but never reach an app/disk store.
|
|
130
|
+
const credential = 'access' in source ? source.access : 'key' in source ? source.key : '';
|
|
131
|
+
return { key: createHash('sha256').update(`${source.provider}\0${credential}`).digest('hex'), stable: false };
|
|
132
|
+
}
|
|
133
|
+
function windows(source, raw, clock) {
|
|
134
|
+
switch (source.provider) {
|
|
135
|
+
case 'claude': return claudeWindows(raw);
|
|
136
|
+
case 'codex': return 'bin' in source ? codexWindows(raw) : codexTokenWindows(raw, clock);
|
|
137
|
+
case 'opencode': return goWindows(record(raw) ? raw.usage : undefined);
|
|
138
|
+
case 'zai': return zaiWindows(record(raw) && record(raw.data) ? raw.data.limits : undefined);
|
|
139
|
+
case 'copilot': return copilotWindows(raw);
|
|
140
|
+
case 'grok': return grokWindows(raw);
|
|
141
|
+
case 'minimax': return minimaxWindows(raw);
|
|
142
|
+
case 'gemini': return geminiWindows(raw);
|
|
143
|
+
case 'kimi': return kimiWindows(raw, clock);
|
|
144
|
+
}
|
|
53
145
|
}
|
|
54
|
-
const windows = (source, raw) => source.provider === 'codex' ? codexWindows(raw) : source.provider === 'opencode' ? goWindows(raw) : zaiWindows(raw);
|
|
55
146
|
function lastKnown(source, opts) {
|
|
56
147
|
if (!connected(source))
|
|
57
148
|
return undefined;
|
|
58
|
-
const
|
|
59
|
-
if (!key)
|
|
60
|
-
return undefined;
|
|
61
|
-
const stored = disk.get(source.provider, key);
|
|
149
|
+
const id = identity(source);
|
|
62
150
|
const clock = now(opts);
|
|
63
|
-
|
|
151
|
+
try {
|
|
152
|
+
const stored = (id.stable ? disk : transient).get(source.provider, id.key);
|
|
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))
|
|
156
|
+
return undefined;
|
|
157
|
+
const rows = safeWindows(source.provider, stored.windows);
|
|
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;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
64
163
|
return undefined;
|
|
65
|
-
|
|
66
|
-
return rows.length ? { provider: source.provider, windows: rows, at: stored.at } : undefined;
|
|
164
|
+
}
|
|
67
165
|
}
|
|
68
166
|
async function read(source, opts) {
|
|
69
167
|
validate(source);
|
|
70
168
|
const clock = now(opts);
|
|
71
|
-
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' } });
|
|
72
170
|
if (!connected(source))
|
|
73
171
|
return empty('not-connected');
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
return empty('not-connected');
|
|
77
|
-
const identity = `${source.provider}\0${key}`;
|
|
172
|
+
const id = identity(source);
|
|
173
|
+
const key = `${source.provider}\0${id.key}`;
|
|
78
174
|
const previous = lastKnown(source, { nowMs: clock });
|
|
79
|
-
if (previous && clock - previous.at < 60_000)
|
|
175
|
+
if (previous && !previous.code && previous.at !== undefined && clock >= previous.at && clock - previous.at < 60_000)
|
|
80
176
|
return previous;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
177
|
+
let until = backoffs.get(key) ?? 0;
|
|
178
|
+
try {
|
|
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
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch { /* keep internal backoff */ }
|
|
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
|
+
}
|
|
195
|
+
const pending = inFlight.get(key);
|
|
84
196
|
if (pending)
|
|
85
197
|
return pending;
|
|
86
|
-
const attempt = attempts.get(
|
|
198
|
+
const attempt = attempts.get(key) ?? previous?.poll;
|
|
87
199
|
if (attempt && clock >= attempt.at && clock - attempt.at < 60_000)
|
|
88
|
-
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);
|
|
89
201
|
const task = (async () => {
|
|
90
202
|
let answer;
|
|
203
|
+
const pacing = { hook: options.pace, provider: source.provider, account: id.key, signal: opts?.signal };
|
|
91
204
|
try {
|
|
92
|
-
answer =
|
|
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);
|
|
93
207
|
}
|
|
94
208
|
catch {
|
|
95
209
|
answer = { code: 'unavailable' };
|
|
96
210
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!answer.code && !rows.length)
|
|
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)
|
|
101
214
|
answer = { code: 'incomplete' };
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 */ }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
for (const outcome of ['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'])
|
|
241
|
+
failures.delete(`${key}\0${outcome}`);
|
|
242
|
+
backoffs.delete(key);
|
|
243
|
+
try {
|
|
244
|
+
if (id.stable)
|
|
245
|
+
options.backoff?.set(source.provider, id.key, 0);
|
|
246
|
+
}
|
|
247
|
+
catch { /* expired host policy remains bounded */ }
|
|
248
|
+
}
|
|
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 };
|
|
255
|
+
try {
|
|
256
|
+
(id.stable ? disk : transient).put(source.provider, id.key, { at: reading.at, windows: rows, ...(limited ? { limited: true } : {}), poll });
|
|
257
|
+
}
|
|
258
|
+
catch { /* reads survive a store failure */ }
|
|
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 */ }
|
|
107
266
|
}
|
|
108
|
-
return
|
|
267
|
+
return { ...(previous ?? empty(answer.code ?? 'unavailable')), code: answer.code, poll };
|
|
109
268
|
})();
|
|
110
|
-
inFlight.set(
|
|
269
|
+
inFlight.set(key, task);
|
|
111
270
|
try {
|
|
112
271
|
return await task;
|
|
113
272
|
}
|
|
114
273
|
finally {
|
|
115
|
-
inFlight.delete(
|
|
274
|
+
inFlight.delete(key);
|
|
116
275
|
}
|
|
117
276
|
}
|
|
118
277
|
return { read, lastKnown, connected, account };
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CallRecord } from './calls.ts';
|
|
2
|
+
/** Counts measured tokens for a host's member; no credential, account or network access. */
|
|
3
|
+
export interface TokenEntry {
|
|
4
|
+
tokens: number;
|
|
5
|
+
time: number;
|
|
6
|
+
call?: CallRecord;
|
|
7
|
+
}
|
|
8
|
+
export interface TokenLedgerStore {
|
|
9
|
+
record(member: string, entry: TokenEntry): void;
|
|
10
|
+
/** Entries in [from, to); the ledger checks bounds again before counting. */
|
|
11
|
+
query(member: string, from: number, to: number): readonly TokenEntry[];
|
|
12
|
+
}
|
|
13
|
+
export interface TokenLedgerOptions {
|
|
14
|
+
store?: TokenLedgerStore;
|
|
15
|
+
/** Seven-day token allowance. Missing means uncapped; zero means no allowance. */
|
|
16
|
+
cap?: number | ((member: string) => number | undefined);
|
|
17
|
+
}
|
|
18
|
+
export interface TokenQuery {
|
|
19
|
+
from: number;
|
|
20
|
+
to: number;
|
|
21
|
+
tokens: number;
|
|
22
|
+
/** Present when call records lack reported total tokens; tokens is the known subtotal. */
|
|
23
|
+
unknownCalls?: number;
|
|
24
|
+
days: {
|
|
25
|
+
date: string;
|
|
26
|
+
tokens: number;
|
|
27
|
+
unknownCalls?: number;
|
|
28
|
+
}[];
|
|
29
|
+
week: {
|
|
30
|
+
from: number;
|
|
31
|
+
to: number;
|
|
32
|
+
tokens: number;
|
|
33
|
+
cap?: number;
|
|
34
|
+
remaining?: number;
|
|
35
|
+
unknownCalls?: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface TokenLedger {
|
|
39
|
+
record(member: string, tokens: number, time: number): void;
|
|
40
|
+
query(member: string, from: number, to: number): TokenQuery;
|
|
41
|
+
}
|
|
42
|
+
export declare class TokenLedgerError extends Error {
|
|
43
|
+
readonly code: 'invalid' | 'store';
|
|
44
|
+
name: string;
|
|
45
|
+
constructor(code: 'invalid' | 'store');
|
|
46
|
+
}
|
|
47
|
+
export declare function memoryTokenLedgerStore(): TokenLedgerStore;
|
|
48
|
+
/** Local calendar days, including DST changes; all API timestamps are epoch milliseconds. */
|
|
49
|
+
export declare function tokenLedger(options?: TokenLedgerOptions): TokenLedger;
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export class TokenLedgerError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
name = 'TokenLedgerError';
|
|
4
|
+
constructor(code) { super(code === 'invalid' ? 'The app supplied an invalid token ledger value.' : 'The token ledger store did not answer.'); this.code = code; }
|
|
5
|
+
}
|
|
6
|
+
const validMember = (member) => typeof member === 'string' && !!member && member.length <= 1024 && !member.includes('\0');
|
|
7
|
+
const validTime = (time) => typeof time === 'number' && Number.isFinite(time) && !Number.isNaN(new Date(time).getTime());
|
|
8
|
+
const validCount = (tokens) => Number.isSafeInteger(tokens) && tokens >= 0;
|
|
9
|
+
const dateOf = (time) => {
|
|
10
|
+
const date = new Date(time);
|
|
11
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
12
|
+
};
|
|
13
|
+
export function memoryTokenLedgerStore() {
|
|
14
|
+
const entries = new Map();
|
|
15
|
+
return {
|
|
16
|
+
record: (member, entry) => { const rows = entries.get(member) ?? []; rows.push(copyEntry(entry)); entries.set(member, rows); },
|
|
17
|
+
query: (member, from, to) => (entries.get(member) ?? []).filter((entry) => entry.time >= from && entry.time < to).map(copyEntry),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Local calendar days, including DST changes; all API timestamps are epoch milliseconds. */
|
|
21
|
+
export function tokenLedger(options = {}) {
|
|
22
|
+
const store = options.store ?? memoryTokenLedgerStore();
|
|
23
|
+
if (options.cap !== undefined && typeof options.cap !== 'function' && !validCount(options.cap))
|
|
24
|
+
throw new TokenLedgerError('invalid');
|
|
25
|
+
return {
|
|
26
|
+
record(member, tokens, time) {
|
|
27
|
+
if (!validMember(member) || !validCount(tokens) || !validTime(time))
|
|
28
|
+
throw new TokenLedgerError('invalid');
|
|
29
|
+
try {
|
|
30
|
+
store.record(member, { tokens, time });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new TokenLedgerError('store');
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
query(member, from, to) {
|
|
37
|
+
if (!validMember(member) || !validTime(from) || !validTime(to) || to < from)
|
|
38
|
+
throw new TokenLedgerError('invalid');
|
|
39
|
+
const start = new Date(to);
|
|
40
|
+
start.setDate(start.getDate() - 7);
|
|
41
|
+
const weekFrom = start.getTime();
|
|
42
|
+
if (!validTime(weekFrom))
|
|
43
|
+
throw new TokenLedgerError('invalid');
|
|
44
|
+
let entries;
|
|
45
|
+
let cap;
|
|
46
|
+
try {
|
|
47
|
+
entries = store.query(member, Math.min(from, weekFrom), to);
|
|
48
|
+
cap = typeof options.cap === 'function' ? options.cap(member) : options.cap;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new TokenLedgerError('store');
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(entries) || cap !== undefined && !validCount(cap))
|
|
54
|
+
throw new TokenLedgerError('invalid');
|
|
55
|
+
let tokens = 0;
|
|
56
|
+
let weekTokens = 0;
|
|
57
|
+
let unknownCalls = 0;
|
|
58
|
+
let weekUnknown = 0;
|
|
59
|
+
const days = new Map();
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry || !validCount(entry.tokens) || !validTime(entry.time) || entry.time >= to)
|
|
62
|
+
continue;
|
|
63
|
+
const unknown = entry.call !== undefined && entry.call.tokens.total === undefined ? 1 : 0;
|
|
64
|
+
if (entry.time >= weekFrom) {
|
|
65
|
+
weekTokens += entry.tokens;
|
|
66
|
+
weekUnknown += unknown;
|
|
67
|
+
}
|
|
68
|
+
if (entry.time < from)
|
|
69
|
+
continue;
|
|
70
|
+
tokens += entry.tokens;
|
|
71
|
+
unknownCalls += unknown;
|
|
72
|
+
const date = dateOf(entry.time);
|
|
73
|
+
const previous = days.get(date) ?? { tokens: 0, unknownCalls: 0 };
|
|
74
|
+
days.set(date, { tokens: previous.tokens + entry.tokens, unknownCalls: previous.unknownCalls + unknown });
|
|
75
|
+
}
|
|
76
|
+
if (!Number.isSafeInteger(tokens) || !Number.isSafeInteger(weekTokens))
|
|
77
|
+
throw new TokenLedgerError('invalid');
|
|
78
|
+
return { from, to, tokens, ...(unknownCalls ? { unknownCalls } : {}), days: [...days].sort(([a], [b]) => a.localeCompare(b)).map(([date, counts]) => ({ date, tokens: counts.tokens, ...(counts.unknownCalls ? { unknownCalls: counts.unknownCalls } : {}) })),
|
|
79
|
+
week: { from: weekFrom, to, tokens: weekTokens, ...(weekUnknown ? { unknownCalls: weekUnknown } : {}), ...(cap === undefined ? {} : { cap, ...(weekUnknown ? {} : { remaining: Math.max(0, cap - weekTokens) }) }) } };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function copyEntry(entry) {
|
|
84
|
+
const call = entry.call;
|
|
85
|
+
return { tokens: entry.tokens, time: entry.time, ...(call ? { call: { ...call, tokens: { ...call.tokens },
|
|
86
|
+
...(call.cost ? { cost: { ...call.cost } } : {}), ...(call.limits ? { limits: call.limits.map((window) => ({ ...window })) } : {}) } } : {}) };
|
|
87
|
+
}
|
package/dist/providers.d.ts
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
export declare function providerGet(source: Extract<Source, {
|
|
1
|
+
import type { Source, SourceAnswer, PacingHook } from './types.ts';
|
|
2
|
+
export type Answer = SourceAnswer;
|
|
3
|
+
type TokenSource = Extract<Source, {
|
|
4
|
+
access: string;
|
|
5
|
+
} | {
|
|
8
6
|
key: string;
|
|
9
|
-
}
|
|
7
|
+
}>;
|
|
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>;
|
|
14
|
+
/** A host hook gets the same deadline and failure envelope as built-in sources. */
|
|
15
|
+
export declare function customClaude(source: Extract<Source, {
|
|
16
|
+
read: unknown;
|
|
17
|
+
}>, nowMs: number, pacing?: {
|
|
18
|
+
hook?: PacingHook;
|
|
19
|
+
account: string;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}): Promise<Answer>;
|
|
10
22
|
export declare function codexUsage(source: Extract<Source, {
|
|
11
|
-
|
|
23
|
+
bin: string;
|
|
12
24
|
}>): Promise<Answer>;
|
|
25
|
+
/** Re-read the tool's credentials so its own renewal is picked up; never renew or write them. */
|
|
26
|
+
export declare function claudeAuth(source: Extract<Source, {
|
|
27
|
+
credentialsFile: string;
|
|
28
|
+
}>): {
|
|
29
|
+
token: string;
|
|
30
|
+
expiresAt?: number;
|
|
31
|
+
account: string;
|
|
32
|
+
} | undefined;
|
|
33
|
+
export declare function claudeUsage(source: Extract<Source, {
|
|
34
|
+
credentialsFile: string;
|
|
35
|
+
}>, fetcher: typeof fetch, nowMs: number, pacing?: {
|
|
36
|
+
hook?: PacingHook;
|
|
37
|
+
provider: Source['provider'];
|
|
38
|
+
account: string;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}): Promise<Answer>;
|
|
41
|
+
export {};
|