@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/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(5, '0')}-${dumpFileSessionSegment(affinity)}-${dumpRequestSegment(input)}`;
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
- writeFile(files.body, input.bodyText, 'utf8'),
263
- writeFile(files.metadata, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'),
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(writeFile(files.relay, `${JSON.stringify(redactForDump(input.payload), null, 2)}\n`, 'utf8'));
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(writeFile(files.request, `${JSON.stringify(redactForDump({
420
+ writes.push(writeDumpFile(files.request, `${JSON.stringify(redactForDump({
270
421
  ...input.request,
271
422
  headers: headersToRecord(input.request.headers),
272
- }), null, 2)}\n`, 'utf8'));
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
+ }
@@ -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
- isMainStale(): boolean;
71
- isFallbackStale(accountId: string, accessToken?: string): boolean;
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
@@ -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 { createHash } from 'node:crypto';
9
- import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, isQuotaPolicyAuthError, quotaBackoffActive, } from "./accounts.js";
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
- * Stable, non-reversible fingerprint of an access token. Used to detect a
14
- * main-account switch so a different account's persisted/cached quota is never
15
- * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
16
- */
17
- export function tokenFingerprint(token) {
18
- return createHash('sha256').update(token).digest('hex').slice(0, 16);
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
- isMainStale() {
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() || this.shouldRefreshOnRequestCount(requestCount);
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(quota, this.storage, now),
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?.(quota, now, this.mainTokenFp, fetchStartedAt);
383
- return quota;
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: now + getQuotaCheckIntervalMs(this.storage),
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 quota;
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[];