@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/providers.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
|
+
import { retryAfterMs } from "./backoff.js";
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
|
|
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 = {}, pacing) {
|
|
4
9
|
const controller = new AbortController();
|
|
5
|
-
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);
|
|
6
15
|
try {
|
|
7
|
-
|
|
8
|
-
|
|
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' };
|
|
25
|
+
const response = await fetcher(url, { headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'User-Agent': USER_AGENT,
|
|
26
|
+
...(extra.body !== undefined ? { 'content-type': 'application/json' } : {}), ...extra.headers },
|
|
27
|
+
...(extra.body !== undefined ? { method: 'POST', body: JSON.stringify(extra.body) } : {}), redirect: 'error', signal: controller.signal });
|
|
9
28
|
if (response.status === 429) {
|
|
10
|
-
const
|
|
11
|
-
const seconds = Number(retry);
|
|
12
|
-
const delay = retry !== null && Number.isFinite(seconds) ? seconds * 1000 : Date.parse(retry ?? '') - nowMs;
|
|
29
|
+
const delay = retryAfterMs(response.headers.get('retry-after'), nowMs);
|
|
13
30
|
controller.abort();
|
|
14
|
-
return { code: 'rate-limited',
|
|
31
|
+
return { code: 'rate-limited', retryAfterMs: delay };
|
|
15
32
|
}
|
|
16
33
|
if (response.status !== 200) {
|
|
17
34
|
controller.abort();
|
|
@@ -40,17 +57,84 @@ export async function providerGet(source, fetcher, nowMs) {
|
|
|
40
57
|
catch {
|
|
41
58
|
return { code: 'incomplete' };
|
|
42
59
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
return record(raw) ? { raw } : { code: 'incomplete' };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return { code: 'unavailable' };
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
pacing?.signal?.removeEventListener('abort', abort);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function providerGet(source, fetcher, nowMs, pacing) {
|
|
71
|
+
const key = 'access' in source ? source.access : source.key;
|
|
72
|
+
const get = (url, extra) => request(url, key, fetcher, nowMs, extra, pacing);
|
|
73
|
+
switch (source.provider) {
|
|
74
|
+
case 'claude': return get('https://api.anthropic.com/api/oauth/usage', { headers: { 'anthropic-beta': 'oauth-2025-04-20' } });
|
|
75
|
+
case 'codex': return get('https://chatgpt.com/backend-api/wham/usage', { headers: { 'ChatGPT-Account-Id': source.accountId } });
|
|
76
|
+
case 'copilot': return get('https://api.github.com/copilot_internal/user');
|
|
77
|
+
case 'minimax': {
|
|
78
|
+
const answer = await get('https://api.minimax.io/v1/token_plan/remains');
|
|
79
|
+
return !answer.code && record(answer.raw) && record(answer.raw.base_resp) && answer.raw.base_resp.status_code !== 0 ? { code: 'no-plan' } : answer;
|
|
80
|
+
}
|
|
81
|
+
case 'kimi': return get('https://api.kimi.com/coding/v1/usages');
|
|
82
|
+
case 'grok': {
|
|
83
|
+
const answer = await get('https://cli-chat-proxy.grok.com/v1/billing?format=credits');
|
|
84
|
+
if (answer.code)
|
|
85
|
+
return answer;
|
|
86
|
+
const config = record(answer.raw) && record(answer.raw.config) ? answer.raw.config : undefined;
|
|
87
|
+
return config?.isUnifiedBillingUser === true || !grokWindows(answer.raw).length ? get('https://cli-chat-proxy.grok.com/v1/billing') : answer;
|
|
88
|
+
}
|
|
89
|
+
case 'gemini': {
|
|
90
|
+
let project = source.project;
|
|
91
|
+
if (!project) {
|
|
92
|
+
const loaded = await get('https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', { body: { metadata: { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' } } });
|
|
93
|
+
if (loaded.code)
|
|
94
|
+
return loaded;
|
|
95
|
+
const value = record(loaded.raw) ? loaded.raw.cloudaicompanionProject : undefined;
|
|
96
|
+
project = typeof value === 'string' ? value : record(value) && typeof value.id === 'string' ? value.id : undefined;
|
|
97
|
+
}
|
|
98
|
+
return get('https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota', { body: project ? { project } : {} });
|
|
99
|
+
}
|
|
100
|
+
case 'opencode': return get('https://opencode.ai/zen/go/v1/usage');
|
|
101
|
+
case 'zai': {
|
|
102
|
+
const answer = await get('https://api.z.ai/api/monitor/usage/quota/limit');
|
|
103
|
+
return !answer.code && record(answer.raw) && answer.raw.success === false ? { code: 'no-plan' } : answer;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** A host hook gets the same deadline and failure envelope as built-in sources. */
|
|
108
|
+
export async function customClaude(source, nowMs, pacing) {
|
|
109
|
+
const controller = new AbortController();
|
|
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);
|
|
115
|
+
try {
|
|
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 });
|
|
130
|
+
})]);
|
|
48
131
|
}
|
|
49
132
|
catch {
|
|
50
133
|
return { code: 'unavailable' };
|
|
51
134
|
}
|
|
52
135
|
finally {
|
|
53
136
|
clearTimeout(timer);
|
|
137
|
+
pacing?.signal?.removeEventListener('abort', abort);
|
|
54
138
|
}
|
|
55
139
|
}
|
|
56
140
|
export function codexUsage(source) {
|
|
@@ -107,3 +191,32 @@ export function codexUsage(source) {
|
|
|
107
191
|
child.stdin.write(`${JSON.stringify({ id: 1, method: 'initialize', params: { clientInfo: { name: 'byokit', version: '1' } } })}\n`);
|
|
108
192
|
});
|
|
109
193
|
}
|
|
194
|
+
/** Re-read the tool's credentials so its own renewal is picked up; never renew or write them. */
|
|
195
|
+
export function claudeAuth(source) {
|
|
196
|
+
const stored = readJson(source.credentialsFile, 64 * 1024);
|
|
197
|
+
const credentials = record(stored) && record(stored.claudeAiOauth) ? stored.claudeAiOauth : undefined;
|
|
198
|
+
const token = credentials?.accessToken;
|
|
199
|
+
if (!credentials || typeof token !== 'string' || !token.trim() || token.length > 16384 || token.includes('\0'))
|
|
200
|
+
return undefined;
|
|
201
|
+
const config = source.configFile ? readJson(source.configFile, 4 * 1024 * 1024) : undefined;
|
|
202
|
+
const oauthAccount = record(config) && record(config.oauthAccount) ? config.oauthAccount : undefined;
|
|
203
|
+
const identity = typeof credentials.accountUuid === 'string' ? credentials.accountUuid : typeof oauthAccount?.accountUuid === 'string' ? oauthAccount.accountUuid : `claude-file\0${source.credentialsFile}`;
|
|
204
|
+
if (typeof identity !== 'string' || !identity || identity.length > 16384)
|
|
205
|
+
return undefined;
|
|
206
|
+
return { token, account: identity, ...(typeof credentials.expiresAt === 'number' && Number.isFinite(credentials.expiresAt) ? { expiresAt: credentials.expiresAt } : {}) };
|
|
207
|
+
}
|
|
208
|
+
export async function claudeUsage(source, fetcher, nowMs, pacing) {
|
|
209
|
+
const snapshot = source.statuslineFile ? readJsonSnapshot(source.statuslineFile, 64 * 1024) : undefined;
|
|
210
|
+
if (snapshot && record(snapshot.value) && claudeWindows(snapshot.value).length) {
|
|
211
|
+
const rawTime = snapshot.value.fetched_at;
|
|
212
|
+
const at = typeof rawTime === 'number' ? rawTime : typeof rawTime === 'string' ? Date.parse(rawTime) : undefined;
|
|
213
|
+
if (at === undefined || !Number.isFinite(at) || nowMs < at || nowMs - at < 300_000)
|
|
214
|
+
return { raw: snapshot.value, at };
|
|
215
|
+
}
|
|
216
|
+
const auth = claudeAuth(source);
|
|
217
|
+
if (!auth)
|
|
218
|
+
return { code: 'not-connected' };
|
|
219
|
+
if (auth.expiresAt !== undefined && auth.expiresAt <= nowMs)
|
|
220
|
+
return { code: 'expired' };
|
|
221
|
+
return providerGet({ provider: 'claude', access: auth.token }, fetcher, nowMs, pacing);
|
|
222
|
+
}
|
package/dist/quota.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Window } from './types.ts';
|
|
2
|
+
export declare function codexTokenWindows(raw: unknown, nowMs?: number): Window[];
|
|
3
|
+
export declare function codexHardLimit(raw: unknown): boolean;
|
|
4
|
+
export declare function copilotWindows(raw: unknown): Window[];
|
|
5
|
+
export declare function grokWindows(raw: unknown): Window[];
|
|
6
|
+
export declare function minimaxWindows(raw: unknown): Window[];
|
|
7
|
+
export declare function geminiWindows(raw: unknown): Window[];
|
|
8
|
+
export declare function kimiWindows(raw: unknown, nowMs: number): Window[];
|
package/dist/quota.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Protocol shapes informed by can1357/oh-my-pi (MIT), pinned at 2b023d1.
|
|
2
|
+
// This module normalizes quota fields only; it does not import upstream runtime code.
|
|
3
|
+
import { record, window, codexWindows } from "./windows.js";
|
|
4
|
+
const obj = (value) => record(value) ? value : {};
|
|
5
|
+
const rows = (value) => Array.isArray(value) ? value : [];
|
|
6
|
+
const number = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
7
|
+
const label = (value) => typeof value === 'string' ? value.replace(/[^\x20-\x7e]/g, ' ').trim().slice(0, 80) || undefined : undefined;
|
|
8
|
+
const time = (value) => typeof value === 'string' ? Date.parse(value) : number(value);
|
|
9
|
+
const ratio = (used, limit, remaining) => {
|
|
10
|
+
const cap = number(limit);
|
|
11
|
+
const consumed = number(used);
|
|
12
|
+
const left = number(remaining);
|
|
13
|
+
return cap !== undefined && cap > 0 && (consumed !== undefined || left !== undefined) ? 100 * (consumed ?? cap - left) / cap : undefined;
|
|
14
|
+
};
|
|
15
|
+
export function codexTokenWindows(raw, nowMs) {
|
|
16
|
+
const source = obj(raw);
|
|
17
|
+
const plan = obj(source.rate_limit);
|
|
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
|
+
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 !== 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
|
+
})) });
|
|
23
|
+
return codexWindows({ rateLimitsByLimitId: Object.fromEntries([
|
|
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
|
+
]) });
|
|
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
|
+
}
|
|
31
|
+
export function copilotWindows(raw) {
|
|
32
|
+
const source = obj(raw);
|
|
33
|
+
return Object.entries(obj(source.quota_snapshots)).flatMap(([key, value]) => {
|
|
34
|
+
const quota = obj(value);
|
|
35
|
+
if (quota.unlimited === true)
|
|
36
|
+
return [];
|
|
37
|
+
const left = number(quota.percent_remaining);
|
|
38
|
+
return window('copilot', 'monthly', left === undefined ? ratio(undefined, quota.entitlement, quota.remaining) : 100 - left, undefined, time(source.quota_reset_date), false, label(key));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function grokWindows(raw) {
|
|
42
|
+
const config = obj(obj(raw).config);
|
|
43
|
+
const period = obj(config.currentPeriod);
|
|
44
|
+
const weekly = number(config.creditUsagePercent);
|
|
45
|
+
if (weekly !== undefined)
|
|
46
|
+
return window('grok', 'weekly', weekly, 10080, time(period.end));
|
|
47
|
+
return window('grok', 'monthly', ratio(config.used, config.monthlyLimit), undefined, time(config.periodEnd));
|
|
48
|
+
}
|
|
49
|
+
export function minimaxWindows(raw) {
|
|
50
|
+
const source = obj(raw);
|
|
51
|
+
if (obj(source.base_resp).status_code !== 0)
|
|
52
|
+
return [];
|
|
53
|
+
return rows(source.model_remains).flatMap((value) => {
|
|
54
|
+
const bucket = obj(value);
|
|
55
|
+
if (bucket.current_interval_status === 3 && bucket.current_weekly_status === 3 && bucket.current_interval_total_count === 0 && bucket.current_weekly_total_count === 0)
|
|
56
|
+
return [];
|
|
57
|
+
return ['interval', 'weekly'].flatMap((key) => {
|
|
58
|
+
const left = number(bucket[`current_${key}_remaining_percent`]);
|
|
59
|
+
const exhausted = bucket[`current_${key}_status`] === 2;
|
|
60
|
+
const rawReset = bucket[key === 'interval' ? 'end_time' : 'weekly_end_time'];
|
|
61
|
+
const reset = typeof rawReset === 'number' && rawReset > 0 ? rawReset < 1e12 ? rawReset * 1000 : rawReset : time(rawReset);
|
|
62
|
+
return window('minimax', key === 'weekly' ? 'weekly' : 'rolling', exhausted ? 100 : left === undefined ? undefined : 100 - left, key === 'weekly' ? 10080 : undefined, reset, exhausted, label(bucket.model_name));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function geminiWindows(raw) {
|
|
67
|
+
return rows(obj(raw).buckets).flatMap((value) => {
|
|
68
|
+
const bucket = obj(value);
|
|
69
|
+
const left = number(bucket.remainingFraction);
|
|
70
|
+
return window('gemini', 'custom', left === undefined ? undefined : 100 - left * 100, undefined, time(bucket.resetTime), false, label(bucket.modelId));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function kimiWindows(raw, nowMs) {
|
|
74
|
+
const source = obj(raw);
|
|
75
|
+
const read = (value, minutes, reset) => {
|
|
76
|
+
const detail = obj(value);
|
|
77
|
+
const units = { SECOND: 1 / 60, MINUTE: 1, HOUR: 60, DAY: 1440, WEEK: 10080 };
|
|
78
|
+
const w = obj(detail.window);
|
|
79
|
+
const duration = number(w.duration);
|
|
80
|
+
const m = minutes ?? (duration === undefined ? undefined : duration * (units[String(w.timeUnit).toUpperCase().replace(/S$/, '')] ?? NaN));
|
|
81
|
+
const row = record(detail.detail) ? detail.detail : detail;
|
|
82
|
+
const resetValue = reset ?? w.resetTime ?? row.resetTime ?? row.reset_at ?? row.resetAt;
|
|
83
|
+
let resetMs = time(resetValue);
|
|
84
|
+
if (typeof resetValue === 'number')
|
|
85
|
+
resetMs = resetValue > 1e12 ? resetValue : resetValue * 1000;
|
|
86
|
+
if (resetValue === undefined && number(row.reset_in) !== undefined)
|
|
87
|
+
resetMs = nowMs + number(row.reset_in) * 1000;
|
|
88
|
+
return window('kimi', m === 300 ? 'session' : m === 10080 ? 'weekly' : m === 43200 ? 'monthly' : 'custom', ratio(row.used, row.limit, row.remaining), m, resetMs);
|
|
89
|
+
};
|
|
90
|
+
return [...read(source.usage, 10080), ...read(source.totalQuota), ...rows(source.limits).flatMap((row) => read(row))];
|
|
91
|
+
}
|
package/dist/room.d.ts
ADDED
package/dist/room.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Figures retain their observation age; poll failure does not imply exhaustion. */
|
|
2
|
+
export function roomOf(reading, nowMs) {
|
|
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);
|
|
16
|
+
if (!tight)
|
|
17
|
+
return { ...meta, left: 'unknown' };
|
|
18
|
+
const span = tight.kind === 'weekly' ? 'week' : tight.kind === 'monthly' ? 'month' : tight.kind === 'session' ? 'session' : 'tightest';
|
|
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 } : {}),
|
|
24
|
+
...(Number.isFinite(tight.resetsAt) ? { resetsAt: tight.resetsAt } : {}) };
|
|
25
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import type { Provider } from './types.ts';
|
|
2
|
-
export
|
|
3
|
-
at: number;
|
|
4
|
-
raw: unknown;
|
|
5
|
-
}
|
|
1
|
+
import type { Provider, StoredReading, UsageStore, Window, Poll } from './types.ts';
|
|
2
|
+
export type Stored = StoredReading;
|
|
6
3
|
/** Bounded regular files only; do not follow credential or state symlinks. */
|
|
4
|
+
export declare function readJsonSnapshot(file: string, cap: number): {
|
|
5
|
+
value: unknown;
|
|
6
|
+
modified: number;
|
|
7
|
+
} | undefined;
|
|
7
8
|
export declare function readJson(file: string, cap: number): unknown;
|
|
8
9
|
export declare function fingerprint(salt: string): (provider: Provider, value: string) => string;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/** Poll metadata is distinct from the observation timestamp. */
|
|
11
|
+
export declare function safePoll(raw: unknown): Poll | undefined;
|
|
12
|
+
export declare function store(stateDir: string): UsageStore;
|
|
13
|
+
/** Public stores receive only these quota fields, never raw provider payloads. */
|
|
14
|
+
export declare function safeWindows(provider: Provider, raw: unknown): Window[];
|
|
15
|
+
export declare function memoryUsageStore(): UsageStore;
|
package/dist/store.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID, scryptSync } from 'node:crypto';
|
|
2
2
|
import { chmodSync, closeSync, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, constants } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { UsageError } from "./types.js";
|
|
5
|
+
import { record, quotaScope } from "./windows.js";
|
|
5
6
|
/** Bounded regular files only; do not follow credential or state symlinks. */
|
|
6
|
-
export function
|
|
7
|
+
export function readJsonSnapshot(file, cap) {
|
|
7
8
|
let fd;
|
|
8
9
|
try {
|
|
9
10
|
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -18,7 +19,7 @@ export function readJson(file, cap) {
|
|
|
18
19
|
break;
|
|
19
20
|
length += count;
|
|
20
21
|
}
|
|
21
|
-
return length <= cap ? JSON.parse(body.subarray(0, length).toString('utf8')) : undefined;
|
|
22
|
+
return length <= cap ? { value: JSON.parse(body.subarray(0, length).toString('utf8')), modified: stat.mtimeMs } : undefined;
|
|
22
23
|
}
|
|
23
24
|
catch {
|
|
24
25
|
return undefined;
|
|
@@ -28,6 +29,7 @@ export function readJson(file, cap) {
|
|
|
28
29
|
closeSync(fd);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
export function readJson(file, cap) { return readJsonSnapshot(file, cap)?.value; }
|
|
31
33
|
export function fingerprint(salt) {
|
|
32
34
|
const memo = new Map();
|
|
33
35
|
return (provider, value) => {
|
|
@@ -42,21 +44,29 @@ export function fingerprint(salt) {
|
|
|
42
44
|
return fp;
|
|
43
45
|
};
|
|
44
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
|
+
}
|
|
45
53
|
export function store(stateDir) {
|
|
46
|
-
|
|
54
|
+
if (typeof stateDir !== 'string' || !isAbsolute(stateDir) || /[\0\r\n]/.test(stateDir))
|
|
55
|
+
throw new UsageError();
|
|
56
|
+
const path = join(stateDir, 'plans-v2.json');
|
|
47
57
|
function load() {
|
|
48
58
|
const saved = readJson(path, 256 * 1024);
|
|
49
59
|
const plans = {};
|
|
50
60
|
if (!record(saved) || !record(saved.plans))
|
|
51
61
|
return plans;
|
|
52
|
-
for (const id of ['claude', 'codex', 'opencode', 'zai']) {
|
|
62
|
+
for (const id of ['claude', 'codex', 'opencode', 'zai', 'copilot', 'grok', 'minimax', 'gemini', 'kimi']) {
|
|
53
63
|
const entries = saved.plans[id];
|
|
54
64
|
if (!record(entries))
|
|
55
65
|
continue;
|
|
56
66
|
const readings = Object.create(null);
|
|
57
67
|
for (const [fp, r] of Object.entries(entries)) {
|
|
58
|
-
if (/^[a-f0-9]{64}$/.test(fp) && record(r) && typeof r.at === 'number' && Number.isFinite(r.at))
|
|
59
|
-
readings[fp] = { at: r.at,
|
|
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) } : {}) };
|
|
60
70
|
}
|
|
61
71
|
plans[id] = readings;
|
|
62
72
|
}
|
|
@@ -65,13 +75,15 @@ export function store(stateDir) {
|
|
|
65
75
|
return {
|
|
66
76
|
get: (id, fp) => load()[id]?.[fp],
|
|
67
77
|
put(id, fp, reading) {
|
|
78
|
+
if (!/^[a-f0-9]{64}$/.test(fp) || !Number.isFinite(reading.at))
|
|
79
|
+
return;
|
|
68
80
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
69
81
|
try {
|
|
70
82
|
const plans = load();
|
|
71
83
|
const entries = plans[id] ?? {};
|
|
72
|
-
if (reading.at < (entries[fp]?.at ?? -Infinity))
|
|
84
|
+
if (reading.at !== undefined && reading.at < (entries[fp]?.at ?? -Infinity))
|
|
73
85
|
return;
|
|
74
|
-
entries[fp] = reading;
|
|
86
|
+
entries[fp] = { at: reading.at, windows: safeWindows(id, reading.windows), ...(reading.limited ? { limited: true } : {}), ...(safePoll(reading.poll) ? { poll: safePoll(reading.poll) } : {}) };
|
|
75
87
|
plans[id] = entries;
|
|
76
88
|
const body = JSON.stringify({ plans });
|
|
77
89
|
if (Buffer.byteLength(body) > 256 * 1024)
|
|
@@ -91,3 +103,22 @@ export function store(stateDir) {
|
|
|
91
103
|
},
|
|
92
104
|
};
|
|
93
105
|
}
|
|
106
|
+
/** Public stores receive only these quota fields, never raw provider payloads. */
|
|
107
|
+
export function safeWindows(provider, raw) {
|
|
108
|
+
return (Array.isArray(raw) ? raw : []).slice(0, 64).flatMap((value) => {
|
|
109
|
+
if (!record(value) || !['session', 'weekly', 'monthly', 'rolling', 'custom'].includes(String(value.kind)) || value.usedPercent !== undefined && (typeof value.usedPercent !== 'number' || !Number.isFinite(value.usedPercent)))
|
|
110
|
+
return [];
|
|
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) } : {}),
|
|
113
|
+
...(typeof value.minutes === 'number' && Number.isFinite(value.minutes) && value.minutes > 0 ? { minutes: value.minutes } : {}),
|
|
114
|
+
...(typeof value.resetsAt === 'number' && Number.isFinite(value.resetsAt) ? { resetsAt: value.resetsAt } : {}),
|
|
115
|
+
...(value.limited === true ? { limited: true } : {}),
|
|
116
|
+
...(typeof value.limit === 'string' && value.limit.length <= 80 ? { limit: value.limit } : {}) }];
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export function memoryUsageStore() {
|
|
120
|
+
const readings = new Map();
|
|
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) } : {}) }); } };
|
|
124
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,47 +1,149 @@
|
|
|
1
|
-
export type Provider = 'claude' | 'codex' | 'opencode' | 'zai';
|
|
2
|
-
/**
|
|
1
|
+
export type Provider = 'claude' | 'codex' | 'opencode' | 'zai' | 'copilot' | 'grok' | 'minimax' | 'gemini' | 'kimi';
|
|
2
|
+
/** Host-owned identity makes readings survive token renewal. Tokens never become stored identities. */
|
|
3
|
+
type Identity = {
|
|
4
|
+
accountId?: string;
|
|
5
|
+
};
|
|
3
6
|
export type Source = {
|
|
4
7
|
provider: 'codex';
|
|
5
8
|
bin: string;
|
|
6
9
|
home: string;
|
|
7
10
|
env?: Record<string, string>;
|
|
8
11
|
} | {
|
|
9
|
-
provider: '
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
provider: 'codex';
|
|
13
|
+
access: string;
|
|
14
|
+
accountId: string;
|
|
15
|
+
} | ({
|
|
16
|
+
provider: 'claude';
|
|
17
|
+
access: string;
|
|
18
|
+
accountUuid?: string;
|
|
19
|
+
} & Identity) | ({
|
|
20
|
+
provider: 'claude';
|
|
21
|
+
accountUuid: string;
|
|
22
|
+
read: ClaudeReader;
|
|
23
|
+
origin?: string;
|
|
24
|
+
connected?: () => boolean;
|
|
25
|
+
}) | {
|
|
26
|
+
provider: 'claude';
|
|
27
|
+
credentialsFile: string;
|
|
28
|
+
configFile?: string;
|
|
29
|
+
statuslineFile?: string;
|
|
30
|
+
} | ({
|
|
31
|
+
provider: 'opencode' | 'zai';
|
|
13
32
|
key: string;
|
|
14
|
-
}
|
|
33
|
+
} & Identity) | ({
|
|
34
|
+
provider: 'copilot' | 'grok' | 'minimax' | 'kimi';
|
|
35
|
+
access: string;
|
|
36
|
+
} & Identity) | ({
|
|
37
|
+
provider: 'gemini';
|
|
38
|
+
access: string;
|
|
39
|
+
project?: string;
|
|
40
|
+
} & Identity);
|
|
15
41
|
export type Kind = 'session' | 'weekly' | 'monthly' | 'rolling' | 'custom';
|
|
42
|
+
/** All reset times are epoch milliseconds in 0.2.0. */
|
|
16
43
|
export type Window = {
|
|
17
44
|
provider: Provider;
|
|
18
45
|
kind: Kind;
|
|
19
46
|
limit?: string;
|
|
20
|
-
usedPercent
|
|
47
|
+
usedPercent?: number;
|
|
48
|
+
scope?: Scope;
|
|
21
49
|
minutes?: number;
|
|
22
50
|
resetsAt?: number;
|
|
23
51
|
limited?: boolean;
|
|
24
52
|
};
|
|
25
|
-
export type
|
|
53
|
+
export type Scope = {
|
|
54
|
+
model?: string;
|
|
55
|
+
surface?: string;
|
|
56
|
+
};
|
|
57
|
+
export type Poll = {
|
|
58
|
+
at: number;
|
|
59
|
+
outcome: Code | 'ok';
|
|
60
|
+
retryAt?: number;
|
|
61
|
+
};
|
|
62
|
+
export type Freshness = 'fresh' | 'stale' | 'future' | 'unknown';
|
|
63
|
+
export type Code = 'not-connected' | 'expired' | 'auth' | 'no-plan' | 'rate-limited' | 'unavailable' | 'incomplete' | 'refresh-failed';
|
|
26
64
|
export type Reading = {
|
|
27
65
|
provider: Provider;
|
|
28
66
|
windows: Window[];
|
|
29
|
-
at
|
|
67
|
+
at?: number;
|
|
68
|
+
limited?: boolean;
|
|
69
|
+
poll?: Poll;
|
|
30
70
|
code?: Code;
|
|
31
71
|
};
|
|
72
|
+
export type Room = {
|
|
73
|
+
at?: number;
|
|
74
|
+
ageMs?: number;
|
|
75
|
+
freshness: Freshness;
|
|
76
|
+
poll?: Poll;
|
|
77
|
+
scope?: Scope;
|
|
78
|
+
limited?: boolean;
|
|
79
|
+
} & ({
|
|
80
|
+
left: number;
|
|
81
|
+
span: 'session' | 'week' | 'month' | 'tightest';
|
|
82
|
+
resetsAt?: number;
|
|
83
|
+
} | {
|
|
84
|
+
left: 'unknown';
|
|
85
|
+
});
|
|
32
86
|
export type ReadOptions = {
|
|
33
87
|
nowMs?: number;
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
};
|
|
90
|
+
export type SourceAnswer = {
|
|
91
|
+
raw?: unknown;
|
|
92
|
+
code?: Code;
|
|
93
|
+
retryAfterMs?: number;
|
|
94
|
+
limited?: boolean;
|
|
95
|
+
at?: number;
|
|
96
|
+
};
|
|
97
|
+
/** The app owns credential reads/refresh and sends its own requests through this seam. */
|
|
98
|
+
export type ClaudeReader = (options: {
|
|
99
|
+
nowMs: number;
|
|
100
|
+
signal: AbortSignal;
|
|
101
|
+
}) => Promise<SourceAnswer>;
|
|
102
|
+
export type StoredReading = {
|
|
103
|
+
at?: number;
|
|
104
|
+
windows: Window[];
|
|
105
|
+
limited?: boolean;
|
|
106
|
+
poll?: Poll;
|
|
107
|
+
};
|
|
108
|
+
export interface UsageStore {
|
|
109
|
+
get(provider: Provider, account: string): StoredReading | undefined;
|
|
110
|
+
put(provider: Provider, account: string, reading: StoredReading): void;
|
|
111
|
+
}
|
|
112
|
+
export type BackoffState = {
|
|
113
|
+
untilMs: number;
|
|
114
|
+
at: number;
|
|
115
|
+
outcome: Code;
|
|
116
|
+
failures: number;
|
|
34
117
|
};
|
|
118
|
+
export interface BackoffPolicy {
|
|
119
|
+
get(provider: Provider, account: string): number | BackoffState | undefined;
|
|
120
|
+
set(provider: Provider, account: string, untilMs: number, state?: BackoffState): void;
|
|
121
|
+
/** Retry-After duration to wait; context carries the current outcome and its consecutive failures. */
|
|
122
|
+
delayMs?(retryAfterMs: number | undefined, context: {
|
|
123
|
+
outcome: Code;
|
|
124
|
+
failures: number;
|
|
125
|
+
}): number;
|
|
126
|
+
}
|
|
127
|
+
export type PacingHook = (request: {
|
|
128
|
+
provider: Provider;
|
|
129
|
+
account: string;
|
|
130
|
+
origin: string;
|
|
131
|
+
signal: AbortSignal;
|
|
132
|
+
}) => Promise<void>;
|
|
35
133
|
export type UsageOptions = {
|
|
36
|
-
stateDir
|
|
134
|
+
stateDir?: string;
|
|
135
|
+
store?: UsageStore;
|
|
136
|
+
backoff?: BackoffPolicy;
|
|
37
137
|
salt?: string;
|
|
38
138
|
fetch?: typeof fetch;
|
|
39
139
|
now?: () => number;
|
|
140
|
+
pace?: PacingHook;
|
|
40
141
|
};
|
|
41
142
|
export interface Usage {
|
|
42
143
|
read(source: Source, options?: ReadOptions): Promise<Reading>;
|
|
43
144
|
lastKnown(source: Source, options?: ReadOptions): Reading | undefined;
|
|
44
145
|
connected(source: Source): boolean;
|
|
146
|
+
/** Stable identity fingerprint, absent for an opaque token without a host-supplied id. */
|
|
45
147
|
account(source: Source): string | undefined;
|
|
46
148
|
}
|
|
47
149
|
export declare class UsageError extends Error {
|
|
@@ -49,3 +151,4 @@ export declare class UsageError extends Error {
|
|
|
49
151
|
name: string;
|
|
50
152
|
constructor();
|
|
51
153
|
}
|
|
154
|
+
export {};
|
package/dist/windows.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type { 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
|
+
/** 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. */
|
|
4
7
|
export declare function claudeWindows(raw: unknown): Window[];
|
|
5
8
|
/** Accepts the `usage` member; monthly length is deliberately absent. */
|
|
6
9
|
export declare function goWindows(raw: unknown): Window[];
|