@cortexkit/anthropic-auth-core 1.16.0 → 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/index.d.ts CHANGED
@@ -13,8 +13,10 @@ export * from './killswitch.ts';
13
13
  export * from './logger.ts';
14
14
  export * from './logging.ts';
15
15
  export * from './models.ts';
16
+ export * from './oauth-profile.ts';
16
17
  export * from './pkce.ts';
17
18
  export * from './provider.ts';
19
+ export * from './quota-headers.ts';
18
20
  export * from './quota-manager.ts';
19
21
  export * from './quotas.ts';
20
22
  export * from './relay.ts';
package/dist/index.js CHANGED
@@ -13,8 +13,10 @@ export * from "./killswitch.js";
13
13
  export * from "./logger.js";
14
14
  export * from "./logging.js";
15
15
  export * from "./models.js";
16
+ export * from "./oauth-profile.js";
16
17
  export * from "./pkce.js";
17
18
  export * from "./provider.js";
19
+ export * from "./quota-headers.js";
18
20
  export * from "./quota-manager.js";
19
21
  export * from "./quotas.js";
20
22
  export * from "./relay.js";
@@ -0,0 +1,11 @@
1
+ import type { OAuthAccountProfile } from './accounts.ts';
2
+ export declare const PROFILE_TTL_MS: number;
3
+ export declare function fetchOAuthAccountProfile(input: {
4
+ accessToken: string;
5
+ fetchImpl?: typeof fetch;
6
+ now?: () => number;
7
+ signal?: AbortSignal;
8
+ }): Promise<OAuthAccountProfile>;
9
+ export declare function oauthProfileMatchesToken(profile: OAuthAccountProfile | undefined, accessToken: string): boolean;
10
+ export declare function oauthProfileIsFresh(profile: OAuthAccountProfile | undefined, now?: number): boolean;
11
+ export declare function formatOAuthAccountTier(profile: OAuthAccountProfile | undefined): string | undefined;
@@ -0,0 +1,48 @@
1
+ import { tokenFingerprint } from "./quota-manager.js";
2
+ const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
3
+ export const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
4
+ export async function fetchOAuthAccountProfile(input) {
5
+ const response = await (input.fetchImpl ?? fetch)(PROFILE_URL, {
6
+ method: 'GET',
7
+ signal: input.signal,
8
+ headers: {
9
+ Authorization: `Bearer ${input.accessToken}`,
10
+ Accept: 'application/json',
11
+ 'anthropic-beta': 'oauth-2025-04-20',
12
+ },
13
+ });
14
+ if (!response.ok) {
15
+ throw new Error(`Claude profile check failed: ${response.status}`);
16
+ }
17
+ const value = (await response.json());
18
+ const tier = value.organization?.rate_limit_tier;
19
+ const orgType = value.organization?.organization_type;
20
+ if (typeof tier !== 'string' ||
21
+ tier.trim() === '' ||
22
+ typeof orgType !== 'string' ||
23
+ orgType.trim() === '') {
24
+ throw new Error('Claude profile response is missing account metadata');
25
+ }
26
+ return {
27
+ tier,
28
+ orgType,
29
+ checkedAt: input.now?.() ?? Date.now(),
30
+ tokenFingerprint: tokenFingerprint(input.accessToken),
31
+ };
32
+ }
33
+ export function oauthProfileMatchesToken(profile, accessToken) {
34
+ return profile?.tokenFingerprint === tokenFingerprint(accessToken);
35
+ }
36
+ export function oauthProfileIsFresh(profile, now = Date.now()) {
37
+ return Boolean(profile &&
38
+ Number.isFinite(profile.checkedAt) &&
39
+ now >= profile.checkedAt &&
40
+ now - profile.checkedAt < PROFILE_TTL_MS);
41
+ }
42
+ export function formatOAuthAccountTier(profile) {
43
+ const match = profile?.tier.match(/^default_claude_max_(\d+)x$/);
44
+ if (!match)
45
+ return undefined;
46
+ const label = `Max ${match[1]}x`;
47
+ return profile?.orgType === 'claude_team' ? `Team · ${label}` : label;
48
+ }
@@ -0,0 +1,4 @@
1
+ import type { OAuthQuotaSnapshot } from './accounts.ts';
2
+ export declare function isQuotaBearingHeaderFrame(headers: Headers): boolean;
3
+ export declare function normalizeQuotaHeaders(headers: Headers, now?: number): OAuthQuotaSnapshot;
4
+ export declare function mergeHeaderQuotaSnapshot(existing: OAuthQuotaSnapshot | undefined, incoming: OAuthQuotaSnapshot): OAuthQuotaSnapshot;
@@ -0,0 +1,66 @@
1
+ const PREFIX = 'anthropic-ratelimit-unified-';
2
+ const WINDOW_KEYS = {
3
+ '5h': 'five_hour',
4
+ '7d': 'seven_day',
5
+ };
6
+ export function isQuotaBearingHeaderFrame(headers) {
7
+ return Object.keys(WINDOW_KEYS).some((suffix) => Number.isFinite(finiteHeaderNumber(headers, `${PREFIX}${suffix}-utilization`)));
8
+ }
9
+ function finiteHeaderNumber(headers, name) {
10
+ const value = headers.get(name);
11
+ if (value == null || value.trim() === '')
12
+ return undefined;
13
+ const parsed = Number(value);
14
+ return Number.isFinite(parsed) ? parsed : undefined;
15
+ }
16
+ function normalizeWindow(headers, suffix, checkedAt) {
17
+ const utilization = finiteHeaderNumber(headers, `${PREFIX}${suffix}-utilization`);
18
+ if (utilization == null)
19
+ return undefined;
20
+ const usedPercent = Math.min(100, Math.max(0, Math.round(utilization * 100)));
21
+ const resetSeconds = finiteHeaderNumber(headers, `${PREFIX}${suffix}-reset`);
22
+ const resetDate = resetSeconds == null ? undefined : new Date(resetSeconds * 1000);
23
+ const resetsAt = resetDate && Number.isFinite(resetDate.getTime())
24
+ ? resetDate.toISOString()
25
+ : undefined;
26
+ return {
27
+ usedPercent,
28
+ remainingPercent: 100 - usedPercent,
29
+ ...(resetsAt && { resetsAt }),
30
+ checkedAt,
31
+ };
32
+ }
33
+ export function normalizeQuotaHeaders(headers, now = Date.now()) {
34
+ const snapshot = {
35
+ fallbackAdvised: headers.get(`${PREFIX}fallback`) === 'available',
36
+ source: 'headers',
37
+ checkedAt: now,
38
+ };
39
+ for (const [suffix, key] of Object.entries(WINDOW_KEYS)) {
40
+ const window = normalizeWindow(headers, suffix, now);
41
+ if (window)
42
+ snapshot[key] = window;
43
+ }
44
+ const representativeClaim = headers.get(`${PREFIX}representative-claim`);
45
+ if (representativeClaim) {
46
+ snapshot.bindingWindow = representativeClaim;
47
+ snapshot.bindingWindowSource = 'headers';
48
+ }
49
+ return snapshot;
50
+ }
51
+ export function mergeHeaderQuotaSnapshot(existing, incoming) {
52
+ return {
53
+ ...existing,
54
+ ...incoming,
55
+ scoped: existing && 'scoped' in existing ? existing.scoped : undefined,
56
+ extraUsage: existing?.extraUsage,
57
+ bindingWindow: existing?.bindingWindowSource === 'poll'
58
+ ? existing.bindingWindow
59
+ : (incoming.bindingWindow ?? existing?.bindingWindow),
60
+ bindingWindowSource: existing?.bindingWindowSource === 'poll'
61
+ ? 'poll'
62
+ : (incoming.bindingWindowSource ?? existing?.bindingWindowSource),
63
+ // Source tracks 5h/7d freshness; preserved scoped and credit fields remain poll-owned.
64
+ source: 'headers',
65
+ };
66
+ }
@@ -6,12 +6,7 @@
6
6
  * Handles deduplication, rate-limiting (429 backoff), and staleness.
