@cortexkit/anthropic-auth-core 1.15.0 → 1.16.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.
@@ -0,0 +1,450 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { acquireRefreshFileLock, getKillswitchThresholdsForAccount, getQuotaCheckIntervalMs, getQuotaMinimumRemainingThresholds, getScopedQuotaWindowForModel, isKillswitchEnabled, } from "./accounts.js";
5
+ import { isClaudeFableOrMythos5Model } from "./models.js";
6
+ export const STICKY_ROUTING_MAIN_ACCOUNT_ID = 'main';
7
+ export const STICKY_ROUTING_SHORT_RESET_GRACE_MS = 15 * 60_000;
8
+ export const STICKY_ROUTING_ASSIGNMENT_TTL_MS = 7 * 24 * 60 * 60_000;
9
+ const STICKY_ROUTING_TOUCH_INTERVAL_MS = 60 * 60_000;
10
+ const STICKY_ROUTING_LOCK_TTL_MS = 10_000;
11
+ const STICKY_ROUTING_LOCK_WAIT_MS = 5_000;
12
+ const STICKY_ROUTING_LOCK_POLL_MS = 10;
13
+ const STICKY_ROUTING_MAX_ASSIGNMENTS = 4_096;
14
+ const MIN_WEIGHT = 0.000_001;
15
+ function isRecord(value) {
16
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
17
+ }
18
+ function finiteNumber(value) {
19
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
20
+ }
21
+ function normalizeAssignment(value) {
22
+ if (!isRecord(value))
23
+ return;
24
+ if (typeof value.accountId !== 'string' ||
25
+ !['fable', 'opus', 'general'].includes(String(value.family))) {
26
+ return;
27
+ }
28
+ const assignedAt = finiteNumber(value.assignedAt);
29
+ const lastSeenAt = finiteNumber(value.lastSeenAt);
30
+ const initialInputBytes = finiteNumber(value.initialInputBytes);
31
+ const quotaCheckedAt = finiteNumber(value.quotaCheckedAt);
32
+ if (assignedAt === undefined ||
33
+ lastSeenAt === undefined ||
34
+ initialInputBytes === undefined ||
35
+ quotaCheckedAt === undefined) {
36
+ return;
37
+ }
38
+ return {
39
+ accountId: value.accountId,
40
+ family: value.family,
41
+ assignedAt,
42
+ lastSeenAt,
43
+ initialInputBytes: Math.max(0, initialInputBytes),
44
+ quotaCheckedAt,
45
+ };
46
+ }
47
+ function normalizeState(value) {
48
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.assignments)) {
49
+ return;
50
+ }
51
+ const assignments = {};
52
+ for (const [key, assignment] of Object.entries(value.assignments)) {
53
+ const normalized = normalizeAssignment(assignment);
54
+ if (normalized)
55
+ assignments[key] = normalized;
56
+ }
57
+ return {
58
+ version: 1,
59
+ updatedAt: finiteNumber(value.updatedAt) ?? 0,
60
+ assignments,
61
+ };
62
+ }
63
+ function emptyState(now) {
64
+ return { version: 1, updatedAt: now, assignments: {} };
65
+ }
66
+ function sessionKey(sessionId) {
67
+ return createHash('sha256').update(sessionId).digest('hex');
68
+ }
69
+ export function stickyQuotaSnapshotIsFresh(quota, storage, now = Date.now(), modelId) {
70
+ const maxAge = getQuotaCheckIntervalMs(storage);
71
+ const standardFresh = ['five_hour', 'seven_day'].every((key) => {
72
+ const window = quota?.[key];
73
+ return Boolean(window && now - window.checkedAt < maxAge);
74
+ });
75
+ if (!standardFresh)
76
+ return false;
77
+ const scoped = getScopedQuotaWindowForModel(quota, modelId);
78
+ return !scoped || now - scoped.checkedAt < maxAge;
79
+ }
80
+ function snapshotCheckedAt(quota) {
81
+ return Math.max(quota.checkedAt ?? 0, quota.five_hour?.checkedAt ?? 0, quota.seven_day?.checkedAt ?? 0, ...(quota.scoped ?? []).map((window) => window.checkedAt ?? 0));
82
+ }
83
+ function remainingThreshold(storage, accountId, key) {
84
+ const quotaMinimum = getQuotaMinimumRemainingThresholds(storage);
85
+ const quotaThreshold = key === 'five_hour'
86
+ ? quotaMinimum.five_hour
87
+ : key === 'seven_day'
88
+ ? quotaMinimum.seven_day
89
+ : 0;
90
+ if (!isKillswitchEnabled(storage))
91
+ return quotaThreshold;
92
+ const killswitch = getKillswitchThresholdsForAccount(storage, accountId === STICKY_ROUTING_MAIN_ACCOUNT_ID ? undefined : accountId);
93
+ return Math.max(quotaThreshold, killswitch[key]);
94
+ }
95
+ function sustainableWindowWeight(remainingPercent, reservePercent, resetsAt, now) {
96
+ const spendable = Math.max(0, remainingPercent - reservePercent);
97
+ if (spendable <= 0)
98
+ return 0;
99
+ if (!resetsAt)
100
+ return spendable;
101
+ const resetAt = Date.parse(resetsAt);
102
+ if (!Number.isFinite(resetAt) || resetAt <= now)
103
+ return spendable;
104
+ const hoursUntilReset = Math.max((resetAt - now) / 3_600_000, 1 / 60);
105
+ return spendable / hoursUntilReset;
106
+ }
107
+ export function stickyRouteFamilyForModel(model) {
108
+ if (isClaudeFableOrMythos5Model(model))
109
+ return 'fable';
110
+ return typeof model === 'string' && model.toLowerCase().includes('opus')
111
+ ? 'opus'
112
+ : 'general';
113
+ }
114
+ export function stickyRouteCandidateWeight(input) {
115
+ const now = input.now ?? Date.now();
116
+ const { candidate, storage } = input;
117
+ if (!stickyQuotaSnapshotIsFresh(candidate.quota, storage, now, input.modelId)) {
118
+ return 0;
119
+ }
120
+ const weights = [];
121
+ for (const key of ['five_hour', 'seven_day']) {
122
+ const window = candidate.quota[key];
123
+ if (!window || !Number.isFinite(window.remainingPercent))
124
+ return 0;
125
+ weights.push(sustainableWindowWeight(window.remainingPercent, remainingThreshold(storage, candidate.accountId, key), window.resetsAt, now));
126
+ }
127
+ if (input.family === 'fable') {
128
+ const window = getScopedQuotaWindowForModel(candidate.quota, input.modelId);
129
+ if (window && Number.isFinite(window.remainingPercent)) {
130
+ weights.push(sustainableWindowWeight(window.remainingPercent, remainingThreshold(storage, candidate.accountId, 'scoped'), window.resetsAt, now));
131
+ }
132
+ }
133
+ return Math.min(...weights);
134
+ }
135
+ export function stickyRouteKnownFableExhausted(candidate, storage, now = Date.now()) {
136
+ const window = getScopedQuotaWindowForModel(candidate.quota, 'claude-fable-5');
137
+ return Boolean(window &&
138
+ stickyQuotaSnapshotIsFresh(candidate.quota, storage, now, 'claude-fable-5') &&
139
+ Number.isFinite(window.remainingPercent) &&
140
+ window.remainingPercent <= 0);
141
+ }
142
+ export function decideStickyQuotaFailure(input) {
143
+ const now = input.now ?? Date.now();
144
+ if (!input.quota)
145
+ return { action: 'retain', reason: 'unknown' };
146
+ const scoped = getScopedQuotaWindowForModel(input.quota, input.modelId);
147
+ if (scoped &&
148
+ Number.isFinite(scoped.remainingPercent) &&
149
+ scoped.remainingPercent <= 0) {
150
+ return { action: 'migrate', reason: 'model-scoped' };
151
+ }
152
+ const sevenDay = input.quota.seven_day;
153
+ if (sevenDay &&
154
+ Number.isFinite(sevenDay.remainingPercent) &&
155
+ sevenDay.remainingPercent <= 0) {
156
+ return { action: 'migrate', reason: 'seven-day' };
157
+ }
158
+ const fiveHour = input.quota.five_hour;
159
+ if (fiveHour &&
160
+ Number.isFinite(fiveHour.remainingPercent) &&
161
+ fiveHour.remainingPercent <= 0) {
162
+ const resetAt = fiveHour.resetsAt ? Date.parse(fiveHour.resetsAt) : NaN;
163
+ if (Number.isFinite(resetAt) &&
164
+ resetAt > now &&
165
+ resetAt - now <= STICKY_ROUTING_SHORT_RESET_GRACE_MS) {
166
+ return {
167
+ action: 'hold',
168
+ reason: 'five-hour-short-reset',
169
+ retryAfterSeconds: Math.max(1, Math.ceil((resetAt - now) / 1000)),
170
+ };
171
+ }
172
+ return { action: 'migrate', reason: 'five-hour' };
173
+ }
174
+ return { action: 'retain', reason: 'not-exhausted' };
175
+ }
176
+ export function getStickyRoutingStatePath(accountStoragePath) {
177
+ const name = basename(accountStoragePath);
178
+ const stem = name.endsWith('.json') ? name.slice(0, -5) : name;
179
+ return join(dirname(accountStoragePath), `${stem}-routing-state.json`);
180
+ }
181
+ export function stickyRetryAfterWithJitter(sessionId, retryAfterSeconds) {
182
+ const hash = createHash('sha256').update(sessionId).digest().readUInt32BE(0);
183
+ return Math.max(1, Math.ceil(retryAfterSeconds)) + (hash % 21);
184
+ }
185
+ export class StickySessionRouter {
186
+ options;
187
+ loaded = false;
188
+ state;
189
+ stateMtimeNs;
190
+ touchChain = Promise.resolve();
191
+ constructor(options) {
192
+ this.options = options;
193
+ this.state = emptyState(this.now());
194
+ }
195
+ now() {
196
+ return this.options.now?.() ?? Date.now();
197
+ }
198
+ async readState() {
199
+ try {
200
+ const raw = await readFile(this.options.path, 'utf8');
201
+ const normalized = normalizeState(JSON.parse(raw));
202
+ if (!normalized) {
203
+ throw new Error(`Invalid sticky routing state at ${this.options.path}`);
204
+ }
205
+ return normalized;
206
+ }
207
+ catch (error) {
208
+ if (error.code === 'ENOENT') {
209
+ return emptyState(this.now());
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+ async readStateMtimeNs() {
215
+ try {
216
+ return (await stat(this.options.path, { bigint: true })).mtimeNs;
217
+ }
218
+ catch (error) {
219
+ if (error.code === 'ENOENT')
220
+ return 0n;
221
+ throw error;
222
+ }
223
+ }
224
+ async refreshStateIfChanged() {
225
+ for (let attempt = 0; attempt < 3; attempt++) {
226
+ const before = await this.readStateMtimeNs();
227
+ if (this.loaded && before === this.stateMtimeNs)
228
+ return;
229
+ const state = await this.readState();
230
+ const after = await this.readStateMtimeNs();
231
+ if (before !== after)
232
+ continue;
233
+ this.state = state;
234
+ this.stateMtimeNs = after;
235
+ this.loaded = true;
236
+ return;
237
+ }
238
+ this.state = await this.readState();
239
+ this.stateMtimeNs = await this.readStateMtimeNs();
240
+ this.loaded = true;
241
+ }
242
+ async acquireLock() {
243
+ await mkdir(dirname(this.options.path), { recursive: true, mode: 0o700 });
244
+ const deadline = this.now() + STICKY_ROUTING_LOCK_WAIT_MS;
245
+ while (true) {
246
+ const lock = await acquireRefreshFileLock({
247
+ name: 'write',
248
+ path: this.options.path,
249
+ ttlMs: STICKY_ROUTING_LOCK_TTL_MS,
250
+ renew: true,
251
+ });
252
+ if (lock)
253
+ return lock;
254
+ if (this.now() >= deadline) {
255
+ throw new Error('Timed out acquiring sticky routing state lock');
256
+ }
257
+ await new Promise((resolve) => setTimeout(resolve, STICKY_ROUTING_LOCK_POLL_MS));
258
+ }
259
+ }
260
+ assignmentIsActive(assignment) {
261
+ return (assignment.lastSeenAt >=
262
+ this.now() -
263
+ (this.options.assignmentTtlMs ?? STICKY_ROUTING_ASSIGNMENT_TTL_MS));
264
+ }
265
+ prune(state) {
266
+ const cutoff = this.now() -
267
+ (this.options.assignmentTtlMs ?? STICKY_ROUTING_ASSIGNMENT_TTL_MS);
268
+ for (const [key, assignment] of Object.entries(state.assignments)) {
269
+ if (assignment.lastSeenAt < cutoff)
270
+ delete state.assignments[key];
271
+ }
272
+ const entries = Object.entries(state.assignments);
273
+ if (entries.length <= STICKY_ROUTING_MAX_ASSIGNMENTS)
274
+ return;
275
+ entries
276
+ .sort((left, right) => right[1].lastSeenAt - left[1].lastSeenAt)
277
+ .slice(STICKY_ROUTING_MAX_ASSIGNMENTS)
278
+ .forEach(([key]) => {
279
+ delete state.assignments[key];
280
+ });
281
+ }
282
+ async writeState(state) {
283
+ this.prune(state);
284
+ state.updatedAt = this.now();
285
+ const tempPath = `${this.options.path}.${randomUUID()}.tmp`;
286
+ try {
287
+ await writeFile(tempPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
288
+ await rename(tempPath, this.options.path);
289
+ }
290
+ finally {
291
+ await rm(tempPath, { force: true }).catch(() => { });
292
+ }
293
+ this.state = state;
294
+ this.stateMtimeNs = await this.readStateMtimeNs();
295
+ this.loaded = true;
296
+ }
297
+ scheduleTouch(key) {
298
+ this.touchChain = this.touchChain
299
+ .catch(() => { })
300
+ .then(async () => {
301
+ const lock = await this.acquireLock();
302
+ try {
303
+ const state = await this.readState();
304
+ const assignment = state.assignments[key];
305
+ if (!assignment)
306
+ return;
307
+ assignment.lastSeenAt = this.now();
308
+ await this.writeState(state);
309
+ }
310
+ finally {
311
+ await lock.release();
312
+ }
313
+ })
314
+ .catch(() => { });
315
+ }
316
+ selectCandidate(input) {
317
+ let candidates = [...input.candidates];
318
+ if (input.family === 'opus') {
319
+ const depleted = candidates.filter((candidate) => stickyRouteKnownFableExhausted(candidate, input.storage, this.now()));
320
+ if (depleted.length > 0)
321
+ candidates = depleted;
322
+ }
323
+ const weighted = candidates.flatMap((candidate) => {
324
+ const weight = stickyRouteCandidateWeight({
325
+ candidate,
326
+ family: input.family,
327
+ modelId: input.modelId,
328
+ storage: input.storage,
329
+ now: this.now(),
330
+ });
331
+ return weight > 0 ? [{ candidate, weight }] : [];
332
+ });
333
+ if (weighted.length === 0)
334
+ return undefined;
335
+ const pendingBytes = new Map();
336
+ for (const assignment of Object.values(input.state.assignments)) {
337
+ const candidate = weighted.find((entry) => entry.candidate.accountId === assignment.accountId)?.candidate;
338
+ if (!candidate)
339
+ continue;
340
+ if (assignment.quotaCheckedAt !== snapshotCheckedAt(candidate.quota))
341
+ continue;
342
+ pendingBytes.set(assignment.accountId, (pendingBytes.get(assignment.accountId) ?? 0) +
343
+ assignment.initialInputBytes);
344
+ }
345
+ return weighted
346
+ .map(({ candidate, weight }) => ({
347
+ candidate,
348
+ score: ((pendingBytes.get(candidate.accountId) ?? 0) + input.inputBytes) /
349
+ Math.max(weight, MIN_WEIGHT),
350
+ }))
351
+ .sort((left, right) => left.score - right.score ||
352
+ left.candidate.order - right.candidate.order ||
353
+ left.candidate.accountId.localeCompare(right.candidate.accountId))[0]?.candidate;
354
+ }
355
+ async resolve(input) {
356
+ if (!input.sessionId)
357
+ return null;
358
+ await this.refreshStateIfChanged();
359
+ const key = sessionKey(input.sessionId);
360
+ const cached = this.state.assignments[key];
361
+ if (cached &&
362
+ this.assignmentIsActive(cached) &&
363
+ input.retainAccountIds.has(cached.accountId) &&
364
+ !input.excludeAccountIds?.has(cached.accountId)) {
365
+ if (this.now() - cached.lastSeenAt >= STICKY_ROUTING_TOUCH_INTERVAL_MS) {
366
+ cached.lastSeenAt = this.now();
367
+ this.scheduleTouch(key);
368
+ }
369
+ return {
370
+ accountId: cached.accountId,
371
+ assignment: { ...cached },
372
+ created: false,
373
+ migrated: false,
374
+ };
375
+ }
376
+ const lock = await this.acquireLock();
377
+ try {
378
+ const state = await this.readState();
379
+ this.prune(state);
380
+ const current = state.assignments[key];
381
+ if (current &&
382
+ input.retainAccountIds.has(current.accountId) &&
383
+ !input.excludeAccountIds?.has(current.accountId)) {
384
+ current.lastSeenAt = this.now();
385
+ await this.writeState(state);
386
+ return {
387
+ accountId: current.accountId,
388
+ assignment: { ...current },
389
+ created: false,
390
+ migrated: false,
391
+ };
392
+ }
393
+ const candidates = input.candidates.filter((candidate) => !input.excludeAccountIds?.has(candidate.accountId));
394
+ const preferred = input.preferredAccountId
395
+ ? candidates.find((candidate) => candidate.accountId === input.preferredAccountId &&
396
+ stickyRouteCandidateWeight({
397
+ candidate,
398
+ family: input.family,
399
+ modelId: input.modelId,
400
+ storage: input.storage,
401
+ now: this.now(),
402
+ }) > 0)
403
+ : undefined;
404
+ const selected = preferred ?? this.selectCandidate({ ...input, candidates, state });
405
+ if (!selected) {
406
+ if (current) {
407
+ delete state.assignments[key];
408
+ await this.writeState(state);
409
+ }
410
+ return null;
411
+ }
412
+ const now = this.now();
413
+ const assignment = {
414
+ accountId: selected.accountId,
415
+ family: input.family,
416
+ assignedAt: now,
417
+ lastSeenAt: now,
418
+ initialInputBytes: Math.max(1, input.inputBytes),
419
+ quotaCheckedAt: snapshotCheckedAt(selected.quota),
420
+ };
421
+ state.assignments[key] = assignment;
422
+ await this.writeState(state);
423
+ return {
424
+ accountId: selected.accountId,
425
+ assignment: { ...assignment },
426
+ created: !current,
427
+ migrated: Boolean(current && current.accountId !== selected.accountId),
428
+ };
429
+ }
430
+ finally {
431
+ await lock.release();
432
+ }
433
+ }
434
+ async clear(sessionId) {
435
+ if (!sessionId)
436
+ return;
437
+ const key = sessionKey(sessionId);
438
+ const lock = await this.acquireLock();
439
+ try {
440
+ const state = await this.readState();
441
+ if (!(key in state.assignments))
442
+ return;
443
+ delete state.assignments[key];
444
+ await this.writeState(state);
445
+ }
446
+ finally {
447
+ await lock.release();
448
+ }
449
+ }
450
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",