@cortexkit/anthropic-auth-core 1.19.0 → 1.20.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.
@@ -12,6 +12,11 @@ export type QuotaEntry = {
12
12
  refreshAfter: number;
13
13
  checkedAt: number;
14
14
  };
15
+ export type QuotaRefreshResult = {
16
+ quota: OAuthQuotaSnapshot;
17
+ /** False when backoff or a cross-process lock served the existing cache. */
18
+ fetched: boolean;
19
+ };
15
20
  export type QuotaManagerOptions = {
16
21
  storage: AccountStorage | null;
17
22
  fetchImpl?: typeof fetch;
@@ -58,7 +63,9 @@ export declare class QuotaManager {
58
63
  pushMainFromHeaders(accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
59
64
  pushFallbackFromHeaders(accountId: string, accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
60
65
  refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
66
+ refreshMainWithMetadata(accessToken: string): Promise<QuotaRefreshResult>;
61
67
  refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
68
+ refreshFallbackWithMetadata(accountId: string, accessToken: string): Promise<QuotaRefreshResult>;
62
69
  refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
63
70
  /**
64
71
  * Fire-and-forget refresh. Does not await, swallows errors.
@@ -5,10 +5,10 @@
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 { 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";
8
+ import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, quotaBackoffActive, quotaSnapshotCheckedAt, } from './accounts.js';
9
+ import { mergeHeaderQuotaSnapshot } from './quota-headers.js';
10
+ export { tokenFingerprint } from './token-fingerprint.js';
11
+ import { tokenFingerprint } from './token-fingerprint.js';
12
12
  // Capture real setTimeout before tests can mock globalThis.setTimeout
13
13
  const nativeSetTimeout = globalThis.setTimeout;
14
14
  function mergePollCompletionWithNewerHeaders(current, polled) {
@@ -158,6 +158,9 @@ export class QuotaManager {
158
158
  // Refresh (async, deduplicated, rate-limited)
159
159
  // =========================================================================
160
160
  async refreshMain(accessToken) {
161
+ return (await this.refreshMainWithMetadata(accessToken)).quota;
162
+ }
163
+ async refreshMainWithMetadata(accessToken) {
161
164
  // If the main account/token changed, invalidate the cache (including a
162
165
  // persisted seed) BEFORE the backoff short-circuit so a different account's
163
166
  // stale quota is never returned while the quota API is backed off.
@@ -172,7 +175,7 @@ export class QuotaManager {
172
175
  // Rate-limit — if API recently 429'd, return stale or throw
173
176
  if (this.isBackedOff()) {
174
177
  if (this.main)
175
- return this.main.quota;
178
+ return { quota: this.main.quota, fetched: false };
176
179
  throw new Error('Quota API rate-limited — try again later');
177
180
  }
178
181
  this.inflightMainFp = fp;
@@ -180,6 +183,10 @@ export class QuotaManager {
180
183
  return this.inflightMain;
181
184
  }
182
185
  async refreshFallback(accountId, accessToken) {
186
+ return (await this.refreshFallbackWithMetadata(accountId, accessToken))
187
+ .quota;
188
+ }
189
+ async refreshFallbackWithMetadata(accountId, accessToken) {
183
190
  // Deduplicate per account+token so a same-label re-login never joins a
184
191
  // quota probe that was started with the previous credentials.
185
192
  const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken);
@@ -190,7 +197,7 @@ export class QuotaManager {
190
197
  if (this.isFallbackBackedOff(accountId, accessToken)) {
191
198
  const cached = this.getFallback(accountId, accessToken);
192
199
  if (cached)
193
- return cached.quota;
200
+ return { quota: cached.quota, fetched: false };
194
201
  throw new Error('Quota API rate-limited — try again later');
195
202
  }
196
203
  const promise = this._fetchFallback(accountId, accessToken);
@@ -319,7 +326,7 @@ export class QuotaManager {
319
326
  continue;
320
327
  if (!account.quota)
321
328
  continue;
322
- const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
329
+ const checkedAt = quotaSnapshotCheckedAt(account.quota);
323
330
  if (checkedAt <= 0)
324
331
  continue;
325
332
  const existing = this.getFallback(account.id, account.access);
@@ -397,7 +404,7 @@ export class QuotaManager {
397
404
  // a preceding queued call while we waited
398
405
  if (this.isBackedOff()) {
399
406
  if (this.main)
400
- return this.main.quota;
407
+ return { quota: this.main.quota, fetched: false };
401
408
  throw new Error('Quota API rate-limited — try again later');
402
409
  }
403
410
  const fileLock = await acquireRefreshFileLock({
@@ -406,8 +413,9 @@ export class QuotaManager {
406
413
  });
407
414
  if (!fileLock) {
408
415
  const cached = this.main;
409
- if (cached && this.now() < cached.refreshAfter)
410
- return cached.quota;
416
+ if (cached && this.now() < cached.refreshAfter) {
417
+ return { quota: cached.quota, fetched: false };
418
+ }
411
419
  throw new Error('Quota refresh is already in progress');
412
420
  }
413
421
  try {
@@ -427,7 +435,7 @@ export class QuotaManager {
427
435
  };
428
436
  this.mainLastApiError = undefined;
429
437
  this.onMainQuotaFetched?.(completedQuota, now, this.mainTokenFp, fetchStartedAt);
430
- return completedQuota;
438
+ return { quota: completedQuota, fetched: true };
431
439
  }
432
440
  catch (error) {
433
441
  this._handleMainFetchError(error);
@@ -452,7 +460,7 @@ export class QuotaManager {
452
460
  if (this.isFallbackBackedOff(accountId, accessToken)) {
453
461
  const cached = this.getFallback(accountId, accessToken);
454
462
  if (cached)
455
- return cached.quota;
463
+ return { quota: cached.quota, fetched: false };
456
464
  throw new Error('Quota API rate-limited — try again later');
457
465
  }
458
466
  const fileLock = await acquireRefreshFileLock({
@@ -461,8 +469,9 @@ export class QuotaManager {
461
469
  });
462
470
  if (!fileLock) {
463
471
  const cached = this.getFallback(accountId, accessToken);
464
- if (cached && this.now() < cached.refreshAfter)
465
- return cached.quota;
472
+ if (cached && this.now() < cached.refreshAfter) {
473
+ return { quota: cached.quota, fetched: false };
474
+ }
466
475
  throw new Error('Quota refresh is already in progress');
467
476
  }
468
477
  try {
@@ -480,7 +489,7 @@ export class QuotaManager {
480
489
  }, accessToken);
481
490
  this.fallbackApiErrors.delete(accountId);
482
491
  this.fallbackErrorTokenFps.delete(accountId);
483
- return completedQuota;
492
+ return { quota: completedQuota, fetched: true };
484
493
  }
485
494
  finally {
486
495
  await fileLock.release();
package/dist/quotas.d.ts CHANGED
@@ -11,13 +11,13 @@ export type QuotaAccountSummary = {
11
11
  };
12
12
  export declare function formatQuotaMoney(money: QuotaMoney): string;
13
13
  export declare function buildFallbackQuotaSummaries(storage: AccountStorage | null, errors?: ReadonlyMap<string, string>): {
14
- error?: string | undefined;
15
- lastRefreshedAt: number | undefined;
16
- tierLabel?: string | undefined;
17
14
  name: string;
18
- role: "fallback";
15
+ role: 'fallback';
19
16
  enabled: boolean;
20
17
  quota: OAuthQuotaSnapshot | undefined;
18
+ tierLabel?: string | undefined;
19
+ lastRefreshedAt: number | undefined;
20
+ error?: string | undefined;
21
21
  }[];
22
22
  export declare function buildClaudeQuotaSummary(input: {
23
23
  accounts: QuotaAccountSummary[];
package/dist/quotas.js CHANGED
@@ -1,5 +1,5 @@
1
- import { isOAuthAccount } from "./accounts.js";
2
- import { formatOAuthAccountTier } from "./oauth-profile.js";
1
+ import { isOAuthAccount } from './accounts.js';
2
+ import { formatOAuthAccountTier } from './oauth-profile.js';
3
3
  export const CLAUDE_QUOTAS_COMMAND_NAME = 'claude-quota';
4
4
  const WINDOW_LABELS = {
5
5
  five_hour: '5h',
package/dist/relay.d.ts CHANGED
@@ -31,5 +31,9 @@ export declare function sendViaRelay(options: {
31
31
  * within an attempt are ignored.
32
32
  */
33
33
  onResponseHeaders?: (headers: Headers) => void;
34
+ dumpTag?: import('./dump.ts').DumpTag;
35
+ onDumpCreated?: (handle: import('./dump.ts').DumpHandle) => void;
36
+ setTimeoutImpl?: typeof globalThis.setTimeout;
37
+ clearTimeoutImpl?: typeof globalThis.clearTimeout;
34
38
  }): Promise<Response>;
35
39
  export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nconst WEBSOCKET_STREAM_FLUSH_BYTES = 1024\nconst WEBSOCKET_STREAM_FLUSH_MS = 20\n\nfunction createWebSocketStreamCoalescer(socket, options = {}) {\n const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES\n const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS\n let chunks = []\n let byteLength = 0\n let flushTimer = null\n const stats = {\n upstreamChunks: 0,\n upstreamBytes: 0,\n frames: 0,\n frameBytes: 0,\n }\n\n const clearFlushTimer = () => {\n if (flushTimer === null) return\n clearTimeout(flushTimer)\n flushTimer = null\n }\n\n const mergeChunks = () => {\n if (chunks.length === 1) return chunks[0]\n const frame = new Uint8Array(byteLength)\n let offset = 0\n for (const chunk of chunks) {\n frame.set(chunk, offset)\n offset += chunk.byteLength\n }\n return frame\n }\n\n const flush = () => {\n clearFlushTimer()\n if (byteLength === 0) return\n const frame = mergeChunks()\n chunks = []\n byteLength = 0\n socket.send(frame)\n stats.frames += 1\n stats.frameBytes += frame.byteLength\n }\n\n const scheduleFlush = () => {\n if (flushTimer !== null) return\n flushTimer = setTimeout(flush, flushMs)\n }\n\n return {\n push(value) {\n const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)\n if (chunk.byteLength === 0) return\n stats.upstreamChunks += 1\n stats.upstreamBytes += chunk.byteLength\n chunks.push(chunk)\n byteLength += chunk.byteLength\n if (byteLength >= flushBytes) flush()\n else scheduleFlush()\n },\n flush,\n stats() {\n return { ...stats, pendingBytes: byteLength }\n },\n }\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch (error) {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n message: error instanceof Error ? error.message : String(error),\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n let streamCoalescer = null\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n streamCoalescer = createWebSocketStreamCoalescer(socket)\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n streamCoalescer.push(value)\n }\n streamCoalescer.flush()\n }\n const stats = streamCoalescer?.stats?.() || {}\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))\n } catch (error) {\n try {\n streamCoalescer?.flush?.()\n } catch {}\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
package/dist/relay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { dumpRelayRequest } from "./dump.js";
4
- import { relayLog } from "./logger.js";
3
+ import { dumpRelayRequest } from './dump.js';
4
+ import { relayLog } from './logger.js';
5
5
  class RelayStateMismatchError extends Error {
6
6
  }
7
7
  class RelayWebSocketConnectionResetError extends Error {
@@ -299,15 +299,17 @@ function relayControlErrorMessage(message) {
299
299
  class PersistentRelaySession {
300
300
  config;
301
301
  affinity;
302
+ timers;
302
303
  socket;
303
304
  connecting;
304
305
  queue = Promise.resolve();
305
306
  pending;
306
307
  serverState;
307
308
  lastUsedAt = Date.now();
308
- constructor(config, affinity) {
309
+ constructor(config, affinity, timers) {
309
310
  this.config = config;
310
311
  this.affinity = affinity;
312
+ this.timers = timers;
311
313
  }
312
314
  touch(now = Date.now()) {
313
315
  this.lastUsedAt = now;
@@ -397,7 +399,7 @@ class PersistentRelaySession {
397
399
  const socket = new WebSocket(parsed.toString());
398
400
  this.socket = socket;
399
401
  this.serverState = undefined;
400
- const timeout = setTimeout(() => {
402
+ const timeout = this.timers.setTimeout(() => {
401
403
  socket.close();
402
404
  reject(new Error('relay websocket ready timed out'));
403
405
  }, 15_000);
@@ -409,7 +411,7 @@ class PersistentRelaySession {
409
411
  }
410
412
  const message = parseRelayControlMessage(event.data);
411
413
  if (message.type === 'ready') {
412
- clearTimeout(timeout);
414
+ this.timers.clearTimeout(timeout);
413
415
  this.serverState = message.state;
414
416
  resolve();
415
417
  return;
@@ -417,20 +419,20 @@ class PersistentRelaySession {
417
419
  this.handleMessage(message);
418
420
  }
419
421
  catch (error) {
420
- clearTimeout(timeout);
422
+ this.timers.clearTimeout(timeout);
421
423
  reject(error);
422
424
  this.failPending(error);
423
425
  socket.close();
424
426
  }
425
427
  });
426
428
  socket.addEventListener('error', () => {
427
- clearTimeout(timeout);
429
+ this.timers.clearTimeout(timeout);
428
430
  const error = new RelayWebSocketConnectionResetError('relay websocket error');
429
431
  reject(error);
430
432
  this.failPending(error);
431
433
  });
432
434
  socket.addEventListener('close', (event) => {
433
- clearTimeout(timeout);
435
+ this.timers.clearTimeout(timeout);
434
436
  if (this.socket === socket) {
435
437
  this.socket = undefined;
436
438
  this.serverState = undefined;
@@ -503,8 +505,8 @@ class PersistentRelaySession {
503
505
  return { response, done };
504
506
  }
505
507
  resetPendingTimeout(pending) {
506
- clearTimeout(pending.timeout);
507
- pending.timeout = setTimeout(() => {
508
+ this.timers.clearTimeout(pending.timeout);
509
+ pending.timeout = this.timers.setTimeout(() => {
508
510
  if (this.pending !== pending)
509
511
  return;
510
512
  if (this.retryPendingBeforeResponse(pending, 'response timed out'))
@@ -522,7 +524,7 @@ class PersistentRelaySession {
522
524
  return false;
523
525
  pending.retryAttempts += 1;
524
526
  pending.retryingBeforeResponse = true;
525
- clearTimeout(pending.timeout);
527
+ this.timers.clearTimeout(pending.timeout);
526
528
  const attempt = pending.retryAttempts;
527
529
  const originalId = pending.payload.id;
528
530
  relayLog(`websocket ${reason}; reconnecting and retrying full_sync session=${shortAffinity(this.affinity)} request=${originalId} retry=${attempt} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}${closeEvent ? ` ${formatCloseEvent(closeEvent)}` : ''}`);
@@ -681,7 +683,7 @@ class PersistentRelaySession {
681
683
  ? ` upstreamChunks=${done.upstreamChunks ?? 'unknown'} upstreamBytes=${done.upstreamBytes ?? 'unknown'} frames=${done.frames} frameBytes=${done.frameBytes ?? 'unknown'}`
682
684
  : '';
683
685
  relayLog(`perf websocket done session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(finishedAt - pending.sentAt)} streamMs=${pending.responseStartedAt == null ? 'unknown' : formatMs(finishedAt - pending.responseStartedAt)} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}${workerStats}`);
684
- clearTimeout(pending.timeout);
686
+ this.timers.clearTimeout(pending.timeout);
685
687
  if (!pending.streamDone) {
686
688
  this.deliverResponseHeaders(pending);
687
689
  pending.streamDone = true;
@@ -694,7 +696,7 @@ class PersistentRelaySession {
694
696
  const pending = this.pending;
695
697
  if (!pending)
696
698
  return;
697
- clearTimeout(pending.timeout);
699
+ this.timers.clearTimeout(pending.timeout);
698
700
  this.pending = undefined;
699
701
  if (pending.responseStarted) {
700
702
  pending.streamController?.error(error);
@@ -737,20 +739,20 @@ function cleanupRelaySessionCaches(now = Date.now()) {
737
739
  break;
738
740
  }
739
741
  }
740
- function getPersistentRelaySession(config, affinity) {
742
+ function getPersistentRelaySession(config, affinity, timers) {
741
743
  cleanupRelaySessionCaches();
742
744
  const existing = websocketSessions.get(affinity);
743
745
  if (existing) {
744
746
  existing.touch();
745
747
  return existing;
746
748
  }
747
- const session = new PersistentRelaySession(config, affinity);
749
+ const session = new PersistentRelaySession(config, affinity, timers);
748
750
  websocketSessions.set(affinity, session);
749
751
  cleanupRelaySessionCaches();
750
752
  return session;
751
753
  }
752
754
  export async function sendViaRelay(options) {
753
- const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, onResponseHeaders, } = options;
755
+ const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, onResponseHeaders, dumpTag, onDumpCreated, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, } = options;
754
756
  if (!config || !isRelayableAnthropicRequest(input, body))
755
757
  return fallback();
756
758
  const affinity = explicitAffinity ||
@@ -791,7 +793,10 @@ export async function sendViaRelay(options) {
791
793
  const sendStart = perfNowMs();
792
794
  if (config.transport === 'websocket') {
793
795
  try {
794
- const session = getPersistentRelaySession(config, affinity);
796
+ const session = getPersistentRelaySession(config, affinity, {
797
+ setTimeout: setTimeoutImpl,
798
+ clearTimeout: clearTimeoutImpl,
799
+ });
795
800
  result = await session.send(payload, bodyText, optimisticResponse === true, onResponseHeaders);
796
801
  }
797
802
  catch (error) {
@@ -843,7 +848,7 @@ export async function sendViaRelay(options) {
843
848
  });
844
849
  relayLog(`used relay transport=${result.transport} protocol=${result.protocol} mode=${result.payload.mode} status=${result.response.status} session=${shortAffinity(affinity)} bodyBytes=${bodyText.length} relayBytes=${actualPayloadBytes}`);
845
850
  const dumpStart = perfNowMs();
846
- await dumpRelayRequest({
851
+ const dumpHandle = await dumpRelayRequest({
847
852
  affinity,
848
853
  transport: result.transport,
849
854
  protocol: result.protocol,
@@ -853,7 +858,13 @@ export async function sendViaRelay(options) {
853
858
  previousBodyText: previous?.body,
854
859
  payload: result.payload,
855
860
  relayBytes: actualPayloadBytes,
861
+ tag: dumpTag,
856
862
  });
863
+ try {
864
+ if (dumpHandle)
865
+ onDumpCreated?.(dumpHandle);
866
+ }
867
+ catch { }
857
868
  relayPerfLog('dump', {
858
869
  session: shortAffinity(affinity),
859
870
  ms: formatMs(perfNowMs() - dumpStart),
package/dist/routing.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type AccountStorage, type RoutingMode } from './accounts.ts';
2
2
  export declare const CLAUDE_ROUTING_COMMAND_NAME = "claude-routing";
3
- export declare const ROUTING_MODES: readonly ["main-first", "fallback-first", "sticky-balanced"];
3
+ export declare const ROUTING_MODES: readonly ['main-first', 'fallback-first', 'sticky-balanced'];
4
4
  export declare const DEFAULT_ROUTING_MODE: RoutingMode;
5
5
  type RoutingCommandAction = {
6
6
  type: 'status';
package/dist/routing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createEmptyStorage, getAccountStoragePath, loadAccounts, saveAccounts, } from "./accounts.js";
1
+ import { createEmptyStorage, getAccountStoragePath, loadAccounts, saveAccounts, } from './accounts.js';
2
2
  export const CLAUDE_ROUTING_COMMAND_NAME = 'claude-routing';
3
3
  export const ROUTING_MODES = [
4
4
  'main-first',
@@ -0,0 +1,13 @@
1
+ export declare const CLAUDE_START_COMMAND_NAME = "claude-start";
2
+ export type LaneStartCommandAction = {
3
+ type: 'fire';
4
+ } | {
5
+ type: 'usage';
6
+ };
7
+ export declare function parseLaneStartCommandAction(input: string): LaneStartCommandAction;
8
+ export declare function executeLaneStartCommand(input: {
9
+ argumentsText: string;
10
+ }): {
11
+ action: LaneStartCommandAction;
12
+ text: string;
13
+ };
package/dist/start.js ADDED
@@ -0,0 +1,29 @@
1
+ export const CLAUDE_START_COMMAND_NAME = 'claude-start';
2
+ const START_STATUS_TITLE = '## Claude Lane Start Status';
3
+ const START_QUEUED_TITLE = '## Claude Lane Start Queued';
4
+ const START_USAGE_TITLE = '## Claude Lane Start Usage';
5
+ const START_USAGE = 'Usage: `/claude-start`.';
6
+ export function parseLaneStartCommandAction(input) {
7
+ const normalized = input.trim().split(/\s+/).filter(Boolean);
8
+ if (normalized.length === 0)
9
+ return { type: 'fire' };
10
+ return { type: 'usage' };
11
+ }
12
+ export function executeLaneStartCommand(input) {
13
+ const action = parseLaneStartCommandAction(input.argumentsText);
14
+ if (action.type === 'fire') {
15
+ return {
16
+ action,
17
+ text: [
18
+ START_QUEUED_TITLE,
19
+ '',
20
+ '- Queued an explicit lane-start request.',
21
+ '- This is a billed request: the one-token limit does not remove prompt-cache read or write charges.',
22
+ ].join('\n'),
23
+ };
24
+ }
25
+ return {
26
+ action,
27
+ text: [START_USAGE_TITLE, '', START_USAGE, '', START_STATUS_TITLE].join('\n'),
28
+ };
29
+ }
@@ -1,4 +1,4 @@
1
- import { type AccountStorage, type OAuthQuotaSnapshot } from './accounts.ts';
1
+ import { type AccountOperationError, type AccountStorage, type OAuthQuotaSnapshot } from './accounts.ts';
2
2
  export declare const STICKY_ROUTING_MAIN_ACCOUNT_ID = "main";
3
3
  export declare const STICKY_ROUTING_SHORT_RESET_GRACE_MS: number;
4
4
  export declare const STICKY_ROUTING_ASSIGNMENT_TTL_MS: number;
@@ -33,6 +33,18 @@ export type StickyQuotaFailureDecision = {
33
33
  action: 'migrate';
34
34
  reason: 'five-hour' | 'seven-day' | 'model-scoped';
35
35
  };
36
+ /**
37
+ * Build the terminal response for a complete sticky quota pool that has no
38
+ * eligible route. This prevents callers from silently falling through to the
39
+ * main account after the router deliberately excluded it.
40
+ */
41
+ export declare function createStickyNoRouteResponse(input: {
42
+ mainRefreshError?: AccountOperationError;
43
+ fallbackReauthLabels?: readonly string[];
44
+ routeQuotas: readonly OAuthQuotaSnapshot[];
45
+ modelId?: string;
46
+ now?: number;
47
+ }): Response;
36
48
  export declare function stickyQuotaSnapshotIsFresh(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now?: number, modelId?: string): boolean;
37
49
  export declare function stickyRouteFamilyForModel(model: unknown): StickyRouteFamily;
38
50
  export declare function stickyRouteCandidateWeight(input: {
@@ -1,8 +1,8 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
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";
4
+ import { acquireRefreshFileLock, getKillswitchThresholdsForAccount, getQuotaCheckIntervalMs, getQuotaMinimumRemainingThresholds, getScopedQuotaWindowForModel, isKillswitchEnabled, isPermanentRefreshError, killswitchRetryAfterSeconds, quotaSnapshotPassesModelScope, } from './accounts.js';
5
+ import { isClaudeFableOrMythos5Model } from './models.js';
6
6
  export const STICKY_ROUTING_MAIN_ACCOUNT_ID = 'main';
7
7
  export const STICKY_ROUTING_SHORT_RESET_GRACE_MS = 15 * 60_000;
8
8
  export const STICKY_ROUTING_ASSIGNMENT_TTL_MS = 7 * 24 * 60 * 60_000;
@@ -12,6 +12,62 @@ const STICKY_ROUTING_LOCK_WAIT_MS = 5_000;
12
12
  const STICKY_ROUTING_LOCK_POLL_MS = 10;
13
13
  const STICKY_ROUTING_MAX_ASSIGNMENTS = 4_096;
14
14
  const MIN_WEIGHT = 0.000_001;
15
+ /**
16
+ * Build the terminal response for a complete sticky quota pool that has no
17
+ * eligible route. This prevents callers from silently falling through to the
18
+ * main account after the router deliberately excluded it.
19
+ */
20
+ export function createStickyNoRouteResponse(input) {
21
+ const modelId = input.modelId;
22
+ const modelName = modelId
23
+ ? input.routeQuotas
24
+ .map((quota) => getScopedQuotaWindowForModel(quota, modelId))
25
+ .find(Boolean)?.modelName
26
+ : undefined;
27
+ let authenticationMessage;
28
+ if (isPermanentRefreshError(input.mainRefreshError)) {
29
+ authenticationMessage = modelName
30
+ ? `Main Claude OAuth account requires re-login, and no fallback OAuth account is currently routable for ${modelName}.`
31
+ : 'Main Claude OAuth account requires re-login, and no fallback OAuth account is currently routable.';
32
+ }
33
+ else if (input.fallbackReauthLabels?.length) {
34
+ const scope = modelName ? ` for ${modelName}` : '';
35
+ authenticationMessage = `No OAuth account is currently routable${scope}. Fallback Claude OAuth accounts require re-login: ${input.fallbackReauthLabels.join(', ')}.`;
36
+ }
37
+ if (authenticationMessage) {
38
+ return new Response(JSON.stringify({
39
+ type: 'error',
40
+ error: {
41
+ type: 'authentication_error',
42
+ message: authenticationMessage,
43
+ },
44
+ }), {
45
+ status: 401,
46
+ headers: { 'content-type': 'application/json' },
47
+ });
48
+ }
49
+ const scopedBlocked = Boolean(modelId &&
50
+ input.routeQuotas.length > 0 &&
51
+ input.routeQuotas.every((quota) => !quotaSnapshotPassesModelScope(quota, modelId)));
52
+ const [firstQuota, ...remainingQuotas] = input.routeQuotas;
53
+ const retryAfter = killswitchRetryAfterSeconds(firstQuota, remainingQuotas.map((quota) => ({ quota })), input.now ?? Date.now(), scopedBlocked ? modelId : undefined);
54
+ const minutes = Math.floor(retryAfter / 60);
55
+ const seconds = retryAfter % 60;
56
+ const reason = scopedBlocked && modelName
57
+ ? `${modelName} weekly limit reached, no routable OAuth accounts.`
58
+ : 'No OAuth account currently satisfies sticky-balanced quota policy.';
59
+ const message = `${reason} Retry in ${minutes}m ${seconds}s.`;
60
+ return new Response(JSON.stringify({
61
+ type: 'error',
62
+ error: { type: 'rate_limit_error', message },
63
+ }), {
64
+ status: 429,
65
+ headers: {
66
+ 'content-type': 'application/json',
67
+ 'retry-after': String(retryAfter),
68
+ },
69
+ });
70
+ }
15
71
  function isRecord(value) {
16
72
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
17
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",