@cortexkit/anthropic-auth-core 1.19.0 → 1.19.1
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 +4 -0
- package/dist/accounts.js +8 -3
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +2 -1
- package/dist/cachekeep.d.ts +2 -0
- package/dist/cachekeep.js +3 -2
- package/dist/relay.d.ts +2 -0
- package/dist/relay.js +20 -15
- package/dist/sticky-routing.d.ts +12 -1
- package/dist/sticky-routing.js +47 -1
- package/package.json +1 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -209,6 +209,8 @@ export type AccountManagerOptions = {
|
|
|
209
209
|
configPath?: string;
|
|
210
210
|
quotaManager?: import('./quota-manager.ts').QuotaManager;
|
|
211
211
|
onFallbackStorageChanged?: () => void;
|
|
212
|
+
setIntervalImpl?: typeof globalThis.setInterval;
|
|
213
|
+
clearIntervalImpl?: typeof globalThis.clearInterval;
|
|
212
214
|
};
|
|
213
215
|
export type AccountRefreshError = {
|
|
214
216
|
accountId: string;
|
|
@@ -362,6 +364,8 @@ export declare class FallbackAccountManager {
|
|
|
362
364
|
private quotaTimer;
|
|
363
365
|
readonly quotaManager: import('./quota-manager.ts').QuotaManager | null;
|
|
364
366
|
private readonly onFallbackStorageChanged;
|
|
367
|
+
private readonly setIntervalImpl;
|
|
368
|
+
private readonly clearIntervalImpl;
|
|
365
369
|
constructor(options?: AccountManagerOptions);
|
|
366
370
|
/**
|
|
367
371
|
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
package/dist/accounts.js
CHANGED
|
@@ -2031,12 +2031,17 @@ export class FallbackAccountManager {
|
|
|
2031
2031
|
quotaTimer = null;
|
|
2032
2032
|
quotaManager;
|
|
2033
2033
|
onFallbackStorageChanged;
|
|
2034
|
+
setIntervalImpl;
|
|
2035
|
+
clearIntervalImpl;
|
|
2034
2036
|
constructor(options = {}) {
|
|
2035
2037
|
this.now = options.now ?? Date.now;
|
|
2036
2038
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
2037
2039
|
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
2038
2040
|
this.quotaManager = options.quotaManager ?? null;
|
|
2039
2041
|
this.onFallbackStorageChanged = options.onFallbackStorageChanged;
|
|
2042
|
+
this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
|
|
2043
|
+
this.clearIntervalImpl =
|
|
2044
|
+
options.clearIntervalImpl ?? globalThis.clearInterval;
|
|
2040
2045
|
}
|
|
2041
2046
|
/**
|
|
2042
2047
|
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
|
@@ -2075,7 +2080,7 @@ export class FallbackAccountManager {
|
|
|
2075
2080
|
};
|
|
2076
2081
|
void run().catch(() => { });
|
|
2077
2082
|
if (!this.refreshTimer) {
|
|
2078
|
-
this.refreshTimer =
|
|
2083
|
+
this.refreshTimer = this.setIntervalImpl(() => {
|
|
2079
2084
|
void run().catch(() => { });
|
|
2080
2085
|
}, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
|
|
2081
2086
|
if ('unref' in this.refreshTimer)
|
|
@@ -2084,9 +2089,9 @@ export class FallbackAccountManager {
|
|
|
2084
2089
|
}
|
|
2085
2090
|
stopBackgroundRefresh() {
|
|
2086
2091
|
if (this.refreshTimer)
|
|
2087
|
-
|
|
2092
|
+
this.clearIntervalImpl(this.refreshTimer);
|
|
2088
2093
|
if (this.quotaTimer)
|
|
2089
|
-
|
|
2094
|
+
this.clearIntervalImpl(this.quotaTimer);
|
|
2090
2095
|
this.refreshTimer = null;
|
|
2091
2096
|
this.quotaTimer = null;
|
|
2092
2097
|
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export declare function refreshClaudeOAuthToken(input: {
|
|
|
25
25
|
now?: () => number;
|
|
26
26
|
maxRetries?: number;
|
|
27
27
|
baseDelayMs?: number;
|
|
28
|
+
setTimeoutImpl?: typeof globalThis.setTimeout;
|
|
28
29
|
}): Promise<ClaudeOAuthRefreshResult>;
|
|
29
30
|
export type AuthorizationResult = {
|
|
30
31
|
url: string;
|
package/dist/auth.js
CHANGED
|
@@ -47,11 +47,12 @@ export async function refreshClaudeOAuthToken(input) {
|
|
|
47
47
|
const fetchImpl = input.fetchImpl ?? fetch;
|
|
48
48
|
const maxRetries = input.maxRetries ?? 2;
|
|
49
49
|
const baseDelayMs = input.baseDelayMs ?? 500;
|
|
50
|
+
const setTimeoutImpl = input.setTimeoutImpl ?? globalThis.setTimeout;
|
|
50
51
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
51
52
|
try {
|
|
52
53
|
if (attempt > 0) {
|
|
53
54
|
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
54
|
-
await new Promise((resolve) =>
|
|
55
|
+
await new Promise((resolve) => setTimeoutImpl(resolve, delay));
|
|
55
56
|
}
|
|
56
57
|
const response = await fetchImpl(TOKEN_URL, {
|
|
57
58
|
method: 'POST',
|
package/dist/cachekeep.d.ts
CHANGED
|
@@ -94,6 +94,8 @@ export declare class CacheKeepManager {
|
|
|
94
94
|
fetchImpl?: typeof fetch;
|
|
95
95
|
now?: () => number;
|
|
96
96
|
prewarmTimeoutMs?: number;
|
|
97
|
+
setIntervalImpl?: typeof globalThis.setInterval;
|
|
98
|
+
clearIntervalImpl?: typeof globalThis.clearInterval;
|
|
97
99
|
prepareHeaders?: (headers: Headers, target: CacheKeepTarget) => Promise<Headers> | Headers;
|
|
98
100
|
onTrackedSessionsChanged?: (sessions: readonly CacheKeepTrackedSession[]) => Promise<void> | void;
|
|
99
101
|
});
|
package/dist/cachekeep.js
CHANGED
|
@@ -239,7 +239,7 @@ export class CacheKeepManager {
|
|
|
239
239
|
if (this.timer)
|
|
240
240
|
return;
|
|
241
241
|
logger.debug('cachekeep', 'started');
|
|
242
|
-
this.timer = setInterval(() => {
|
|
242
|
+
this.timer = (this.options.setIntervalImpl ?? globalThis.setInterval)(() => {
|
|
243
243
|
void this.tick().catch((error) => {
|
|
244
244
|
logger.warn('cachekeep', 'tick failed', {
|
|
245
245
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -253,7 +253,8 @@ export class CacheKeepManager {
|
|
|
253
253
|
if (!this.timer)
|
|
254
254
|
return;
|
|
255
255
|
logger.debug('cachekeep', 'stopped');
|
|
256
|
-
|
|
256
|
+
const clearIntervalImpl = this.options.clearIntervalImpl ?? globalThis.clearInterval;
|
|
257
|
+
clearIntervalImpl(this.timer);
|
|
257
258
|
this.timer = null;
|
|
258
259
|
}
|
|
259
260
|
stats(window, now = this.options.now?.() ?? Date.now(), always = false) {
|
package/dist/relay.d.ts
CHANGED
|
@@ -31,5 +31,7 @@ export declare function sendViaRelay(options: {
|
|
|
31
31
|
* within an attempt are ignored.
|
|
32
32
|
*/
|
|
33
33
|
onResponseHeaders?: (headers: Headers) => void;
|
|
34
|
+
setTimeoutImpl?: typeof globalThis.setTimeout;
|
|
35
|
+
clearTimeoutImpl?: typeof globalThis.clearTimeout;
|
|
34
36
|
}): Promise<Response>;
|
|
35
37
|
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
|
@@ -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, 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) {
|
package/dist/sticky-routing.d.ts
CHANGED
|
@@ -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,17 @@ 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
|
+
routeQuotas: readonly OAuthQuotaSnapshot[];
|
|
44
|
+
modelId?: string;
|
|
45
|
+
now?: number;
|
|
46
|
+
}): Response;
|
|
36
47
|
export declare function stickyQuotaSnapshotIsFresh(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now?: number, modelId?: string): boolean;
|
|
37
48
|
export declare function stickyRouteFamilyForModel(model: unknown): StickyRouteFamily;
|
|
38
49
|
export declare function stickyRouteCandidateWeight(input: {
|
package/dist/sticky-routing.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
4
|
+
import { acquireRefreshFileLock, getKillswitchThresholdsForAccount, getQuotaCheckIntervalMs, getQuotaMinimumRemainingThresholds, getScopedQuotaWindowForModel, isKillswitchEnabled, isPermanentRefreshError, killswitchRetryAfterSeconds, quotaSnapshotPassesModelScope, } from "./accounts.js";
|
|
5
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;
|
|
@@ -12,6 +12,52 @@ 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
|
+
if (isPermanentRefreshError(input.mainRefreshError)) {
|
|
28
|
+
const message = modelName
|
|
29
|
+
? `Main Claude OAuth account requires re-login, and no fallback OAuth account is currently routable for ${modelName}.`
|
|
30
|
+
: 'Main Claude OAuth account requires re-login, and no fallback OAuth account is currently routable.';
|
|
31
|
+
return new Response(JSON.stringify({
|
|
32
|
+
type: 'error',
|
|
33
|
+
error: { type: 'authentication_error', message },
|
|
34
|
+
}), {
|
|
35
|
+
status: 401,
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const scopedBlocked = Boolean(modelId &&
|
|
40
|
+
input.routeQuotas.length > 0 &&
|
|
41
|
+
input.routeQuotas.every((quota) => !quotaSnapshotPassesModelScope(quota, modelId)));
|
|
42
|
+
const [firstQuota, ...remainingQuotas] = input.routeQuotas;
|
|
43
|
+
const retryAfter = killswitchRetryAfterSeconds(firstQuota, remainingQuotas.map((quota) => ({ quota })), input.now ?? Date.now(), scopedBlocked ? modelId : undefined);
|
|
44
|
+
const minutes = Math.floor(retryAfter / 60);
|
|
45
|
+
const seconds = retryAfter % 60;
|
|
46
|
+
const reason = scopedBlocked && modelName
|
|
47
|
+
? `${modelName} weekly limit reached, no routable OAuth accounts.`
|
|
48
|
+
: 'No OAuth account currently satisfies sticky-balanced quota policy.';
|
|
49
|
+
const message = `${reason} Retry in ${minutes}m ${seconds}s.`;
|
|
50
|
+
return new Response(JSON.stringify({
|
|
51
|
+
type: 'error',
|
|
52
|
+
error: { type: 'rate_limit_error', message },
|
|
53
|
+
}), {
|
|
54
|
+
status: 429,
|
|
55
|
+
headers: {
|
|
56
|
+
'content-type': 'application/json',
|
|
57
|
+
'retry-after': String(retryAfter),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
15
61
|
function isRecord(value) {
|
|
16
62
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
17
63
|
}
|