@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.
- package/dist/accounts.d.ts +72 -2
- package/dist/accounts.js +390 -26
- package/dist/auth.d.ts +3 -0
- package/dist/auth.js +5 -3
- package/dist/cache1h.d.ts +1 -1
- package/dist/cache1h.js +1 -1
- package/dist/cachekeep.d.ts +22 -2
- package/dist/cachekeep.js +137 -30
- package/dist/cch.d.ts +1 -0
- package/dist/cch.js +6 -1
- package/dist/claude-code.d.ts +2 -1
- package/dist/claude-code.js +3 -3
- package/dist/commands/account.js +1 -1
- package/dist/constants.d.ts +3 -3
- package/dist/dump.d.ts +12 -1
- package/dist/dump.js +166 -22
- package/dist/index.d.ts +2 -0
- package/dist/index.js +26 -24
- package/dist/killswitch.js +1 -1
- package/dist/models.d.ts +22 -6
- package/dist/models.js +16 -0
- package/dist/oauth-profile.js +1 -1
- package/dist/prime.d.ts +219 -0
- package/dist/prime.js +823 -0
- package/dist/quota-manager.d.ts +7 -0
- package/dist/quota-manager.js +24 -15
- package/dist/quotas.d.ts +4 -4
- package/dist/quotas.js +2 -2
- package/dist/relay.d.ts +4 -0
- package/dist/relay.js +29 -18
- package/dist/routing.d.ts +1 -1
- package/dist/routing.js +1 -1
- package/dist/start.d.ts +13 -0
- package/dist/start.js +29 -0
- package/dist/sticky-routing.d.ts +13 -1
- package/dist/sticky-routing.js +58 -2
- package/package.json +1 -1
package/dist/quota-manager.d.ts
CHANGED
|
@@ -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.
|
package/dist/quota-manager.js
CHANGED
|
@@ -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
|
|
9
|
-
import { mergeHeaderQuotaSnapshot } from
|
|
10
|
-
export { tokenFingerprint } from
|
|
11
|
-
import { tokenFingerprint } from
|
|
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 =
|
|
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:
|
|
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
|
|
2
|
-
import { formatOAuthAccountTier } from
|
|
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
|
|
4
|
-
import { relayLog } from
|
|
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 [
|
|
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
|
|
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',
|
package/dist/start.d.ts
ADDED
|
@@ -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
|
+
}
|
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,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: {
|
package/dist/sticky-routing.js
CHANGED
|
@@ -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
|
|
5
|
-
import { isClaudeFableOrMythos5Model } from
|
|
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
|
}
|