@cortexkit/anthropic-auth-core 1.15.1 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts.d.ts +48 -3
- package/dist/accounts.js +506 -54
- package/dist/cachekeep-registry.d.ts +21 -0
- package/dist/cachekeep-registry.js +124 -0
- package/dist/cachekeep.d.ts +16 -1
- package/dist/cachekeep.js +93 -14
- package/dist/commands/account.d.ts +1 -0
- package/dist/commands/account.js +7 -1
- package/dist/dump.d.ts +19 -0
- package/dist/dump.js +162 -10
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/oauth-profile.d.ts +11 -0
- package/dist/oauth-profile.js +48 -0
- package/dist/quota-headers.d.ts +4 -0
- package/dist/quota-headers.js +66 -0
- package/dist/quota-manager.d.ts +7 -9
- package/dist/quota-manager.js +72 -24
- package/dist/quotas.d.ts +5 -2
- package/dist/quotas.js +35 -5
- package/dist/relay.d.ts +7 -0
- package/dist/relay.js +37 -8
- package/dist/routing.d.ts +3 -1
- package/dist/routing.js +20 -2
- package/dist/sticky-routing.d.ts +86 -0
- package/dist/sticky-routing.js +450 -0
- package/dist/token-fingerprint.d.ts +5 -0
- package/dist/token-fingerprint.js +8 -0
- package/package.json +1 -1
package/dist/quotas.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isOAuthAccount } from "./accounts.js";
|
|
2
|
+
import { formatOAuthAccountTier } from "./oauth-profile.js";
|
|
2
3
|
export const CLAUDE_QUOTAS_COMMAND_NAME = 'claude-quota';
|
|
3
4
|
const WINDOW_LABELS = {
|
|
4
5
|
five_hour: '5h',
|
|
@@ -43,14 +44,28 @@ function formatReset(resetsAt, now) {
|
|
|
43
44
|
return '';
|
|
44
45
|
return `, resets ${formatResetDuration(resetsAt, now)}`;
|
|
45
46
|
}
|
|
46
|
-
function formatWindow(key, window, now) {
|
|
47
|
+
function formatWindow(key, window, now, bindingWindow) {
|
|
47
48
|
if (!window)
|
|
48
49
|
return ` - ${WINDOW_LABELS[key]}: unknown`;
|
|
49
|
-
|
|
50
|
+
const line = [
|
|
50
51
|
` - ${WINDOW_LABELS[key]}: ${formatPercent(window.remainingPercent)} remaining`,
|
|
51
52
|
` (${formatPercent(window.usedPercent)} used`,
|
|
52
53
|
`${formatReset(window.resetsAt, now)}, checked ${formatAge(window.checkedAt, now)})`,
|
|
53
54
|
].join('');
|
|
55
|
+
return bindingWindow === key ? `${line} •` : line;
|
|
56
|
+
}
|
|
57
|
+
export function formatQuotaMoney(money) {
|
|
58
|
+
try {
|
|
59
|
+
return new Intl.NumberFormat('en-US', {
|
|
60
|
+
style: 'currency',
|
|
61
|
+
currency: money.currency,
|
|
62
|
+
minimumFractionDigits: money.exponent,
|
|
63
|
+
maximumFractionDigits: money.exponent,
|
|
64
|
+
}).format(money.amountMinor / 10 ** money.exponent);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return `${money.amountMinor} ${money.currency}`;
|
|
68
|
+
}
|
|
54
69
|
}
|
|
55
70
|
function formatScopedWindow(window, now) {
|
|
56
71
|
return [
|
|
@@ -75,6 +90,9 @@ export function buildFallbackQuotaSummaries(storage, errors = new Map()) {
|
|
|
75
90
|
role: 'fallback',
|
|
76
91
|
enabled: account.enabled !== false,
|
|
77
92
|
quota: account.quota,
|
|
93
|
+
...(formatOAuthAccountTier(account.profile) && {
|
|
94
|
+
tierLabel: formatOAuthAccountTier(account.profile),
|
|
95
|
+
}),
|
|
78
96
|
lastRefreshedAt: account.lastRefreshedAt,
|
|
79
97
|
...(error && { error }),
|
|
80
98
|
};
|
|
@@ -94,16 +112,28 @@ export function buildClaudeQuotaSummary(input) {
|
|
|
94
112
|
const role = account.role === 'main' ? 'main' : 'fallback';
|
|
95
113
|
const disabled = account.enabled === false ? ' disabled' : '';
|
|
96
114
|
lines.push(`### ${account.name} (${role}${disabled})`);
|
|
115
|
+
if (account.tierLabel)
|
|
116
|
+
lines.push(` - Tier: ${account.tierLabel}`);
|
|
97
117
|
if (account.lastRefreshedAt) {
|
|
98
118
|
lines.push(` - Last token refresh: ${formatAge(account.lastRefreshedAt, now)}`);
|
|
99
119
|
}
|
|
100
120
|
if (account.error) {
|
|
101
121
|
lines.push(` - Error: ${account.error}`);
|
|
102
122
|
}
|
|
103
|
-
lines.push(formatWindow('five_hour', account.quota?.five_hour, now));
|
|
104
|
-
lines.push(formatWindow('seven_day', account.quota?.seven_day, now));
|
|
123
|
+
lines.push(formatWindow('five_hour', account.quota?.five_hour, now, account.quota?.bindingWindow));
|
|
124
|
+
lines.push(formatWindow('seven_day', account.quota?.seven_day, now, account.quota?.bindingWindow));
|
|
105
125
|
for (const window of account.quota?.scoped ?? []) {
|
|
106
|
-
|
|
126
|
+
const line = formatScopedWindow(window, now);
|
|
127
|
+
lines.push(account.quota?.bindingWindow === window.id ? `${line} •` : line);
|
|
128
|
+
}
|
|
129
|
+
const extraUsage = account.quota?.extraUsage;
|
|
130
|
+
if (extraUsage) {
|
|
131
|
+
const used = formatQuotaMoney(extraUsage.used);
|
|
132
|
+
const limit = formatQuotaMoney(extraUsage.limit);
|
|
133
|
+
lines.push(` - credits ${used}/${limit}${extraUsage.exhausted ? ' · exhausted' : ''}`);
|
|
134
|
+
}
|
|
135
|
+
if (account.quota?.fallbackAdvised === true) {
|
|
136
|
+
lines.push(' - → fallback advised');
|
|
107
137
|
}
|
|
108
138
|
lines.push('');
|
|
109
139
|
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -24,5 +24,12 @@ export declare function sendViaRelay(options: {
|
|
|
24
24
|
fallback: () => Promise<Response>;
|
|
25
25
|
affinity?: string | null;
|
|
26
26
|
optimisticResponse?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Observes genuine upstream headers once per delivered response. WebSocket
|
|
29
|
+
* retries before downstream bytes replace staged headers, so the callback
|
|
30
|
+
* receives only the final accepted response; duplicate response_start frames
|
|
31
|
+
* within an attempt are ignored.
|
|
32
|
+
*/
|
|
33
|
+
onResponseHeaders?: (headers: Headers) => void;
|
|
27
34
|
}): Promise<Response>;
|
|
28
35
|
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
|
@@ -323,12 +323,12 @@ class PersistentRelaySession {
|
|
|
323
323
|
this.serverState = undefined;
|
|
324
324
|
this.connecting = undefined;
|
|
325
325
|
}
|
|
326
|
-
send(payload, bodyText, optimisticResponse = false) {
|
|
326
|
+
send(payload, bodyText, optimisticResponse = false, onResponseHeaders) {
|
|
327
327
|
this.touch();
|
|
328
328
|
const enqueuedAt = perfNowMs();
|
|
329
329
|
const start = this.queue
|
|
330
330
|
.catch(() => { })
|
|
331
|
-
.then(() => this.startQueued(payload, bodyText, enqueuedAt, optimisticResponse));
|
|
331
|
+
.then(() => this.startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders));
|
|
332
332
|
const result = start.then(async ({ response, getPayload }) => ({
|
|
333
333
|
response: await response,
|
|
334
334
|
payload: getPayload(),
|
|
@@ -339,7 +339,7 @@ class PersistentRelaySession {
|
|
|
339
339
|
this.queue = start.then(({ done }) => done.catch(() => { }), () => { });
|
|
340
340
|
return result;
|
|
341
341
|
}
|
|
342
|
-
async startQueued(payload, bodyText, enqueuedAt, optimisticResponse) {
|
|
342
|
+
async startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders) {
|
|
343
343
|
const connectStart = perfNowMs();
|
|
344
344
|
await this.ensureConnected();
|
|
345
345
|
const connectedAt = perfNowMs();
|
|
@@ -363,7 +363,7 @@ class PersistentRelaySession {
|
|
|
363
363
|
}
|
|
364
364
|
relayLog(`perf websocket send_start session=${shortAffinity(this.affinity)} request=${requestPayload.id} mode=${requestPayload.mode} queueMs=${formatMs(connectedAt - enqueuedAt)} connectMs=${formatMs(connectedAt - connectStart)} bodyBytes=${bodyText.length} relayBytes=${JSON.stringify(requestPayload).length}`);
|
|
365
365
|
let activePayload = requestPayload;
|
|
366
|
-
const first = this.sendPayload(requestPayload, bodyText, optimisticResponse);
|
|
366
|
+
const first = this.sendPayload(requestPayload, bodyText, optimisticResponse, onResponseHeaders);
|
|
367
367
|
void first.done.catch(() => { });
|
|
368
368
|
let activeDone = first.done;
|
|
369
369
|
const response = first.response.catch((error) => {
|
|
@@ -376,7 +376,7 @@ class PersistentRelaySession {
|
|
|
376
376
|
fullSync.id = createRequestId();
|
|
377
377
|
fullSync.revision = (this.serverState?.revision ?? 0) + 1;
|
|
378
378
|
activePayload = fullSync;
|
|
379
|
-
const retry = this.sendPayload(fullSync, bodyText, optimisticResponse);
|
|
379
|
+
const retry = this.sendPayload(fullSync, bodyText, optimisticResponse, onResponseHeaders);
|
|
380
380
|
void retry.done.catch(() => { });
|
|
381
381
|
activeDone = retry.done;
|
|
382
382
|
return retry.response;
|
|
@@ -457,7 +457,7 @@ class PersistentRelaySession {
|
|
|
457
457
|
});
|
|
458
458
|
await this.connecting;
|
|
459
459
|
}
|
|
460
|
-
sendPayload(payload, bodyText, optimisticResponse) {
|
|
460
|
+
sendPayload(payload, bodyText, optimisticResponse, onResponseHeaders) {
|
|
461
461
|
const socket = this.socket;
|
|
462
462
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
463
463
|
throw new Error('relay websocket is not connected');
|
|
@@ -486,6 +486,9 @@ class PersistentRelaySession {
|
|
|
486
486
|
streamByteCount: 0,
|
|
487
487
|
retryAttempts: 0,
|
|
488
488
|
retryingBeforeResponse: false,
|
|
489
|
+
onResponseHeaders,
|
|
490
|
+
stagedResponseHeaders: undefined,
|
|
491
|
+
responseHeadersDelivered: false,
|
|
489
492
|
};
|
|
490
493
|
this.pending = pending;
|
|
491
494
|
this.resetPendingTimeout(pending);
|
|
@@ -540,6 +543,8 @@ class PersistentRelaySession {
|
|
|
540
543
|
pending.accepted = false;
|
|
541
544
|
pending.acceptedAt = undefined;
|
|
542
545
|
pending.responseStartedAt = undefined;
|
|
546
|
+
pending.stagedResponseHeaders = undefined;
|
|
547
|
+
pending.responseHeadersDelivered = false;
|
|
543
548
|
pending.sentAt = perfNowMs();
|
|
544
549
|
pending.retryingBeforeResponse = false;
|
|
545
550
|
const socket = this.socket;
|
|
@@ -559,10 +564,21 @@ class PersistentRelaySession {
|
|
|
559
564
|
return true;
|
|
560
565
|
}
|
|
561
566
|
enqueueStreamChunk(pending, chunk) {
|
|
567
|
+
this.deliverResponseHeaders(pending);
|
|
562
568
|
pending.streamChunkCount += 1;
|
|
563
569
|
pending.streamByteCount += chunk.byteLength;
|
|
564
570
|
pending.streamController?.enqueue(chunk);
|
|
565
571
|
}
|
|
572
|
+
deliverResponseHeaders(pending) {
|
|
573
|
+
if (pending.responseHeadersDelivered ||
|
|
574
|
+
pending.stagedResponseHeaders == null)
|
|
575
|
+
return;
|
|
576
|
+
pending.responseHeadersDelivered = true;
|
|
577
|
+
try {
|
|
578
|
+
pending.onResponseHeaders?.(pending.stagedResponseHeaders);
|
|
579
|
+
}
|
|
580
|
+
catch { }
|
|
581
|
+
}
|
|
566
582
|
handleBinaryChunk(data) {
|
|
567
583
|
const pending = this.pending;
|
|
568
584
|
if (!pending?.responseStarted)
|
|
@@ -609,12 +625,17 @@ class PersistentRelaySession {
|
|
|
609
625
|
return;
|
|
610
626
|
}
|
|
611
627
|
if (message.type === 'response_start') {
|
|
628
|
+
if (pending.responseStartedAt != null)
|
|
629
|
+
return;
|
|
612
630
|
const responseStartedAt = perfNowMs();
|
|
613
631
|
pending.responseStartedAt = responseStartedAt;
|
|
614
632
|
relayLog(`perf websocket response_start session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(responseStartedAt - pending.sentAt)} upstreamMs=${pending.acceptedAt == null ? 'unknown' : formatMs(responseStartedAt - pending.acceptedAt)} status=${message.status}`);
|
|
615
633
|
clearTimeout(pending.timeout);
|
|
634
|
+
if (message.headers)
|
|
635
|
+
pending.stagedResponseHeaders = new Headers(message.headers);
|
|
616
636
|
this.resolvePendingResponse(pending, message.status, message.statusText, message.headers);
|
|
617
637
|
if (pending.optimisticResponse && message.status >= 400) {
|
|
638
|
+
this.deliverResponseHeaders(pending);
|
|
618
639
|
pending.streamController?.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_upstream_error', message: `Relay upstream returned HTTP ${message.status}` } })}\n\n`));
|
|
619
640
|
this.finishPending();
|
|
620
641
|
}
|
|
@@ -635,6 +656,7 @@ class PersistentRelaySession {
|
|
|
635
656
|
// inject an SSE error event and close cleanly.
|
|
636
657
|
if (pending.optimisticResponse && pending.responseStartedAt != null) {
|
|
637
658
|
relayLog(`websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}`);
|
|
659
|
+
this.deliverResponseHeaders(pending);
|
|
638
660
|
pending.streamController?.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_error', message: message.message || 'relay error' } })}\n\n`));
|
|
639
661
|
this.finishPending();
|
|
640
662
|
this.socket?.close();
|
|
@@ -661,6 +683,7 @@ class PersistentRelaySession {
|
|
|
661
683
|
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}`);
|
|
662
684
|
clearTimeout(pending.timeout);
|
|
663
685
|
if (!pending.streamDone) {
|
|
686
|
+
this.deliverResponseHeaders(pending);
|
|
664
687
|
pending.streamDone = true;
|
|
665
688
|
pending.streamController?.close();
|
|
666
689
|
}
|
|
@@ -727,7 +750,7 @@ function getPersistentRelaySession(config, affinity) {
|
|
|
727
750
|
return session;
|
|
728
751
|
}
|
|
729
752
|
export async function sendViaRelay(options) {
|
|
730
|
-
const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, } = options;
|
|
753
|
+
const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, onResponseHeaders, } = options;
|
|
731
754
|
if (!config || !isRelayableAnthropicRequest(input, body))
|
|
732
755
|
return fallback();
|
|
733
756
|
const affinity = explicitAffinity ||
|
|
@@ -769,7 +792,7 @@ export async function sendViaRelay(options) {
|
|
|
769
792
|
if (config.transport === 'websocket') {
|
|
770
793
|
try {
|
|
771
794
|
const session = getPersistentRelaySession(config, affinity);
|
|
772
|
-
result = await session.send(payload, bodyText, optimisticResponse === true);
|
|
795
|
+
result = await session.send(payload, bodyText, optimisticResponse === true, onResponseHeaders);
|
|
773
796
|
}
|
|
774
797
|
catch (error) {
|
|
775
798
|
relayLog(`websocket relay failed session=${shortAffinity(affinity)}; trying http relay: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -802,6 +825,12 @@ export async function sendViaRelay(options) {
|
|
|
802
825
|
});
|
|
803
826
|
if (!result.usedRelay)
|
|
804
827
|
return result.response;
|
|
828
|
+
if (result.transport === 'http') {
|
|
829
|
+
try {
|
|
830
|
+
onResponseHeaders?.(result.response.headers);
|
|
831
|
+
}
|
|
832
|
+
catch { }
|
|
833
|
+
}
|
|
805
834
|
if (result.transport === 'http') {
|
|
806
835
|
updateLocalRelayState(affinity, bodyText, result.payload.next_hash, result.payload.revision);
|
|
807
836
|
}
|
package/dist/routing.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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"];
|
|
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';
|
|
7
7
|
} | {
|
|
8
8
|
type: 'mode';
|
|
9
9
|
mode: RoutingMode;
|
|
10
|
+
} | {
|
|
11
|
+
type: 'reset';
|
|
10
12
|
} | {
|
|
11
13
|
type: 'usage';
|
|
12
14
|
};
|
package/dist/routing.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { createEmptyStorage, getAccountStoragePath, loadAccounts, saveAccounts, } from "./accounts.js";
|
|
2
2
|
export const CLAUDE_ROUTING_COMMAND_NAME = 'claude-routing';
|
|
3
|
-
export const ROUTING_MODES = [
|
|
3
|
+
export const ROUTING_MODES = [
|
|
4
|
+
'main-first',
|
|
5
|
+
'fallback-first',
|
|
6
|
+
'sticky-balanced',
|
|
7
|
+
];
|
|
4
8
|
export const DEFAULT_ROUTING_MODE = 'main-first';
|
|
5
|
-
const ROUTING_USAGE = 'Usage: `/claude-routing`, `/claude-routing main-first`,
|
|
9
|
+
const ROUTING_USAGE = 'Usage: `/claude-routing`, `/claude-routing main-first`, `/claude-routing fallback-first`, `/claude-routing sticky-balanced`, or `/claude-routing reset`.';
|
|
6
10
|
function normalizeRoutingMode(value) {
|
|
7
11
|
return typeof value === 'string' &&
|
|
8
12
|
ROUTING_MODES.includes(value)
|
|
@@ -25,6 +29,8 @@ export function parseRoutingCommandAction(argumentsText) {
|
|
|
25
29
|
const tokens = argumentsText.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
26
30
|
if (tokens.length === 0)
|
|
27
31
|
return { type: 'status' };
|
|
32
|
+
if (tokens.length === 1 && tokens[0] === 'reset')
|
|
33
|
+
return { type: 'reset' };
|
|
28
34
|
if (tokens.length === 1 && ROUTING_MODES.includes(tokens[0])) {
|
|
29
35
|
return { type: 'mode', mode: tokens[0] };
|
|
30
36
|
}
|
|
@@ -39,6 +45,9 @@ function routingDescription(mode) {
|
|
|
39
45
|
if (mode === 'fallback-first') {
|
|
40
46
|
return 'Try usable fallback accounts before the main account. If no fallback succeeds, try the main account.';
|
|
41
47
|
}
|
|
48
|
+
if (mode === 'sticky-balanced') {
|
|
49
|
+
return 'Assign each session to a quota-weighted OAuth account, keep it sticky across transient failures, and migrate only for confirmed long-lived exhaustion or permanent account failure.';
|
|
50
|
+
}
|
|
42
51
|
return 'Try the main account first. Use fallback accounts only when quota policy or fallback errors require it.';
|
|
43
52
|
}
|
|
44
53
|
export function buildRoutingStatusSummary(input) {
|
|
@@ -66,6 +75,15 @@ export function executeRoutingCommand(input) {
|
|
|
66
75
|
...buildRoutingStatusSummary({ mode: action.mode }).split('\n').slice(2),
|
|
67
76
|
].join('\n');
|
|
68
77
|
}
|
|
78
|
+
if (action.type === 'reset') {
|
|
79
|
+
return [
|
|
80
|
+
'## Claude Routing Assignment Reset',
|
|
81
|
+
'',
|
|
82
|
+
'The current session will be assigned again on its next request.',
|
|
83
|
+
'',
|
|
84
|
+
...buildRoutingStatusSummary({ mode }).split('\n').slice(2),
|
|
85
|
+
].join('\n');
|
|
86
|
+
}
|
|
69
87
|
return [
|
|
70
88
|
'## Claude Routing Usage',
|
|
71
89
|
'',
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type AccountStorage, type OAuthQuotaSnapshot } from './accounts.ts';
|
|
2
|
+
export declare const STICKY_ROUTING_MAIN_ACCOUNT_ID = "main";
|
|
3
|
+
export declare const STICKY_ROUTING_SHORT_RESET_GRACE_MS: number;
|
|
4
|
+
export declare const STICKY_ROUTING_ASSIGNMENT_TTL_MS: number;
|
|
5
|
+
export type StickyRouteFamily = 'fable' | 'opus' | 'general';
|
|
6
|
+
export type StickyRouteCandidate = {
|
|
7
|
+
accountId: string;
|
|
8
|
+
quota: OAuthQuotaSnapshot;
|
|
9
|
+
order: number;
|
|
10
|
+
};
|
|
11
|
+
export type StickyRouteAssignment = {
|
|
12
|
+
accountId: string;
|
|
13
|
+
family: StickyRouteFamily;
|
|
14
|
+
assignedAt: number;
|
|
15
|
+
lastSeenAt: number;
|
|
16
|
+
initialInputBytes: number;
|
|
17
|
+
quotaCheckedAt: number;
|
|
18
|
+
};
|
|
19
|
+
export type StickyRouteResolution = {
|
|
20
|
+
accountId: string;
|
|
21
|
+
assignment: StickyRouteAssignment;
|
|
22
|
+
created: boolean;
|
|
23
|
+
migrated: boolean;
|
|
24
|
+
};
|
|
25
|
+
export type StickyQuotaFailureDecision = {
|
|
26
|
+
action: 'retain';
|
|
27
|
+
reason: 'not-exhausted' | 'unknown';
|
|
28
|
+
} | {
|
|
29
|
+
action: 'hold';
|
|
30
|
+
reason: 'five-hour-short-reset';
|
|
31
|
+
retryAfterSeconds: number;
|
|
32
|
+
} | {
|
|
33
|
+
action: 'migrate';
|
|
34
|
+
reason: 'five-hour' | 'seven-day' | 'model-scoped';
|
|
35
|
+
};
|
|
36
|
+
export declare function stickyQuotaSnapshotIsFresh(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now?: number, modelId?: string): boolean;
|
|
37
|
+
export declare function stickyRouteFamilyForModel(model: unknown): StickyRouteFamily;
|
|
38
|
+
export declare function stickyRouteCandidateWeight(input: {
|
|
39
|
+
candidate: StickyRouteCandidate;
|
|
40
|
+
family: StickyRouteFamily;
|
|
41
|
+
modelId?: string;
|
|
42
|
+
storage: AccountStorage | null;
|
|
43
|
+
now?: number;
|
|
44
|
+
}): number;
|
|
45
|
+
export declare function stickyRouteKnownFableExhausted(candidate: StickyRouteCandidate, storage: AccountStorage | null, now?: number): boolean;
|
|
46
|
+
export declare function decideStickyQuotaFailure(input: {
|
|
47
|
+
quota: OAuthQuotaSnapshot | undefined;
|
|
48
|
+
modelId?: string;
|
|
49
|
+
now?: number;
|
|
50
|
+
}): StickyQuotaFailureDecision;
|
|
51
|
+
export declare function getStickyRoutingStatePath(accountStoragePath: string): string;
|
|
52
|
+
export declare function stickyRetryAfterWithJitter(sessionId: string, retryAfterSeconds: number): number;
|
|
53
|
+
export declare class StickySessionRouter {
|
|
54
|
+
private readonly options;
|
|
55
|
+
private loaded;
|
|
56
|
+
private state;
|
|
57
|
+
private stateMtimeNs;
|
|
58
|
+
private touchChain;
|
|
59
|
+
constructor(options: {
|
|
60
|
+
path: string;
|
|
61
|
+
now?: () => number;
|
|
62
|
+
assignmentTtlMs?: number;
|
|
63
|
+
});
|
|
64
|
+
private now;
|
|
65
|
+
private readState;
|
|
66
|
+
private readStateMtimeNs;
|
|
67
|
+
private refreshStateIfChanged;
|
|
68
|
+
private acquireLock;
|
|
69
|
+
private assignmentIsActive;
|
|
70
|
+
private prune;
|
|
71
|
+
private writeState;
|
|
72
|
+
private scheduleTouch;
|
|
73
|
+
private selectCandidate;
|
|
74
|
+
resolve(input: {
|
|
75
|
+
sessionId: string;
|
|
76
|
+
family: StickyRouteFamily;
|
|
77
|
+
modelId?: string;
|
|
78
|
+
candidates: readonly StickyRouteCandidate[];
|
|
79
|
+
retainAccountIds: ReadonlySet<string>;
|
|
80
|
+
storage: AccountStorage | null;
|
|
81
|
+
inputBytes: number;
|
|
82
|
+
preferredAccountId?: string;
|
|
83
|
+
excludeAccountIds?: ReadonlySet<string>;
|
|
84
|
+
}): Promise<StickyRouteResolution | null>;
|
|
85
|
+
clear(sessionId: string): Promise<void>;
|
|
86
|
+
}
|