@cortexkit/anthropic-auth-core 1.1.2 → 1.2.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.
@@ -89,6 +89,14 @@ export type AccountRefreshError = {
89
89
  export declare function getAccountStoragePath(): string;
90
90
  export declare function loadAccounts(path?: string): Promise<AccountStorage | null>;
91
91
  export declare function saveAccounts(storage: AccountStorage, path?: string): Promise<void>;
92
+ export declare function acquireRefreshFileLock(options: {
93
+ name: string;
94
+ ttlMs: number;
95
+ path?: string;
96
+ now?: () => number;
97
+ }): Promise<{
98
+ release: () => Promise<void>;
99
+ } | null>;
92
100
  export declare function isCache1hPersistentlyEnabled(storage: AccountStorage | null): boolean;
93
101
  export declare function getCache1hPersistentMode(storage: AccountStorage | null): Cache1hMode;
94
102
  export declare function setCache1hPersistentEnabled(enabled: boolean, mode?: Cache1hMode, path?: string): Promise<AccountStorage>;
@@ -133,7 +141,7 @@ export declare class FallbackAccountManager {
133
141
  save(storage: AccountStorage): Promise<void>;
134
142
  startBackgroundRefresh(): void;
135
143
  stopBackgroundRefresh(): void;
136
- getUsableFallbackAccounts(): Promise<OAuthAccount[]>;
144
+ getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
137
145
  markUsed(account: OAuthAccount): Promise<void>;
138
146
  accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
139
147
  refreshDueAccounts(): Promise<void>;
package/dist/accounts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from "./auth.js";
@@ -8,7 +8,8 @@ import { log } from "./logger.js";
8
8
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
9
9
  export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
10
10
  const DEFAULT_FALLBACK_ON = [401, 403, 429];
11
- const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = 30;
11
+ const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
12
+ const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
12
13
  const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
13
14
  const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
14
15
  const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
@@ -159,6 +160,51 @@ export async function saveAccounts(storage, path = getAccountStoragePath()) {
159
160
  });
160
161
  await rename(tempPath, path);
161
162
  }
163
+ export async function acquireRefreshFileLock(options) {
164
+ const accountPath = options.path ?? getAccountStoragePath();
165
+ const lockDir = `${accountPath}.${options.name}.lock`;
166
+ const ownerPath = join(lockDir, 'owner.json');
167
+ const ownerId = randomUUID();
168
+ const now = options.now ?? Date.now;
169
+ async function tryAcquire() {
170
+ try {
171
+ await mkdir(lockDir, { mode: 0o700 });
172
+ await writeFile(ownerPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600 });
173
+ return true;
174
+ }
175
+ catch (error) {
176
+ if (error.code !== 'EEXIST')
177
+ throw error;
178
+ return false;
179
+ }
180
+ }
181
+ if (!(await tryAcquire())) {
182
+ try {
183
+ const owner = JSON.parse(await readFile(ownerPath, 'utf8'));
184
+ if (Number(owner?.expiresAt) > now())
185
+ return null;
186
+ }
187
+ catch {
188
+ // Broken owner metadata is treated as stale.
189
+ }
190
+ await rm(lockDir, { recursive: true, force: true }).catch(() => { });
191
+ if (!(await tryAcquire()))
192
+ return null;
193
+ }
194
+ return {
195
+ release: async () => {
196
+ try {
197
+ const owner = JSON.parse(await readFile(ownerPath, 'utf8'));
198
+ if (owner?.ownerId !== ownerId)
199
+ return;
200
+ }
201
+ catch {
202
+ return;
203
+ }
204
+ await rm(lockDir, { recursive: true, force: true }).catch(() => { });
205
+ },
206
+ };
207
+ }
162
208
  export function isCache1hPersistentlyEnabled(storage) {
163
209
  return storage?.claudeCache?.enabled === true;
164
210
  }
