@cortexkit/anthropic-auth-core 1.15.1 → 1.17.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/accounts.d.ts +48 -3
- package/dist/accounts.js +506 -54
- package/dist/cachekeep-registry.d.ts +21 -0
- package/dist/cachekeep-registry.js +124 -0
- package/dist/cachekeep.d.ts +16 -1
- package/dist/cachekeep.js +93 -14
- package/dist/commands/account.d.ts +1 -0
- package/dist/commands/account.js +7 -1
- package/dist/dump.d.ts +19 -0
- package/dist/dump.js +162 -10
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/oauth-profile.d.ts +11 -0
- package/dist/oauth-profile.js +48 -0
- package/dist/quota-headers.d.ts +4 -0
- package/dist/quota-headers.js +66 -0
- package/dist/quota-manager.d.ts +7 -9
- package/dist/quota-manager.js +72 -24
- package/dist/quotas.d.ts +5 -2
- package/dist/quotas.js +35 -5
- package/dist/relay.d.ts +7 -0
- package/dist/relay.js +37 -8
- package/dist/routing.d.ts +3 -1
- package/dist/routing.js +20 -2
- package/dist/sticky-routing.d.ts +86 -0
- package/dist/sticky-routing.js +450 -0
- package/dist/token-fingerprint.d.ts +5 -0
- package/dist/token-fingerprint.js +8 -0
- package/package.json +1 -1
package/dist/dump.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readdir, rename, unlink, writeFile, } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { extractBillingHeaderCCH } from "./cch.js";
|
|
6
|
-
import { isSecretKey, relayLog } from "./logger.js";
|
|
6
|
+
import { isSecretKey, logger, relayLog } from "./logger.js";
|
|
7
7
|
export const CLAUDE_DUMP_COMMAND_NAME = 'claude-dump';
|
|
8
8
|
const DUMP_STATUS_TITLE = '## Claude Dump Status';
|
|
9
9
|
const DUMP_ENABLED_TITLE = '## Claude Dump Enabled';
|
|
@@ -11,9 +11,21 @@ const DUMP_DISABLED_TITLE = '## Claude Dump Disabled';
|
|
|
11
11
|
const DUMP_USAGE_TITLE = '## Claude Dump Usage';
|
|
12
12
|
const DUMP_USAGE = 'Usage: `/claude-dump`, `/claude-dump on`, or `/claude-dump off`.';
|
|
13
13
|
const DUMP_DIR_ENV = 'OPENCODE_ANTHROPIC_AUTH_DUMP_DIR';
|
|
14
|
+
const DUMP_MAX_BYTES_ENV = 'OPENCODE_ANTHROPIC_AUTH_DUMP_MAX_BYTES';
|
|
14
15
|
const DEFAULT_DUMP_DIR = join(tmpdir(), 'opencode-anthropic-auth-dumps');
|
|
16
|
+
const DEFAULT_DUMP_MAX_BYTES = 512 * 1024 * 1024;
|
|
17
|
+
const DUMP_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
18
|
+
const DUMP_SWEEP_NEWNESS_FLOOR_MS = 60 * 1000;
|
|
19
|
+
const DUMP_PARTIAL_STALE_MS = 10 * 60 * 1000;
|
|
20
|
+
const DUMP_ARTIFACT_SUFFIX_PATTERN = /\.(body|meta|relay|request)\.json$/;
|
|
21
|
+
// Earlier builds emitted five-digit counters; current counters grow without truncation.
|
|
22
|
+
const DUMP_ARTIFACT_ID_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d{5,}-(.+)$/;
|
|
23
|
+
const DUMP_SEGMENT_PATTERN = '[a-zA-Z0-9._-]';
|
|
24
|
+
const DIRECT_DUMP_PATTERN = new RegExp(`^${DUMP_SEGMENT_PATTERN}{1,80}-direct(?:-${DUMP_SEGMENT_PATTERN}{1,80})?$`);
|
|
25
|
+
const RELAY_DUMP_PATTERN = new RegExp(`^${DUMP_SEGMENT_PATTERN}{1,80}-(?:http|websocket)-p[12]-(?:full_sync|patch)$`);
|
|
15
26
|
let dumpEnabled = false;
|
|
16
27
|
let nextDumpId = 0;
|
|
28
|
+
let lastDumpSweepAt = 0;
|
|
17
29
|
const DIRECT_DUMP_PREVIOUS_BODY_LIMIT = 100;
|
|
18
30
|
const directDumpPreviousBodies = new Map();
|
|
19
31
|
export function isDumpEnabled() {
|
|
@@ -25,11 +37,150 @@ export function setDumpEnabled(enabled) {
|
|
|
25
37
|
export function resetDumpState() {
|
|
26
38
|
dumpEnabled = false;
|
|
27
39
|
nextDumpId = 0;
|
|
40
|
+
lastDumpSweepAt = 0;
|
|
28
41
|
directDumpPreviousBodies.clear();
|
|
29
42
|
}
|
|
30
43
|
export function getDumpDirectory() {
|
|
31
44
|
return process.env[DUMP_DIR_ENV] || DEFAULT_DUMP_DIR;
|
|
32
45
|
}
|
|
46
|
+
function getDumpMaxBytes() {
|
|
47
|
+
const configured = Number(process.env[DUMP_MAX_BYTES_ENV]);
|
|
48
|
+
return Number.isFinite(configured) && configured >= 0
|
|
49
|
+
? Math.floor(configured)
|
|
50
|
+
: DEFAULT_DUMP_MAX_BYTES;
|
|
51
|
+
}
|
|
52
|
+
function isConfiguredDumpDirectory(path) {
|
|
53
|
+
return resolve(path) === resolve(getDumpDirectory());
|
|
54
|
+
}
|
|
55
|
+
function isDumpArtifactFileName(name) {
|
|
56
|
+
const stem = name.replace(DUMP_ARTIFACT_SUFFIX_PATTERN, '');
|
|
57
|
+
if (stem === name)
|
|
58
|
+
return false;
|
|
59
|
+
const match = DUMP_ARTIFACT_ID_PATTERN.exec(stem);
|
|
60
|
+
const requestPath = match?.[1];
|
|
61
|
+
if (!requestPath)
|
|
62
|
+
return false;
|
|
63
|
+
return (DIRECT_DUMP_PATTERN.test(requestPath) ||
|
|
64
|
+
RELAY_DUMP_PATTERN.test(requestPath));
|
|
65
|
+
}
|
|
66
|
+
function isDumpPartialFileName(name) {
|
|
67
|
+
if (!name.endsWith('.partial'))
|
|
68
|
+
return false;
|
|
69
|
+
const partialStem = name.slice(0, -8);
|
|
70
|
+
if (isDumpArtifactFileName(partialStem))
|
|
71
|
+
return true;
|
|
72
|
+
const separator = partialStem.lastIndexOf('.');
|
|
73
|
+
if (separator < 0)
|
|
74
|
+
return false;
|
|
75
|
+
const nonce = partialStem.slice(separator + 1);
|
|
76
|
+
return (/^[a-f0-9]{24}$/.test(nonce) &&
|
|
77
|
+
isDumpArtifactFileName(partialStem.slice(0, separator)));
|
|
78
|
+
}
|
|
79
|
+
export async function writeDumpFile(path, contents) {
|
|
80
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
81
|
+
const nonce = randomBytes(12).toString('hex');
|
|
82
|
+
const partialPath = `${path}.${nonce}.partial`;
|
|
83
|
+
let created = false;
|
|
84
|
+
try {
|
|
85
|
+
await writeFile(partialPath, contents, { encoding: 'utf8', flag: 'wx' });
|
|
86
|
+
created = true;
|
|
87
|
+
await rename(partialPath, path);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === 'EEXIST')
|
|
92
|
+
continue;
|
|
93
|
+
if (created)
|
|
94
|
+
await unlink(partialPath).catch(() => { });
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Caps completed dump artifacts in the configured directory. A zero-byte cap
|
|
102
|
+
* disables sweeping. Custom directories may contain unrelated files: only
|
|
103
|
+
* recognized final artifact names and stale partials are eligible. Symlinks,
|
|
104
|
+
* fresh partial writes, and files younger than the newness floor remain
|
|
105
|
+
* protected.
|
|
106
|
+
*/
|
|
107
|
+
export async function sweepDumpDirectory(options) {
|
|
108
|
+
const dumpDir = options.dumpDir ?? getDumpDirectory();
|
|
109
|
+
const maxBytes = options.maxBytes ?? getDumpMaxBytes();
|
|
110
|
+
const now = options.now ?? Date.now();
|
|
111
|
+
const minAgeMs = options.minAgeMs ?? DUMP_SWEEP_NEWNESS_FLOOR_MS;
|
|
112
|
+
const partialStaleMs = options.partialStaleMs ?? DUMP_PARTIAL_STALE_MS;
|
|
113
|
+
const emptyResult = { removed: 0, freedBytes: 0 };
|
|
114
|
+
if (!isConfiguredDumpDirectory(dumpDir) || maxBytes <= 0)
|
|
115
|
+
return emptyResult;
|
|
116
|
+
try {
|
|
117
|
+
const dumpDirStats = await lstat(dumpDir);
|
|
118
|
+
if (dumpDirStats.isSymbolicLink())
|
|
119
|
+
return emptyResult;
|
|
120
|
+
const protectedPaths = new Set((options.protectedPaths ?? []).map((path) => resolve(path)));
|
|
121
|
+
const entries = await readdir(dumpDir, { withFileTypes: true });
|
|
122
|
+
const files = [];
|
|
123
|
+
await Promise.all(entries.map(async (entry) => {
|
|
124
|
+
if (!entry.isFile())
|
|
125
|
+
return;
|
|
126
|
+
const partial = isDumpPartialFileName(entry.name);
|
|
127
|
+
if (!partial && !isDumpArtifactFileName(entry.name))
|
|
128
|
+
return;
|
|
129
|
+
const path = join(dumpDir, entry.name);
|
|
130
|
+
try {
|
|
131
|
+
const stats = await lstat(path);
|
|
132
|
+
if (!stats.isFile() || stats.isSymbolicLink())
|
|
133
|
+
return;
|
|
134
|
+
files.push({
|
|
135
|
+
path,
|
|
136
|
+
size: stats.size,
|
|
137
|
+
mtimeMs: stats.mtimeMs,
|
|
138
|
+
partial,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Files can disappear while concurrent dump requests finish.
|
|
143
|
+
}
|
|
144
|
+
}));
|
|
145
|
+
let totalBytes = files.reduce((total, file) => total + file.size, 0);
|
|
146
|
+
files.sort((left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path));
|
|
147
|
+
let removed = 0;
|
|
148
|
+
let freedBytes = 0;
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
if (protectedPaths.has(resolve(file.path)))
|
|
151
|
+
continue;
|
|
152
|
+
const ageMs = now - file.mtimeMs;
|
|
153
|
+
const stalePartial = file.partial && ageMs >= partialStaleMs;
|
|
154
|
+
if (!stalePartial && totalBytes <= maxBytes)
|
|
155
|
+
continue;
|
|
156
|
+
if (file.partial ? !stalePartial : ageMs < minAgeMs)
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
await unlink(file.path);
|
|
160
|
+
totalBytes -= file.size;
|
|
161
|
+
freedBytes += file.size;
|
|
162
|
+
removed += 1;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Dump cleanup is best-effort and must not affect request handling.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (removed > 0) {
|
|
169
|
+
logger.debug('dump', 'removed old dump files', { removed, freedBytes });
|
|
170
|
+
}
|
|
171
|
+
return { removed, freedBytes };
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return emptyResult;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function scheduleDumpSweep(dumpDir, protectedPaths) {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (now - lastDumpSweepAt < DUMP_SWEEP_INTERVAL_MS)
|
|
180
|
+
return;
|
|
181
|
+
lastDumpSweepAt = now;
|
|
182
|
+
void sweepDumpDirectory({ dumpDir, protectedPaths }).catch(() => { });
|
|
183
|
+
}
|
|
33
184
|
export function parseDumpCommandAction(argumentsText) {
|
|
34
185
|
const normalized = argumentsText.trim().split(/\s+/).filter(Boolean);
|
|
35
186
|
if (normalized.length === 0)
|
|
@@ -228,7 +379,7 @@ async function dumpRequest(input) {
|
|
|
228
379
|
return;
|
|
229
380
|
nextDumpId += 1;
|
|
230
381
|
const affinity = input.affinity?.trim() || 'session-unknown';
|
|
231
|
-
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(nextDumpId).padStart(
|
|
382
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(nextDumpId).padStart(6, '0')}-${dumpFileSessionSegment(affinity)}-${dumpRequestSegment(input)}`;
|
|
232
383
|
const dumpDir = getDumpDirectory();
|
|
233
384
|
const prefix = join(dumpDir, id);
|
|
234
385
|
const files = {
|
|
@@ -259,19 +410,20 @@ async function dumpRequest(input) {
|
|
|
259
410
|
files,
|
|
260
411
|
};
|
|
261
412
|
const writes = [
|
|
262
|
-
|
|
263
|
-
|
|
413
|
+
writeDumpFile(files.body, input.bodyText),
|
|
414
|
+
writeDumpFile(files.metadata, `${JSON.stringify(metadata, null, 2)}\n`),
|
|
264
415
|
];
|
|
265
416
|
if (input.payload !== undefined && files.relay) {
|
|
266
|
-
writes.push(
|
|
417
|
+
writes.push(writeDumpFile(files.relay, `${JSON.stringify(redactForDump(input.payload), null, 2)}\n`));
|
|
267
418
|
}
|
|
268
419
|
if (input.request !== undefined && files.request) {
|
|
269
|
-
writes.push(
|
|
420
|
+
writes.push(writeDumpFile(files.request, `${JSON.stringify(redactForDump({
|
|
270
421
|
...input.request,
|
|
271
422
|
headers: headersToRecord(input.request.headers),
|
|
272
|
-
}), null, 2)}\n
|
|
423
|
+
}), null, 2)}\n`));
|
|
273
424
|
}
|
|
274
425
|
await Promise.all(writes);
|
|
426
|
+
scheduleDumpSweep(dumpDir, Object.values(files));
|
|
275
427
|
relayLog(`dumped request id=${id} session=${shortAffinity(affinity)} body=${files.body} meta=${files.metadata}`);
|
|
276
428
|
}
|
|
277
429
|
catch (error) {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './accounts.ts';
|
|
|
2
2
|
export * from './auth.ts';
|
|
3
3
|
export * from './cache1h.ts';
|
|
4
4
|
export * from './cachekeep.ts';
|
|
5
|
+
export * from './cachekeep-registry.ts';
|
|
5
6
|
export * from './cch.ts';
|
|
6
7
|
export * from './claude-code.ts';
|
|
7
8
|
export * from './commands/account.ts';
|
|
@@ -12,9 +13,12 @@ export * from './killswitch.ts';
|
|
|
12
13
|
export * from './logger.ts';
|
|
13
14
|
export * from './logging.ts';
|
|
14
15
|
export * from './models.ts';
|
|
16
|
+
export * from './oauth-profile.ts';
|
|
15
17
|
export * from './pkce.ts';
|
|
16
18
|
export * from './provider.ts';
|
|
19
|
+
export * from './quota-headers.ts';
|
|
17
20
|
export * from './quota-manager.ts';
|
|
18
21
|
export * from './quotas.ts';
|
|
19
22
|
export * from './relay.ts';
|
|
20
23
|
export * from './routing.ts';
|
|
24
|
+
export * from './sticky-routing.ts';
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./accounts.js";
|
|
|
2
2
|
export * from "./auth.js";
|
|
3
3
|
export * from "./cache1h.js";
|
|
4
4
|
export * from "./cachekeep.js";
|
|
5
|
+
export * from "./cachekeep-registry.js";
|
|
5
6
|
export * from "./cch.js";
|
|
6
7
|
export * from "./claude-code.js";
|
|
7
8
|
export * from "./commands/account.js";
|
|
@@ -12,9 +13,12 @@ export * from "./killswitch.js";
|
|
|
12
13
|
export * from "./logger.js";
|
|
13
14
|
export * from "./logging.js";
|
|
14
15
|
export * from "./models.js";
|
|
16
|
+
export * from "./oauth-profile.js";
|
|
15
17
|
export * from "./pkce.js";
|
|
16
18
|
export * from "./provider.js";
|
|
19
|
+
export * from "./quota-headers.js";
|
|
17
20
|
export * from "./quota-manager.js";
|
|
18
21
|
export * from "./quotas.js";
|
|
19
22
|
export * from "./relay.js";
|
|
20
23
|
export * from "./routing.js";
|
|
24
|
+
export * from "./sticky-routing.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OAuthAccountProfile } from './accounts.ts';
|
|
2
|
+
export declare const PROFILE_TTL_MS: number;
|
|
3
|
+
export declare function fetchOAuthAccountProfile(input: {
|
|
4
|
+
accessToken: string;
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}): Promise<OAuthAccountProfile>;
|
|
9
|
+
export declare function oauthProfileMatchesToken(profile: OAuthAccountProfile | undefined, accessToken: string): boolean;
|
|
10
|
+
export declare function oauthProfileIsFresh(profile: OAuthAccountProfile | undefined, now?: number): boolean;
|
|
11
|
+
export declare function formatOAuthAccountTier(profile: OAuthAccountProfile | undefined): string | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { tokenFingerprint } from "./quota-manager.js";
|
|
2
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
3
|
+
export const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
export async function fetchOAuthAccountProfile(input) {
|
|
5
|
+
const response = await (input.fetchImpl ?? fetch)(PROFILE_URL, {
|
|
6
|
+
method: 'GET',
|
|
7
|
+
signal: input.signal,
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${input.accessToken}`,
|
|
10
|
+
Accept: 'application/json',
|
|
11
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new Error(`Claude profile check failed: ${response.status}`);
|
|
16
|
+
}
|
|
17
|
+
const value = (await response.json());
|
|
18
|
+
const tier = value.organization?.rate_limit_tier;
|
|
19
|
+
const orgType = value.organization?.organization_type;
|
|
20
|
+
if (typeof tier !== 'string' ||
|
|
21
|
+
tier.trim() === '' ||
|
|
22
|
+
typeof orgType !== 'string' ||
|
|
23
|
+
orgType.trim() === '') {
|
|
24
|
+
throw new Error('Claude profile response is missing account metadata');
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
tier,
|
|
28
|
+
orgType,
|
|
29
|
+
checkedAt: input.now?.() ?? Date.now(),
|
|
30
|
+
tokenFingerprint: tokenFingerprint(input.accessToken),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function oauthProfileMatchesToken(profile, accessToken) {
|
|
34
|
+
return profile?.tokenFingerprint === tokenFingerprint(accessToken);
|
|
35
|
+
}
|
|
36
|
+
export function oauthProfileIsFresh(profile, now = Date.now()) {
|
|
37
|
+
return Boolean(profile &&
|
|
38
|
+
Number.isFinite(profile.checkedAt) &&
|
|
39
|
+
now >= profile.checkedAt &&
|
|
40
|
+
now - profile.checkedAt < PROFILE_TTL_MS);
|
|
41
|
+
}
|
|
42
|
+
export function formatOAuthAccountTier(profile) {
|
|
43
|
+
const match = profile?.tier.match(/^default_claude_max_(\d+)x$/);
|
|
44
|
+
if (!match)
|
|
45
|
+
return undefined;
|
|
46
|
+
const label = `Max ${match[1]}x`;
|
|
47
|
+
return profile?.orgType === 'claude_team' ? `Team · ${label}` : label;
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OAuthQuotaSnapshot } from './accounts.ts';
|
|
2
|
+
export declare function isQuotaBearingHeaderFrame(headers: Headers): boolean;
|
|
3
|
+
export declare function normalizeQuotaHeaders(headers: Headers, now?: number): OAuthQuotaSnapshot;
|
|
4
|
+
export declare function mergeHeaderQuotaSnapshot(existing: OAuthQuotaSnapshot | undefined, incoming: OAuthQuotaSnapshot): OAuthQuotaSnapshot;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const PREFIX = 'anthropic-ratelimit-unified-';
|
|
2
|
+
const WINDOW_KEYS = {
|
|
3
|
+
'5h': 'five_hour',
|
|
4
|
+
'7d': 'seven_day',
|
|
5
|
+
};
|
|
6
|
+
export function isQuotaBearingHeaderFrame(headers) {
|
|
7
|
+
return Object.keys(WINDOW_KEYS).some((suffix) => Number.isFinite(finiteHeaderNumber(headers, `${PREFIX}${suffix}-utilization`)));
|
|
8
|
+
}
|
|
9
|
+
function finiteHeaderNumber(headers, name) {
|
|
10
|
+
const value = headers.get(name);
|
|
11
|
+
if (value == null || value.trim() === '')
|
|
12
|
+
return undefined;
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
15
|
+
}
|
|
16
|
+
function normalizeWindow(headers, suffix, checkedAt) {
|
|
17
|
+
const utilization = finiteHeaderNumber(headers, `${PREFIX}${suffix}-utilization`);
|
|
18
|
+
if (utilization == null)
|
|
19
|
+
return undefined;
|
|
20
|
+
const usedPercent = Math.min(100, Math.max(0, Math.round(utilization * 100)));
|
|
21
|
+
const resetSeconds = finiteHeaderNumber(headers, `${PREFIX}${suffix}-reset`);
|
|
22
|
+
const resetDate = resetSeconds == null ? undefined : new Date(resetSeconds * 1000);
|
|
23
|
+
const resetsAt = resetDate && Number.isFinite(resetDate.getTime())
|
|
24
|
+
? resetDate.toISOString()
|
|
25
|
+
: undefined;
|
|
26
|
+
return {
|
|
27
|
+
usedPercent,
|
|
28
|
+
remainingPercent: 100 - usedPercent,
|
|
29
|
+
...(resetsAt && { resetsAt }),
|
|
30
|
+
checkedAt,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function normalizeQuotaHeaders(headers, now = Date.now()) {
|
|
34
|
+
const snapshot = {
|
|
35
|
+
fallbackAdvised: headers.get(`${PREFIX}fallback`) === 'available',
|
|
36
|
+
source: 'headers',
|
|
37
|
+
checkedAt: now,
|
|
38
|
+
};
|
|
39
|
+
for (const [suffix, key] of Object.entries(WINDOW_KEYS)) {
|
|
40
|
+
const window = normalizeWindow(headers, suffix, now);
|
|
41
|
+
if (window)
|
|
42
|
+
snapshot[key] = window;
|
|
43
|
+
}
|
|
44
|
+
const representativeClaim = headers.get(`${PREFIX}representative-claim`);
|
|
45
|
+
if (representativeClaim) {
|
|
46
|
+
snapshot.bindingWindow = representativeClaim;
|
|
47
|
+
snapshot.bindingWindowSource = 'headers';
|
|
48
|
+
}
|
|
49
|
+
return snapshot;
|
|
50
|
+
}
|
|
51
|
+
export function mergeHeaderQuotaSnapshot(existing, incoming) {
|
|
52
|
+
return {
|
|
53
|
+
...existing,
|
|
54
|
+
...incoming,
|
|
55
|
+
scoped: existing && 'scoped' in existing ? existing.scoped : undefined,
|
|
56
|
+
extraUsage: existing?.extraUsage,
|
|
57
|
+
bindingWindow: existing?.bindingWindowSource === 'poll'
|
|
58
|
+
? existing.bindingWindow
|
|
59
|
+
: (incoming.bindingWindow ?? existing?.bindingWindow),
|
|
60
|
+
bindingWindowSource: existing?.bindingWindowSource === 'poll'
|
|
61
|
+
? 'poll'
|
|
62
|
+
: (incoming.bindingWindowSource ?? existing?.bindingWindowSource),
|
|
63
|
+
// Source tracks 5h/7d freshness; preserved scoped and credit fields remain poll-owned.
|
|
64
|
+
source: 'headers',
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/quota-manager.d.ts
CHANGED
|
@@ -6,12 +6,7 @@
|
|
|
6
6
|
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
7
|
*/
|
|
8
8
|
import type { AccountOperationError, AccountStorage, OAuthAccount, OAuthQuotaSnapshot } from './accounts.ts';
|
|
9
|
-
|
|
10
|
-
* Stable, non-reversible fingerprint of an access token. Used to detect a
|
|
11
|
-
* main-account switch so a different account's persisted/cached quota is never
|
|
12
|
-
* reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
|
|
13
|
-
*/
|
|
14
|
-
export declare function tokenFingerprint(token: string): string;
|
|
9
|
+
export { tokenFingerprint } from './token-fingerprint.ts';
|
|
15
10
|
export type QuotaEntry = {
|
|
16
11
|
quota: OAuthQuotaSnapshot;
|
|
17
12
|
refreshAfter: number;
|
|
@@ -60,6 +55,8 @@ export declare class QuotaManager {
|
|
|
60
55
|
getAllFallbacks(): Map<string, QuotaEntry>;
|
|
61
56
|
setMain(accessToken: string, entry: QuotaEntry): void;
|
|
62
57
|
setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void;
|
|
58
|
+
pushMainFromHeaders(accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
|
|
59
|
+
pushFallbackFromHeaders(accountId: string, accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
|
|
63
60
|
refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
64
61
|
refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
65
62
|
refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
|
|
@@ -67,14 +64,15 @@ export declare class QuotaManager {
|
|
|
67
64
|
* Fire-and-forget refresh. Does not await, swallows errors.
|
|
68
65
|
*/
|
|
69
66
|
refreshMainInBackground(accessToken: string): void;
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
private scopedWindowIsStale;
|
|
68
|
+
isMainStale(modelId?: string): boolean;
|
|
69
|
+
isFallbackStale(accountId: string, accessToken?: string, modelId?: string): boolean;
|
|
72
70
|
shouldRefreshOnRequestCount(requestCount: number): boolean;
|
|
73
71
|
/**
|
|
74
72
|
* Combined check: should a refresh happen right now?
|
|
75
73
|
* True if main is stale by time OR triggered by request count.
|
|
76
74
|
*/
|
|
77
|
-
needsRefresh(requestCount: number): boolean;
|
|
75
|
+
needsRefresh(requestCount: number, modelId?: string): boolean;
|
|
78
76
|
updateStorage(storage: AccountStorage | null): void;
|
|
79
77
|
/**
|
|
80
78
|
* Seed/update the main quota cache from persisted state. This is deliberately
|
package/dist/quota-manager.js
CHANGED
|
@@ -5,17 +5,32 @@
|
|
|
5
5
|
* share one QuotaManager instance so they see the same in-memory cache.
|
|
6
6
|
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, quotaBackoffActive, } from "./accounts.js";
|
|
9
|
+
import { mergeHeaderQuotaSnapshot } from "./quota-headers.js";
|
|
10
|
+
export { tokenFingerprint } from "./token-fingerprint.js";
|
|
11
|
+
import { tokenFingerprint } from "./token-fingerprint.js";
|
|
10
12
|
// Capture real setTimeout before tests can mock globalThis.setTimeout
|
|
11
13
|
const nativeSetTimeout = globalThis.setTimeout;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
function mergePollCompletionWithNewerHeaders(current, polled) {
|
|
15
|
+
if (current?.source !== 'headers')
|
|
16
|
+
return polled;
|
|
17
|
+
const fiveHourIsNewer = Boolean(current.five_hour &&
|
|
18
|
+
current.five_hour.checkedAt > (polled.five_hour?.checkedAt ?? 0));
|
|
19
|
+
const sevenDayIsNewer = Boolean(current.seven_day &&
|
|
20
|
+
current.seven_day.checkedAt > (polled.seven_day?.checkedAt ?? 0));
|
|
21
|
+
if (!fiveHourIsNewer && !sevenDayIsNewer)
|
|
22
|
+
return polled;
|
|
23
|
+
return mergeHeaderQuotaSnapshot(polled, {
|
|
24
|
+
...(fiveHourIsNewer && { five_hour: current.five_hour }),
|
|
25
|
+
...(sevenDayIsNewer && { seven_day: current.seven_day }),
|
|
26
|
+
fallbackAdvised: current.fallbackAdvised,
|
|
27
|
+
...(current.bindingWindowSource === 'headers' && {
|
|
28
|
+
bindingWindow: current.bindingWindow,
|
|
29
|
+
bindingWindowSource: current.bindingWindowSource,
|
|
30
|
+
}),
|
|
31
|
+
source: 'headers',
|
|
32
|
+
checkedAt: Math.max(fiveHourIsNewer ? (current.five_hour?.checkedAt ?? 0) : 0, sevenDayIsNewer ? (current.seven_day?.checkedAt ?? 0) : 0),
|
|
33
|
+
});
|
|
19
34
|
}
|
|
20
35
|
// ---------------------------------------------------------------------------
|
|
21
36
|
// Class
|
|
@@ -116,6 +131,29 @@ export class QuotaManager {
|
|
|
116
131
|
this.fallbackTokenFps.delete(accountId);
|
|
117
132
|
}
|
|
118
133
|
}
|
|
134
|
+
pushMainFromHeaders(accessToken, incoming) {
|
|
135
|
+
const checkedAt = incoming.checkedAt ?? this.now();
|
|
136
|
+
const accessTokenFp = tokenFingerprint(accessToken);
|
|
137
|
+
const quota = mergeHeaderQuotaSnapshot(this.mainTokenFp === accessTokenFp ? this.main?.quota : undefined, incoming);
|
|
138
|
+
const entry = {
|
|
139
|
+
quota,
|
|
140
|
+
checkedAt,
|
|
141
|
+
refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
|
|
142
|
+
};
|
|
143
|
+
this.setMain(accessToken, entry);
|
|
144
|
+
return entry;
|
|
145
|
+
}
|
|
146
|
+
pushFallbackFromHeaders(accountId, accessToken, incoming) {
|
|
147
|
+
const checkedAt = incoming.checkedAt ?? this.now();
|
|
148
|
+
const quota = mergeHeaderQuotaSnapshot(this.getFallback(accountId, accessToken)?.quota, incoming);
|
|
149
|
+
const entry = {
|
|
150
|
+
quota,
|
|
151
|
+
checkedAt,
|
|
152
|
+
refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
|
|
153
|
+
};
|
|
154
|
+
this.setFallback(accountId, entry, accessToken);
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
119
157
|
// =========================================================================
|
|
120
158
|
// Refresh (async, deduplicated, rate-limited)
|
|
121
159
|
// =========================================================================
|
|
@@ -190,17 +228,24 @@ export class QuotaManager {
|
|
|
190
228
|
// =========================================================================
|
|
191
229
|
// Staleness queries
|
|
192
230
|
// =========================================================================
|
|
193
|
-
|
|
231
|
+
scopedWindowIsStale(entry, modelId) {
|
|
232
|
+
const scoped = getScopedQuotaWindowForModel(entry.quota, modelId);
|
|
233
|
+
return Boolean(scoped &&
|
|
234
|
+
this.now() - scoped.checkedAt >= getQuotaCheckIntervalMs(this.storage));
|
|
235
|
+
}
|
|
236
|
+
isMainStale(modelId) {
|
|
194
237
|
if (!this.main)
|
|
195
238
|
return true;
|
|
196
|
-
return this.now() >= this.main.refreshAfter
|
|
239
|
+
return (this.now() >= this.main.refreshAfter ||
|
|
240
|
+
this.scopedWindowIsStale(this.main, modelId));
|
|
197
241
|
}
|
|
198
|
-
isFallbackStale(accountId, accessToken) {
|
|
242
|
+
isFallbackStale(accountId, accessToken, modelId) {
|
|
199
243
|
// Token-aware: a credential change invalidates the entry (treated as stale).
|
|
200
244
|
const entry = this.getFallback(accountId, accessToken);
|
|
201
245
|
if (!entry)
|
|
202
246
|
return true;
|
|
203
|
-
return this.now() >= entry.refreshAfter
|
|
247
|
+
return (this.now() >= entry.refreshAfter ||
|
|
248
|
+
this.scopedWindowIsStale(entry, modelId));
|
|
204
249
|
}
|
|
205
250
|
shouldRefreshOnRequestCount(requestCount) {
|
|
206
251
|
const everyN = getQuotaRefreshEveryNRequests(this.storage);
|
|
@@ -212,8 +257,9 @@ export class QuotaManager {
|
|
|
212
257
|
* Combined check: should a refresh happen right now?
|
|
213
258
|
* True if main is stale by time OR triggered by request count.
|
|
214
259
|
*/
|
|
215
|
-
needsRefresh(requestCount) {
|
|
216
|
-
return this.isMainStale() ||
|
|
260
|
+
needsRefresh(requestCount, modelId) {
|
|
261
|
+
return (this.isMainStale(modelId) ||
|
|
262
|
+
this.shouldRefreshOnRequestCount(requestCount));
|
|
217
263
|
}
|
|
218
264
|
// =========================================================================
|
|
219
265
|
// Config
|
|
@@ -372,15 +418,16 @@ export class QuotaManager {
|
|
|
372
418
|
now: this.now,
|
|
373
419
|
});
|
|
374
420
|
const now = this.now();
|
|
421
|
+
const completedQuota = mergePollCompletionWithNewerHeaders(this.mainTokenFp === thisFetchFp ? this.main?.quota : undefined, quota);
|
|
375
422
|
this.mainTokenFp = tokenFingerprint(accessToken);
|
|
376
423
|
this.main = {
|
|
377
|
-
quota,
|
|
378
|
-
refreshAfter: getQuotaNextRefreshAt(
|
|
379
|
-
checkedAt: now,
|
|
424
|
+
quota: completedQuota,
|
|
425
|
+
refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
|
|
426
|
+
checkedAt: completedQuota.checkedAt ?? now,
|
|
380
427
|
};
|
|
381
428
|
this.mainLastApiError = undefined;
|
|
382
|
-
this.onMainQuotaFetched?.(
|
|
383
|
-
return
|
|
429
|
+
this.onMainQuotaFetched?.(completedQuota, now, this.mainTokenFp, fetchStartedAt);
|
|
430
|
+
return completedQuota;
|
|
384
431
|
}
|
|
385
432
|
catch (error) {
|
|
386
433
|
this._handleMainFetchError(error);
|
|
@@ -425,14 +472,15 @@ export class QuotaManager {
|
|
|
425
472
|
now: this.now,
|
|
426
473
|
});
|
|
427
474
|
const now = this.now();
|
|
475
|
+
const completedQuota = mergePollCompletionWithNewerHeaders(this.getFallback(accountId, accessToken)?.quota, quota);
|
|
428
476
|
this.setFallback(accountId, {
|
|
429
|
-
quota,
|
|
430
|
-
refreshAfter:
|
|
431
|
-
checkedAt: now,
|
|
477
|
+
quota: completedQuota,
|
|
478
|
+
refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
|
|
479
|
+
checkedAt: completedQuota.checkedAt ?? now,
|
|
432
480
|
}, accessToken);
|
|
433
481
|
this.fallbackApiErrors.delete(accountId);
|
|
434
482
|
this.fallbackErrorTokenFps.delete(accountId);
|
|
435
|
-
return
|
|
483
|
+
return completedQuota;
|
|
436
484
|
}
|
|
437
485
|
finally {
|
|
438
486
|
await fileLock.release();
|
package/dist/quotas.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountStorage, OAuthQuotaSnapshot } from './accounts.ts';
|
|
1
|
+
import type { AccountStorage, OAuthQuotaSnapshot, QuotaMoney } from './accounts.ts';
|
|
2
2
|
export declare const CLAUDE_QUOTAS_COMMAND_NAME = "claude-quota";
|
|
3
3
|
export type QuotaAccountSummary = {
|
|
4
4
|
name: string;
|
|
@@ -7,14 +7,17 @@ export type QuotaAccountSummary = {
|
|
|
7
7
|
quota?: OAuthQuotaSnapshot;
|
|
8
8
|
lastRefreshedAt?: number;
|
|
9
9
|
error?: string;
|
|
10
|
+
tierLabel?: string;
|
|
10
11
|
};
|
|
12
|
+
export declare function formatQuotaMoney(money: QuotaMoney): string;
|
|
11
13
|
export declare function buildFallbackQuotaSummaries(storage: AccountStorage | null, errors?: ReadonlyMap<string, string>): {
|
|
12
14
|
error?: string | undefined;
|
|
15
|
+
lastRefreshedAt: number | undefined;
|
|
16
|
+
tierLabel?: string | undefined;
|
|
13
17
|
name: string;
|
|
14
18
|
role: "fallback";
|
|
15
19
|
enabled: boolean;
|
|
16
20
|
quota: OAuthQuotaSnapshot | undefined;
|
|
17
|
-
lastRefreshedAt: number | undefined;
|
|
18
21
|
}[];
|
|
19
22
|
export declare function buildClaudeQuotaSummary(input: {
|
|
20
23
|
accounts: QuotaAccountSummary[];
|