7
7
  */
8
8
  import type { AccountOperationError, AccountStorage, OAuthAccount, OAuthQuotaSnapshot } from './accounts.ts';
9
- /**
10
- * Stable, non-reversible fingerprint of an access token. Used to detect a
11
- * main-account switch so a different account's persisted/cached quota is never
12
- * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
13
- */
14
- export declare function tokenFingerprint(token: string): string;
9
+ export { tokenFingerprint } from './token-fingerprint.ts';
15
10
  export type QuotaEntry = {
16
11
  quota: OAuthQuotaSnapshot;
17
12
  refreshAfter: number;
@@ -60,6 +55,8 @@ export declare class QuotaManager {
60
55
  getAllFallbacks(): Map<string, QuotaEntry>;
61
56
  setMain(accessToken: string, entry: QuotaEntry): void;
62
57
  setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void;
58
+ pushMainFromHeaders(accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
59
+ pushFallbackFromHeaders(accountId: string, accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
63
60
  refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
64
61
  refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
65
62
  refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
@@ -5,17 +5,32 @@
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 { createHash } from 'node:crypto';
9
8
  import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, quotaBackoffActive, } from "./accounts.js";
9
+ import { mergeHeaderQuotaSnapshot } from "./quota-headers.js";
10
+ export { tokenFingerprint } from "./token-fingerprint.js";
11
+ import { tokenFingerprint } from "./token-fingerprint.js";
10
12
  // Capture real setTimeout before tests can mock globalThis.setTimeout
11
13
  const nativeSetTimeout = globalThis.setTimeout;
12
- /**
13
- * Stable, non-reversible fingerprint of an access token. Used to detect a
14
- * main-account switch so a different account's persisted/cached quota is never
15
- * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
16
- */
17
- export function tokenFingerprint(token) {
18
- return createHash('sha256').update(token).digest('hex').slice(0, 16);
14
+ function mergePollCompletionWithNewerHeaders(current, polled) {
15
+ if (current?.source !== 'headers')
16
+ return polled;
17
+ const fiveHourIsNewer = Boolean(current.five_hour &&
18
+ current.five_hour.checkedAt > (polled.five_hour?.checkedAt ?? 0));
19
+ const sevenDayIsNewer = Boolean(current.seven_day &&
20
+ current.seven_day.checkedAt > (polled.seven_day?.checkedAt ?? 0));
21
+ if (!fiveHourIsNewer && !sevenDayIsNewer)
22
+ return polled;
23
+ return mergeHeaderQuotaSnapshot(polled, {
24
+ ...(fiveHourIsNewer && { five_hour: current.five_hour }),
25
+ ...(sevenDayIsNewer && { seven_day: current.seven_day }),
26
+ fallbackAdvised: current.fallbackAdvised,
27
+ ...(current.bindingWindowSource === 'headers' && {
28
+ bindingWindow: current.bindingWindow,
29
+ bindingWindowSource: current.bindingWindowSource,
30
+ }),
31
+ source: 'headers',
32
+ checkedAt: Math.max(fiveHourIsNewer ? (current.five_hour?.checkedAt ?? 0) : 0, sevenDayIsNewer ? (current.seven_day?.checkedAt ?? 0) : 0),
33
+ });
19
34
  }
20
35
  // ---------------------------------------------------------------------------
21
36
  // Class
@@ -116,6 +131,29 @@ export class QuotaManager {
116
131
  this.fallbackTokenFps.delete(accountId);
117
132
  }
118
133
  }
134
+ pushMainFromHeaders(accessToken, incoming) {
135
+ const checkedAt = incoming.checkedAt ?? this.now();
136
+ const accessTokenFp = tokenFingerprint(accessToken);
137
+ const quota = mergeHeaderQuotaSnapshot(this.mainTokenFp === accessTokenFp ? this.main?.quota : undefined, incoming);
138
+ const entry = {
139
+ quota,
140
+ checkedAt,
141
+ refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
142
+ };
143
+ this.setMain(accessToken, entry);
144
+ return entry;
145
+ }
146
+ pushFallbackFromHeaders(accountId, accessToken, incoming) {
147
+ const checkedAt = incoming.checkedAt ?? this.now();
148
+ const quota = mergeHeaderQuotaSnapshot(this.getFallback(accountId, accessToken)?.quota, incoming);
149
+ const entry = {
150
+ quota,
151
+ checkedAt,
152
+ refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
153
+ };
154
+ this.setFallback(accountId, entry, accessToken);
155
+ return entry;
156
+ }
119
157
  // =========================================================================
120
158
  // Refresh (async, deduplicated, rate-limited)
121
159
  // =========================================================================
@@ -380,15 +418,16 @@ export class QuotaManager {
380
418
  now: this.now,
381
419
  });
382
420
  const now = this.now();
421
+ const completedQuota = mergePollCompletionWithNewerHeaders(this.mainTokenFp === thisFetchFp ? this.main?.quota : undefined, quota);
383
422
  this.mainTokenFp = tokenFingerprint(accessToken);
384
423
  this.main = {
385
- quota,
386
- refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now),
387
- checkedAt: now,
424
+ quota: completedQuota,
425
+ refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
426
+ checkedAt: completedQuota.checkedAt ?? now,
388
427
  };
389
428
  this.mainLastApiError = undefined;
390
- this.onMainQuotaFetched?.(quota, now, this.mainTokenFp, fetchStartedAt);
391
- return quota;
429
+ this.onMainQuotaFetched?.(completedQuota, now, this.mainTokenFp, fetchStartedAt);
430
+ return completedQuota;
392
431
  }