@@ -284,7 +330,7 @@ function refreshEnabled(storage) {
284
330
  function refreshBeforeExpiryMs(storage) {
285
331
  const minutes = storage?.refresh?.refreshBeforeExpiryMinutes ??
286
332
  DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
287
- return Math.max(0, minutes) * 60_000;
333
+ return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
288
334
  }
289
335
  export function getRefreshIntervalMs(storage) {
290
336
  const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
@@ -498,8 +544,8 @@ export class FallbackAccountManager {
498
544
  this.refreshTimer = null;
499
545
  this.quotaTimer = null;
500
546
  }
501
- async getUsableFallbackAccounts() {
502
- const storage = await this.load();
547
+ async getUsableFallbackAccounts(existingStorage) {
548
+ const storage = existingStorage !== undefined ? existingStorage : await this.load();
503
549
  if (!storage)
504
550
  return [];
505
551
  const usable = [];
package/dist/auth.d.ts CHANGED
@@ -13,6 +13,8 @@ export declare function refreshClaudeOAuthToken(input: {
13
13
  refreshToken: string;
14
14
  fetchImpl?: typeof fetch;
15
15
  now?: () => number;
16
+ maxRetries?: number;
17
+ baseDelayMs?: number;
16
18
  }): Promise<ClaudeOAuthRefreshResult>;
17
19
  export type AuthorizationResult = {
18
20
  url: string;
package/dist/auth.js CHANGED
@@ -10,32 +10,64 @@ export class ClaudeOAuthRefreshError extends Error {
10
10
  this.name = 'ClaudeOAuthRefreshError';
11
11
  }
12
12
  }
13
+ function isTransientNetworkError(error) {
14
+ if (!(error instanceof Error))
15
+ return false;
16
+ const code = error.code;
17
+ return (error.message.includes('fetch failed') ||
18
+ code === 'ECONNRESET' ||
19
+ code === 'ECONNREFUSED' ||
20
+ code === 'ETIMEDOUT' ||
21
+ code === 'UND_ERR_CONNECT_TIMEOUT');
22
+ }
13
23
  export async function refreshClaudeOAuthToken(input) {
14
24
  const fetchImpl = input.fetchImpl ?? fetch;
15
- const response = await fetchImpl(TOKEN_URL, {
16
- method: 'POST',
17
- headers: {
18
- 'Content-Type': 'application/json',
19
- Accept: 'application/json',
20
- },
21
- body: JSON.stringify({
22
- grant_type: 'refresh_token',
23
- refresh_token: input.refreshToken,
24
- client_id: CLIENT_ID,
25
- }),
26
- });
27
- if (!response.ok) {
28
- const body = await response.text().catch(() => '');
29
- throw new ClaudeOAuthRefreshError(response.status, body);
25
+ const maxRetries = input.maxRetries ?? 2;
26
+ const baseDelayMs = input.baseDelayMs ?? 500;
27
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
28
+ try {
29
+ if (attempt > 0) {
30
+ const delay = baseDelayMs * 2 ** (attempt - 1);
31
+ await new Promise((resolve) => setTimeout(resolve, delay));
32
+ }
33
+ const response = await fetchImpl(TOKEN_URL, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ Accept: 'application/json',
38
+ },
39
+ body: JSON.stringify({
40
+ grant_type: 'refresh_token',
41
+ refresh_token: input.refreshToken,
42
+ client_id: CLIENT_ID,
43
+ }),
44
+ });
45
+ if (!response.ok) {
46
+ if (response.status >= 500 && attempt < maxRetries) {
47
+ await response.body?.cancel().catch(() => { });
48
+ continue;
49
+ }
50
+ const body = await response.text().catch(() => '');
51
+ throw new ClaudeOAuthRefreshError(response.status, body);
52
+ }
53
+ const json = (await response.json());
54
+ const refreshedAt = input.now?.() ?? Date.now();
55
+ return {
56
+ access: json.access_token,
57
+ refresh: json.refresh_token ?? input.refreshToken,
58
+ expires: refreshedAt + json.expires_in * 1000,
59
+ expiresIn: json.expires_in,
60
+ };
61
+ }
62
+ catch (error) {
63
+ if (error instanceof ClaudeOAuthRefreshError)
64
+ throw error;
65
+ if (attempt < maxRetries && isTransientNetworkError(error))
66
+ continue;
67
+ throw error;
68
+ }
30
69
  }
31
- const json = (await response.json());
32
- const refreshedAt = input.now?.() ?? Date.now();
33
- return {
34
- access: json.access_token,
35
- refresh: json.refresh_token ?? input.refreshToken,
36
- expires: refreshedAt + json.expires_in * 1000,
37
- expiresIn: json.expires_in,
38
- };
70
+ throw new Error('Token refresh exhausted all retries');
39
71
  }
40
72
  function generateState() {
41
73
  return crypto.randomUUID().replace(/-/g, '');
package/dist/relay.d.ts CHANGED
@@ -25,4 +25,4 @@ export declare function sendViaRelay(options: {
25
25
  affinity?: string | null;
26
26
  optimisticResponse?: boolean;
27
27
  }): Promise<Response>;
28
- 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 await writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision })\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 }\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\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 }\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 }, 5000)\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 while (true) {\n const { done, value } = await reader.read()\n if (done) break\n socket.send(value)\n }\n }\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id }))\n } catch (error) {\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') return new Response('ok')\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 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 const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n },\n}\n";
28
+ 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\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 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 while (true) {\n const { done, value } = await reader.read()\n if (done) break\n socket.send(value)\n }\n }\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id }))\n } catch (error) {\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
@@ -544,6 +544,17 @@ class PersistentRelaySession {
544
544
  return;
545
545
  }
546
546
  if (message.type === 'error') {
547
+ // If an optimistic response is already streaming (responseStartedAt is
548
+ // set), the pending promise was already resolved — calling failPending
549
+ // would reject a settled promise and leave the stream hanging. Instead,
550
+ // inject an SSE error event and close cleanly.
551
+ if (pending.optimisticResponse && pending.responseStartedAt != null) {
552
+ relayLog(`websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'}`);
553
+ pending.streamController?.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_error', message: message.message || 'relay error' } })}\n\n`));
554
+ this.finishPending();
555
+ this.socket?.close();
556
+ return;
557
+ }
547
558
  if (message.status === 409 &&
548
559
  this.retryPendingBeforeResponse(pending, message.message || 'state mismatch')) {
549
560
  return;
@@ -718,7 +729,7 @@ async function prepareUpstream(env, payload) {
718
729
  return { error: 'hash mismatch', status: 409 }
719
730
  }
720
731
 
721
- await writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision })
732
+ const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})
722
733
  console.log(JSON.stringify({
723
734
  relay: 'opencode-anthropic-auth',
724
735
  transport: 'relay',
@@ -728,7 +739,7 @@ async function prepareUpstream(env, payload) {
728
739
  bodyBytes: body.length,
729
740
  }))
730
741
 
731
- return { body }
742
+ return { body, stateWrite }
732
743
  }
