@byokit/usage 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,69 +1,153 @@
1
+ import { backoffDelayMs } from "./backoff.js";
1
2
  import { accessSync, constants, statSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
2
4
  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";
5
+ import { claudeAuth, claudeUsage, codexUsage, customClaude, providerGet } from "./providers.js";
6
+ import { fingerprint, readJson, store, memoryUsageStore, safeWindows } from "./store.js";
7
+ import { claudeWindows, codexWindows, goWindows, record, zaiWindows } from "./windows.js";
8
+ import { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
6
9
  import { UsageError } from "./types.js";
7
10
  export * from "./types.js";
11
+ export { callLedger, normalizeTokens, priceCall } from "./calls.js";
12
+ export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError } from "./ledger.js";
13
+ export { roomOf } from "./room.js";
14
+ export { fingerprint, store as fileUsageStore, memoryUsageStore } from "./store.js";
15
+ export { retryAfterMs, backoffDelayMs, memoryBackoffPolicy } from "./backoff.js";
8
16
  export { claudeWindows, codexWindows, goWindows, zaiWindows } from "./windows.js";
17
+ export { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
9
18
  export { WORDS, words, usageWords } from "./words.js";
10
- const validText = (v) => typeof v === 'string' && !v.includes('\0');
19
+ const providers = ['claude', 'codex', 'opencode', 'zai', 'copilot', 'grok', 'minimax', 'gemini', 'kimi'];
20
+ const validText = (v) => typeof v === 'string' && !v.includes('\0') && !/[\r\n]/.test(v) && v.length <= 16384;
11
21
  function validate(source) {
12
- if (!record(source))
22
+ if (!record(source) || !providers.includes(source.provider))
13
23
  throw new UsageError();
14
- if (source.provider === 'codex') {
15
- if (!validText(source.bin) || !isAbsolute(source.bin) || !validText(source.home) || !isAbsolute(source.home))
24
+ if ('credentialsFile' in source) {
25
+ if (source.provider !== 'claude' || ![source.credentialsFile, ...[source.configFile, source.statuslineFile].filter((v) => v !== undefined)].every((v) => validText(v) && isAbsolute(v)))
26
+ throw new UsageError();
27
+ }
28
+ else if ('read' in source) {
29
+ if (source.provider !== 'claude' || typeof source.read !== 'function' || !validText(source.accountUuid) || !source.accountUuid || source.connected !== undefined && typeof source.connected !== 'function')
30
+ throw new UsageError();
31
+ }
32
+ else if ('bin' in source) {
33
+ if (source.provider !== 'codex' || !validText(source.bin) || !isAbsolute(source.bin) || !validText(source.home) || !isAbsolute(source.home))
16
34
  throw new UsageError();
17
35
  if (source.env !== undefined && (!record(source.env) || Object.entries(source.env).some(([k, v]) => !k || k.includes('=') || !validText(k) || !validText(v))))
18
36
  throw new UsageError();
19
37
  }
20
- else if (!['opencode', 'zai'].includes(source.provider) || !validText(source.key) || source.key.length > 16384)
21
- throw new UsageError();
38
+ else {
39
+ if ('key' in source && !['opencode', 'zai'].includes(source.provider) || 'access' in source && ['opencode', 'zai'].includes(source.provider))
40
+ throw new UsageError();
41
+ const credential = 'access' in source ? source.access : 'key' in source ? source.key : undefined;
42
+ if (!validText(credential))
43
+ throw new UsageError();
44
+ if ('access' in source && source.provider === 'codex' && (!validText(source.accountId) || !source.accountId))
45
+ throw new UsageError();
46
+ }
47
+ const fields = source;
48
+ for (const field of ['accountId', 'accountUuid', 'project'])
49
+ if (fields[field] !== undefined && (!validText(fields[field]) || !fields[field]))
50
+ throw new UsageError();
51
+ }
52
+ /** Claims provide a cache identity only; this does not authenticate or trust a token. */
53
+ function subject(access) {
54
+ try {
55
+ const encoded = access.split('.')[1];
56
+ if (!encoded)
57
+ return undefined;
58
+ const claims = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
59
+ return record(claims) && validText(claims.sub) && claims.sub ? claims.sub : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
22
64
  }
23
65
  /** One reader owns per-account backoff and concurrent-read deduplication. */
24
66
  export function usage(options) {
25
- if (!validText(options.stateDir) || !isAbsolute(options.stateDir))
67
+ if (options.stateDir !== undefined && (!validText(options.stateDir) || !isAbsolute(options.stateDir)))
26
68
  throw new UsageError();
27
- const disk = store(options.stateDir);
69
+ const disk = options.store ?? (options.stateDir ? store(options.stateDir) : memoryUsageStore());
70
+ const transient = memoryUsageStore();
28
71
  const fp = fingerprint(options.salt ?? 'byokit/usage/account');
29
- const backoff = new Map();
72
+ const backoffs = new Map();
30
73
  const inFlight = new Map();
31
74
  const attempts = new Map();
32
75
  const now = (opts) => opts?.nowMs ?? (options.now ?? Date.now)();
33
76
  function connected(source) {
34
77
  validate(source);
35
- if (source.provider !== 'codex')
36
- return source.key.trim() !== '';
37
- try {
38
- accessSync(source.bin, constants.X_OK);
39
- return statSync(source.bin).isFile();
78
+ if ('credentialsFile' in source)
79
+ return claudeAuth(source) !== undefined;
80
+ if ('read' in source) {
81
+ try {
82
+ return source.connected?.() ?? true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
40
87
  }
41
- catch {
42
- return false;
88
+ if ('bin' in source) {
89
+ try {
90
+ accessSync(source.bin, constants.X_OK);
91
+ return statSync(source.bin).isFile();
92
+ }
93
+ catch {
94
+ return false;
95
+ }
43
96
  }
97
+ return ('access' in source ? source.access : source.key).trim() !== '';
44
98
  }
45
99
  function account(source) {
46
100
  validate(source);
47
- if (source.provider !== 'codex')
48
- return source.key.trim() ? fp(source.provider, source.key) : undefined;
49
- const raw = readJson(join(source.home, 'auth.json'), 64 * 1024);
50
- const tokens = record(raw) && record(raw.tokens) ? raw.tokens : undefined;
51
- const value = typeof tokens?.account_id === 'string' ? tokens.account_id : `codex-home\0${source.home}`;
52
- return value !== '' && value.length <= 16384 ? fp('codex', value) : undefined;
101
+ let id;
102
+ if ('credentialsFile' in source)
103
+ id = claudeAuth(source)?.account;
104
+ else if ('read' in source)
105
+ id = source.accountUuid;
106
+ else if ('bin' in source) {
107
+ const raw = readJson(join(source.home, 'auth.json'), 64 * 1024);
108
+ const tokens = record(raw) && record(raw.tokens) ? raw.tokens : undefined;
109
+ id = typeof tokens?.account_id === 'string' ? tokens.account_id : `codex-home\0${source.home}`;
110
+ }
111
+ else
112
+ id = source.accountId ?? (source.provider === 'claude' ? source.accountUuid : undefined) ?? ('access' in source ? subject(source.access) : undefined);
113
+ return id && id.length <= 16384 ? fp(source.provider, id) : undefined;
114
+ }
115
+ function identity(source) {
116
+ const stable = account(source);
117
+ if (stable)
118
+ return { key: stable, stable: true };
119
+ // Opaque credentials without an identity can be read, but never reach an app/disk store.
120
+ const credential = 'access' in source ? source.access : 'key' in source ? source.key : '';
121
+ return { key: createHash('sha256').update(`${source.provider}\0${credential}`).digest('hex'), stable: false };
122
+ }
123
+ function windows(source, raw, clock) {
124
+ switch (source.provider) {
125
+ case 'claude': return claudeWindows(raw);
126
+ case 'codex': return 'bin' in source ? codexWindows(raw) : codexTokenWindows(raw);
127
+ case 'opencode': return goWindows(record(raw) ? raw.usage : undefined);
128
+ case 'zai': return zaiWindows(record(raw) && record(raw.data) ? raw.data.limits : undefined);
129
+ case 'copilot': return copilotWindows(raw);
130
+ case 'grok': return grokWindows(raw);
131
+ case 'minimax': return minimaxWindows(raw);
132
+ case 'gemini': return geminiWindows(raw);
133
+ case 'kimi': return kimiWindows(raw, clock);
134
+ }
53
135
  }
54
- const windows = (source, raw) => source.provider === 'codex' ? codexWindows(raw) : source.provider === 'opencode' ? goWindows(raw) : zaiWindows(raw);
55
136
  function lastKnown(source, opts) {
56
137
  if (!connected(source))
57
138
  return undefined;
58
- const key = account(source);
59
- if (!key)
60
- return undefined;
61
- const stored = disk.get(source.provider, key);
139
+ const id = identity(source);
62
140
  const clock = now(opts);
63
- if (!stored || clock < stored.at || clock - stored.at > 86_400_000)
141
+ try {
142
+ const stored = (id.stable ? disk : transient).get(source.provider, id.key);
143
+ if (!stored || !Number.isFinite(stored.at) || clock < stored.at || clock - stored.at > 86_400_000)
144
+ return undefined;
145
+ const rows = safeWindows(source.provider, stored.windows);
146
+ return rows.length ? { provider: source.provider, windows: rows, at: stored.at } : undefined;
147
+ }
148
+ catch {
64
149
  return undefined;
65
- const rows = windows(source, stored.raw);
66
- return rows.length ? { provider: source.provider, windows: rows, at: stored.at } : undefined;
150
+ }
67
151
  }
68
152
  async function read(source, opts) {
69
153
  validate(source);
@@ -71,48 +155,68 @@ export function usage(options) {
71
155
  const empty = (code) => ({ provider: source.provider, windows: [], at: clock, code });
72
156
  if (!connected(source))
73
157
  return empty('not-connected');
74
- const key = account(source);
75
- if (!key)
76
- return empty('not-connected');
77
- const identity = `${source.provider}\0${key}`;
158
+ const id = identity(source);
159
+ const key = `${source.provider}\0${id.key}`;
78
160
  const previous = lastKnown(source, { nowMs: clock });
79
161
  if (previous && clock - previous.at < 60_000)
80
162
  return previous;
81
- if ((backoff.get(identity) ?? 0) > clock)
163
+ let until = backoffs.get(key) ?? 0;
164
+ try {
165
+ if (id.stable)
166
+ until = Math.max(until, options.backoff?.get(source.provider, id.key) ?? 0);
167
+ }
168
+ catch { /* keep internal backoff */ }
169
+ if (until > clock)
82
170
  return previous ? { ...previous, code: 'rate-limited' } : empty('rate-limited');
83
- const pending = inFlight.get(identity);
171
+ const pending = inFlight.get(key);
84
172
  if (pending)
85
173
  return pending;
86
- const attempt = attempts.get(identity);
174
+ const attempt = attempts.get(key);
87
175
  if (attempt && clock >= attempt.at && clock - attempt.at < 60_000)
88
- return previous ? { ...previous, code: attempt.answer.code } : empty(attempt.answer.code ?? 'unavailable');
176
+ return previous ? { ...previous, code: attempt.code } : empty(attempt.code ?? 'unavailable');
89
177
  const task = (async () => {
90
178
  let answer;
91
179
  try {
92
- answer = source.provider === 'codex' ? await codexUsage(source) : await providerGet(source, options.fetch ?? globalThis.fetch, clock);
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);
93
182
  }
94
183
  catch {
95
184
  answer = { code: 'unavailable' };
96
185
  }
97
- if (answer.until !== undefined)
98
- backoff.set(identity, answer.until);
99
- const rows = windows(source, answer.raw);
186
+ if (answer.code === 'rate-limited') {
187
+ let delay = backoffDelayMs(answer.retryAfterMs);
188
+ try {
189
+ const selected = options.backoff?.delayMs?.(answer.retryAfterMs);
190
+ if (selected !== undefined && Number.isFinite(selected) && selected >= 0)
191
+ delay = selected;
192
+ }
193
+ catch { /* default */ }
194
+ backoffs.set(key, clock + delay);
195
+ try {
196
+ if (id.stable)
197
+ options.backoff?.set(source.provider, id.key, clock + delay);
198
+ }
199
+ catch { /* internal backoff stands */ }
200
+ }
201
+ const rows = safeWindows(source.provider, windows(source, answer.raw, clock));
100
202
  if (!answer.code && !rows.length)
101
203
  answer = { code: 'incomplete' };
102
- // Do not keep a source key or a raw payload in a retry map.
103
- attempts.set(identity, { at: clock, answer: { code: answer.code } });
204
+ attempts.set(key, { at: clock, code: answer.code });
104
205
  if (rows.length && !answer.code) {
105
- disk.put(source.provider, key, { at: clock, raw: answer.raw });
206
+ try {
207
+ (id.stable ? disk : transient).put(source.provider, id.key, { at: clock, windows: rows });
208
+ }
209
+ catch { /* reads survive a store failure */ }
106
210
  return { provider: source.provider, windows: rows, at: clock };
107
211
  }
108
212
  return previous ? { ...previous, code: answer.code } : empty(answer.code ?? 'unavailable');
109
213
  })();
110
- inFlight.set(identity, task);
214
+ inFlight.set(key, task);
111
215
  try {
112
216
  return await task;
113
217
  }
114
218
  finally {
115
- inFlight.delete(identity);
219
+ inFlight.delete(key);
116
220
  }
117
221
  }
118
222
  return { read, lastKnown, connected, account };
@@ -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
+ }
@@ -2,11 +2,30 @@ import type { Code, Source } from './types.ts';
2
2
  export interface Answer {
3
3
  raw?: unknown;
4
4
  code?: Code;
5
- until?: number;
5
+ retryAfterMs?: number;
6
6
  }
7
- export declare function providerGet(source: Extract<Source, {
7
+ type TokenSource = Extract<Source, {
8
+ access: string;
9
+ } | {
8
10
  key: string;
9
- }>, fetcher: typeof fetch, nowMs: number): Promise<Answer>;
11
+ }>;
12
+ export declare function providerGet(source: TokenSource, fetcher: typeof fetch, nowMs: number): Promise<Answer>;
13
+ /** A host hook gets the same deadline and failure envelope as built-in sources. */
14
+ export declare function customClaude(source: Extract<Source, {
15
+ read: unknown;
16
+ }>, nowMs: number): Promise<Answer>;
10
17
  export declare function codexUsage(source: Extract<Source, {
11
- provider: 'codex';
18
+ bin: string;
12
19
  }>): Promise<Answer>;
20
+ /** Re-read the tool's credentials so its own renewal is picked up; never renew or write them. */
21
+ export declare function claudeAuth(source: Extract<Source, {
22
+ credentialsFile: string;
23
+ }>): {
24
+ token: string;
25
+ expiresAt?: number;
26
+ account: string;
27
+ } | undefined;
28
+ export declare function claudeUsage(source: Extract<Source, {
29
+ credentialsFile: string;
30
+ }>, fetcher: typeof fetch, nowMs: number): Promise<Answer>;
31
+ export {};
package/dist/providers.js CHANGED
@@ -1,17 +1,21 @@
1
+ import { retryAfterMs } from "./backoff.js";
1
2
  import { spawn } from 'node:child_process';
2
- import { record } from "./windows.js";
3
- export async function providerGet(source, fetcher, nowMs) {
3
+ import { readJson, readJsonSnapshot } from "./store.js";
4
+ import { claudeWindows, record } from "./windows.js";
5
+ import { grokWindows } from "./quota.js";
6
+ const USER_AGENT = 'byokit/usage/0.2.0';
7
+ /** One bounded request; credentials and response bodies never become errors. */
8
+ async function request(url, key, fetcher, nowMs, extra = {}) {
4
9
  const controller = new AbortController();
5
10
  const timer = setTimeout(() => controller.abort(), 10_000);
6
11
  try {
7
- const url = source.provider === 'opencode' ? 'https://opencode.ai/zen/go/v1/usage' : 'https://api.z.ai/api/monitor/usage/quota/limit';
8
- const response = await fetcher(url, { headers: { accept: 'application/json', authorization: `Bearer ${source.key}` }, redirect: 'error', signal: controller.signal });
12
+ const response = await fetcher(url, { headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'User-Agent': USER_AGENT,
13
+ ...(extra.body !== undefined ? { 'content-type': 'application/json' } : {}), ...extra.headers },
14
+ ...(extra.body !== undefined ? { method: 'POST', body: JSON.stringify(extra.body) } : {}), redirect: 'error', signal: controller.signal });
9
15
  if (response.status === 429) {
10
- const retry = response.headers.get('retry-after');
11
- const seconds = Number(retry);
12
- const delay = retry !== null && Number.isFinite(seconds) ? seconds * 1000 : Date.parse(retry ?? '') - nowMs;
16
+ const delay = retryAfterMs(response.headers.get('retry-after'), nowMs);
13
17
  controller.abort();
14
- return { code: 'rate-limited', until: nowMs + Math.max(300_000, Number.isFinite(delay) ? delay : 0) };
18
+ return { code: 'rate-limited', retryAfterMs: delay };
15
19
  }
16
20
  if (response.status !== 200) {
17
21
  controller.abort();
@@ -40,11 +44,60 @@ export async function providerGet(source, fetcher, nowMs) {
40
44
  catch {
41
45
  return { code: 'incomplete' };
42
46
  }
43
- if (!record(raw))
44
- return { code: 'incomplete' };
45
- if (source.provider === 'zai' && raw.success === false)
46
- return { code: 'no-plan' };
47
- return { raw: source.provider === 'opencode' ? raw.usage : record(raw.data) ? raw.data.limits : undefined };
47
+ return record(raw) ? { raw } : { code: 'incomplete' };
48
+ }
49
+ catch {
50
+ return { code: 'unavailable' };
51
+ }
52
+ finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+ export async function providerGet(source, fetcher, nowMs) {
57
+ const key = 'access' in source ? source.access : source.key;
58
+ const get = (url, extra) => request(url, key, fetcher, nowMs, extra);
59
+ switch (source.provider) {
60
+ case 'claude': return get('https://api.anthropic.com/api/oauth/usage', { headers: { 'anthropic-beta': 'oauth-2025-04-20' } });
61
+ case 'codex': return get('https://chatgpt.com/backend-api/wham/usage', { headers: { 'ChatGPT-Account-Id': source.accountId } });
62
+ case 'copilot': return get('https://api.github.com/copilot_internal/user');
63
+ case 'minimax': {
64
+ const answer = await get('https://api.minimax.io/v1/token_plan/remains');
65
+ return !answer.code && record(answer.raw) && record(answer.raw.base_resp) && answer.raw.base_resp.status_code !== 0 ? { code: 'no-plan' } : answer;
66
+ }
67
+ case 'kimi': return get('https://api.kimi.com/coding/v1/usages');
68
+ case 'grok': {
69
+ const answer = await get('https://cli-chat-proxy.grok.com/v1/billing?format=credits');
70
+ if (answer.code)
71
+ return answer;
72
+ const config = record(answer.raw) && record(answer.raw.config) ? answer.raw.config : undefined;
73
+ return config?.isUnifiedBillingUser === true || !grokWindows(answer.raw).length ? get('https://cli-chat-proxy.grok.com/v1/billing') : answer;
74
+ }
75
+ case 'gemini': {
76
+ let project = source.project;
77
+ if (!project) {
78
+ const loaded = await get('https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', { body: { metadata: { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' } } });
79
+ if (loaded.code)
80
+ return loaded;
81
+ const value = record(loaded.raw) ? loaded.raw.cloudaicompanionProject : undefined;
82
+ project = typeof value === 'string' ? value : record(value) && typeof value.id === 'string' ? value.id : undefined;
83
+ }
84
+ return get('https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota', { body: project ? { project } : {} });
85
+ }
86
+ case 'opencode': return get('https://opencode.ai/zen/go/v1/usage');
87
+ case 'zai': {
88
+ const answer = await get('https://api.z.ai/api/monitor/usage/quota/limit');
89
+ return !answer.code && record(answer.raw) && answer.raw.success === false ? { code: 'no-plan' } : answer;
90
+ }
91
+ }
92
+ }
93
+ /** A host hook gets the same deadline and failure envelope as built-in sources. */
94
+ export async function customClaude(source, nowMs) {
95
+ const controller = new AbortController();
96
+ let timer;
97
+ try {
98
+ return await Promise.race([source.read({ nowMs, signal: controller.signal }), new Promise((resolve) => {
99
+ timer = setTimeout(() => { controller.abort(); resolve({ code: 'unavailable' }); }, 10_000);
100
+ })]);
48
101
  }
49
102
  catch {
50
103
  return { code: 'unavailable' };
@@ -107,3 +160,28 @@ export function codexUsage(source) {
107
160
  child.stdin.write(`${JSON.stringify({ id: 1, method: 'initialize', params: { clientInfo: { name: 'byokit', version: '1' } } })}\n`);
108
161
  });
109
162
  }
163
+ /** Re-read the tool's credentials so its own renewal is picked up; never renew or write them. */
164
+ export function claudeAuth(source) {
165
+ const stored = readJson(source.credentialsFile, 64 * 1024);
166
+ const credentials = record(stored) && record(stored.claudeAiOauth) ? stored.claudeAiOauth : undefined;
167
+ const token = credentials?.accessToken;
168
+ if (!credentials || typeof token !== 'string' || !token.trim() || token.length > 16384 || token.includes('\0'))
169
+ return undefined;
170
+ const config = source.configFile ? readJson(source.configFile, 4 * 1024 * 1024) : undefined;
171
+ const oauthAccount = record(config) && record(config.oauthAccount) ? config.oauthAccount : undefined;
172
+ const identity = typeof credentials.accountUuid === 'string' ? credentials.accountUuid : typeof oauthAccount?.accountUuid === 'string' ? oauthAccount.accountUuid : `claude-file\0${source.credentialsFile}`;
173
+ if (typeof identity !== 'string' || !identity || identity.length > 16384)
174
+ return undefined;
175
+ return { token, account: identity, ...(typeof credentials.expiresAt === 'number' && Number.isFinite(credentials.expiresAt) ? { expiresAt: credentials.expiresAt } : {}) };
176
+ }
177
+ export async function claudeUsage(source, fetcher, nowMs) {
178
+ const snapshot = source.statuslineFile ? readJsonSnapshot(source.statuslineFile, 64 * 1024) : undefined;
179
+ if (snapshot && nowMs >= snapshot.modified && nowMs - snapshot.modified < 300_000 && claudeWindows(snapshot.value).length)
180
+ return { raw: snapshot.value };
181
+ const auth = claudeAuth(source);
182
+ if (!auth)
183
+ return { code: 'not-connected' };
184
+ if (auth.expiresAt !== undefined && auth.expiresAt <= nowMs)
185
+ return { code: 'expired' };
186
+ return providerGet({ provider: 'claude', access: auth.token }, fetcher, nowMs);
187
+ }
@@ -0,0 +1,7 @@
1
+ import type { Window } from './types.ts';
2
+ export declare function codexTokenWindows(raw: unknown): Window[];
3
+ export declare function copilotWindows(raw: unknown): Window[];
4
+ export declare function grokWindows(raw: unknown): Window[];
5
+ export declare function minimaxWindows(raw: unknown): Window[];
6
+ export declare function geminiWindows(raw: unknown): Window[];
7
+ export declare function kimiWindows(raw: unknown, nowMs: number): Window[];