393
432
  catch (error) {
394
433
  this._handleMainFetchError(error);
@@ -433,14 +472,15 @@ export class QuotaManager {
433
472
  now: this.now,
434
473
  });
435
474
  const now = this.now();
475
+ const completedQuota = mergePollCompletionWithNewerHeaders(this.getFallback(accountId, accessToken)?.quota, quota);
436
476
  this.setFallback(accountId, {
437
- quota,
438
- refreshAfter: now + getQuotaCheckIntervalMs(this.storage),
439
- checkedAt: now,
477
+ quota: completedQuota,
478
+ refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
479
+ checkedAt: completedQuota.checkedAt ?? now,
440
480
  }, accessToken);
441
481
  this.fallbackApiErrors.delete(accountId);
442
482
  this.fallbackErrorTokenFps.delete(accountId);
443
- return quota;
483
+ return completedQuota;
444
484
  }
445
485
  finally {
446
486
  await fileLock.release();
package/dist/quotas.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AccountStorage, OAuthQuotaSnapshot } from './accounts.ts';
1
+ import type { AccountStorage, OAuthQuotaSnapshot, QuotaMoney } from './accounts.ts';
2
2
  export declare const CLAUDE_QUOTAS_COMMAND_NAME = "claude-quota";
3
3
  export type QuotaAccountSummary = {
4
4
  name: string;
@@ -7,14 +7,17 @@ export type QuotaAccountSummary = {
7
7
  quota?: OAuthQuotaSnapshot;
8
8
  lastRefreshedAt?: number;
9
9
  error?: string;
10
+ tierLabel?: string;
10
11
  };
12
+ export declare function formatQuotaMoney(money: QuotaMoney): string;
11
13
  export declare function buildFallbackQuotaSummaries(storage: AccountStorage | null, errors?: ReadonlyMap<string, string>): {
12
14
  error?: string | undefined;
15
+ lastRefreshedAt: number | undefined;
16
+ tierLabel?: string | undefined;
13
17
  name: string;
14
18
  role: "fallback";
15
19
  enabled: boolean;
16
20
  quota: OAuthQuotaSnapshot | undefined;
17
- lastRefreshedAt: number | undefined;
18
21
  }[];
19
22
  export declare function buildClaudeQuotaSummary(input: {
20
23
  accounts: QuotaAccountSummary[];
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
- return [
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
- lines.push(formatScopedWindow(window, now));
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
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Stable, non-reversible fingerprint used to bind persisted state to an access
3
+ * token without storing another copy of the credential.
4
+ */
5
+ export declare function tokenFingerprint(token: string): string;
@@ -0,0 +1,8 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Stable, non-reversible fingerprint used to bind persisted state to an access
4
+ * token without storing another copy of the credential.
5
+ */
6
+ export function tokenFingerprint(token) {
7
+ return createHash('sha256').update(token).digest('hex').slice(0, 16);
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",