733
744
 
734
745
  function resolveBodyFromState(state, payload) {
@@ -805,7 +816,7 @@ async function handleRelayPayload(env, payload) {
805
816
  headers: payload.upstream.headers,
806
817
  body: prepared.body,
807
818
  })
808
- return { upstream }
819
+ return { upstream, stateWrite: prepared.stateWrite }
809
820
  }
810
821
 
811
822
  async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
@@ -815,7 +826,7 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
815
826
  } catch {
816
827
  clearInterval(heartbeat)
817
828
  }
818
- }, 5000)
829
+ }, 15000)
819
830
  try {
820
831
  const result = await prepareWebSocketUpstream(env, getState(), payload)
821
832
  if (result.error) {
@@ -914,22 +925,33 @@ export default {
914
925
  return new Response(null, { status: 101, webSocket: client })
915
926
  }
916
927
 
917
- if (request.method === 'GET') return new Response('ok')
928
+ if (request.method === 'GET') {
929
+ return Response.json({ status: 'ok', transports: ['http', 'websocket'] })
930
+ }
918
931
  if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })
919
932
  if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {
920
933
  return new Response('unauthorized', { status: 401 })
921
934
  }
922
935
 
923
- const payload = await request.json()
924
- const result = await handleRelayPayload(env, payload)
925
- if (result.error) return Response.json({ error: result.error }, { status: result.status })
936
+ try {
937
+ const payload = await request.json()
938
+ const result = await handleRelayPayload(env, payload)
939
+ if (result.error) return Response.json({ error: result.error }, { status: result.status })
926
940
 
927
- const upstream = result.upstream
928
- return new Response(upstream.body, {
929
- status: upstream.status,
930
- statusText: upstream.statusText,
931
- headers: upstream.headers,
932
- })
941
+ if (result.stateWrite) ctx.waitUntil(result.stateWrite)
942
+
943
+ const upstream = result.upstream
944
+ return new Response(upstream.body, {
945
+ status: upstream.status,
946
+ statusText: upstream.statusText,
947
+ headers: upstream.headers,
948
+ })
949
+ } catch (error) {
950
+ return Response.json(
951
+ { error: error instanceof Error ? error.message : 'internal relay error' },
952
+ { status: 502 },
953
+ )
954
+ }
933
955
  },
934
956
  }
935
957
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",