@cortexkit/anthropic-auth-core 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @cortexkit/anthropic-auth-core
2
2
 
3
- Shared Anthropic OAuth, quota, cache, relay, dump, and request-signing helpers used by CortexKit's OpenCode and Pi integrations.
3
+ Shared Anthropic OAuth, quota, cache, fast mode, relay, dump, and request-signing helpers used by CortexKit's OpenCode and Pi integrations.
4
4
 
5
5
  User-facing packages:
6
6
 
@@ -20,6 +20,9 @@ export type OAuthAccount = {
20
20
  export type AccountOperationError = {
21
21
  message: string;
22
22
  checkedAt: number;
23
+ nextRetryAt?: number;
24
+ retryCount?: number;
25
+ tokenHash?: string;
23
26
  };
24
27
  export type AccountQuotaWindow = {
25
28
  usedPercent: number;
@@ -38,6 +41,10 @@ export type AccountStorage = {
38
41
  enabled?: boolean;
39
42
  intervalMinutes?: number;
40
43
  refreshBeforeExpiryMinutes?: number;
44
+ mainLastRefreshError?: AccountOperationError;
45
+ mainRefreshLeaseId?: string;
46
+ mainRefreshLeaseUntil?: number;
47
+ mainRefreshLeaseTokenHash?: string;
41
48
  };
42
49
  quota?: {
43
50
  enabled?: boolean;
@@ -52,6 +59,9 @@ export type AccountStorage = {
52
59
  dump?: {
53
60
  enabled?: boolean;
54
61
  };
62
+ claudeFast?: {
63
+ enabled?: boolean;
64
+ };
55
65
  relay?: {
56
66
  enabled?: boolean;
57
67
  url?: string;
@@ -80,7 +90,19 @@ export declare function setCache1hPersistentEnabled(enabled: boolean, mode?: Cac
80
90
  export declare function setCache1hPersistentMode(mode: Cache1hMode, path?: string): Promise<AccountStorage>;
81
91
  export declare function isDumpPersistentlyEnabled(storage: AccountStorage | null): boolean;
82
92
  export declare function setDumpPersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
93
+ export declare function isFastModePersistentlyEnabled(storage: AccountStorage | null): boolean;
94
+ export declare function setFastModePersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
83
95
  export declare function shouldFallbackStatus(status: number, storage: AccountStorage | null): boolean;
96
+ export declare function getRefreshIntervalMs(storage: AccountStorage | null): number;
97
+ export declare function hashRefreshToken(refreshToken: string): string;
98
+ export declare function buildRefreshOperationError(input: {
99
+ error: unknown;
100
+ now: number;
101
+ refreshToken: string;
102
+ previous?: AccountOperationError;
103
+ }): AccountOperationError;
104
+ export declare function refreshBackoffActive(error: AccountOperationError | undefined, refreshToken: string | undefined, now: number): boolean;
105
+ export declare function formatRefreshBackoffMessage(error: AccountOperationError, now: number): string;
84
106
  export declare function getQuotaCheckIntervalMs(storage: AccountStorage | null): number;
85
107
  export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
86
108
  export declare function getQuotaNextRefreshAt(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now: number): number;
package/dist/accounts.js CHANGED
@@ -1,12 +1,17 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, CLIENT_ID, DEFAULT_CACHE_1H_MODE, TOKEN_URL, } from "./constants.js";
5
+ import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from "./auth.js";
6
+ import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
6
7
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
7
8
  export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
8
9
  const DEFAULT_FALLBACK_ON = [401, 403, 429];
9
10
  const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = 30;
11
+ const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
12
+ const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
13
+ const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
14
+ const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000;
10
15
  const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5;
11
16
  const DEFAULT_MINIMUM_REMAINING = {
12
17
  five_hour: 0,
@@ -62,7 +67,15 @@ function normalizeOperationError(value) {
62
67
  const checkedAt = Number(value.checkedAt);
63
68
  if (!Number.isFinite(checkedAt))
64
69
  return undefined;
65
- return { message: value.message, checkedAt };
70
+ const nextRetryAt = Number(value.nextRetryAt);
71
+ const retryCount = Number(value.retryCount);
72
+ return {
73
+ message: value.message,
74
+ checkedAt,
75
+ nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
76
+ retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
77
+ tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
78
+ };
66
79
  }
67
80
  function normalizeQuota(value) {
68
81
  if (!isRecord(value))
@@ -102,6 +115,7 @@ function normalizeStorage(value) {
102
115
  quota: isRecord(value.quota) ? value.quota : undefined,
103
116
  claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
104
117
  dump: isRecord(value.dump) ? value.dump : undefined,
118
+ claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
105
119
  relay: isRecord(value.relay) ? value.relay : undefined,
106
120
  accounts: value.accounts
107
121
  .map(normalizeAccount)
@@ -199,6 +213,22 @@ export async function setDumpPersistentEnabled(enabled, path = getAccountStorage
199
213
  await saveAccounts(storage, path);
200
214
  return storage;
201
215
  }
216
+ export function isFastModePersistentlyEnabled(storage) {
217
+ return storage?.claudeFast?.enabled === true;
218
+ }
219
+ export async function setFastModePersistentEnabled(enabled, path = getAccountStoragePath()) {
220
+ const storage = (await loadAccounts(path)) ?? {
221
+ version: 1,
222
+ main: { type: 'opencode', provider: 'anthropic' },
223
+ accounts: [],
224
+ };
225
+ storage.claudeFast = {
226
+ ...(storage.claudeFast ?? {}),
227
+ enabled,
228
+ };
229
+ await saveAccounts(storage, path);
230
+ return storage;
231
+ }
202
232
  function getFallbackStatuses(storage) {
203
233
  return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
204
234
  }
@@ -227,6 +257,50 @@ function refreshBeforeExpiryMs(storage) {
227
257
  DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
228
258
  return Math.max(0, minutes) * 60_000;
229
259
  }
260
+ export function getRefreshIntervalMs(storage) {
261
+ const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
262
+ return Math.max(1, minutes) * 60_000;
263
+ }
264
+ export function hashRefreshToken(refreshToken) {
265
+ return createHash('sha256').update(refreshToken).digest('hex');
266
+ }
267
+ function isTransientRefreshError(error) {
268
+ if (error instanceof ClaudeOAuthRefreshError) {
269
+ return error.status === 429 || error.status >= 500;
270
+ }
271
+ if (!(error instanceof Error))
272
+ return false;
273
+ return (error.message.includes('fetch failed') ||
274
+ ('code' in error &&
275
+ (error.code === 'ECONNRESET' ||
276
+ error.code === 'ECONNREFUSED' ||
277
+ error.code === 'ETIMEDOUT' ||
278
+ error.code === 'UND_ERR_CONNECT_TIMEOUT')));
279
+ }
280
+ export function buildRefreshOperationError(input) {
281
+ const retryCount = (input.previous?.retryCount ?? 0) + 1;
282
+ const delay = isTransientRefreshError(input.error)
283
+ ? Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
284
+ : NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
285
+ return {
286
+ message: formatErrorMessage(input.error),
287
+ checkedAt: input.now,
288
+ nextRetryAt: input.now + delay,
289
+ retryCount,
290
+ tokenHash: hashRefreshToken(input.refreshToken),
291
+ };
292
+ }
293
+ export function refreshBackoffActive(error, refreshToken, now) {
294
+ if (!error?.nextRetryAt || error.nextRetryAt <= now)
295
+ return false;
296
+ if (!refreshToken)
297
+ return true;
298
+ return error.tokenHash === hashRefreshToken(refreshToken);
299
+ }
300
+ export function formatRefreshBackoffMessage(error, now) {
301
+ const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
302
+ return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
303
+ }
230
304
  export function getQuotaCheckIntervalMs(storage) {
231
305
  const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
232
306
  return Math.max(1, minutes) * 60_000;
@@ -335,16 +409,21 @@ function formatErrorMessage(error) {
335
409
  return error instanceof Error ? error.message : String(error);
336
410
  }
337
411
  function recordRefreshError(account, error, now) {
338
- account.lastRefreshError = {
339
- message: formatErrorMessage(error),
340
- checkedAt: now,
341
- };
412
+ account.lastRefreshError = buildRefreshOperationError({
413
+ error,
414
+ now,
415
+ refreshToken: account.refresh,
416
+ previous: account.lastRefreshError,
417
+ });
342
418
  }
343
419
  function recordQuotaRefreshError(account, error, now) {
344
420
  account.lastQuotaRefreshError = {
345
421
  message: formatErrorMessage(error),
346
422
  checkedAt: now,
347
423
  };
424
+ if (error instanceof ClaudeOAuthRefreshError) {
425
+ recordRefreshError(account, error, now);
426
+ }
348
427
  }
349
428
  export class FallbackAccountManager {
350
429
  now;
@@ -365,22 +444,18 @@ export class FallbackAccountManager {
365
444
  await saveAccounts(storage, this.configPath);
366
445
  }
367
446
  startBackgroundRefresh() {
368
- void this.refreshDueAccounts().catch(() => { });
369
- void this.refreshQuotaForDueAccounts().catch(() => { });
447
+ const run = async () => {
448
+ await this.refreshDueAccounts();
449
+ await this.refreshQuotaForDueAccounts();
450
+ };
451
+ void run().catch(() => { });
370
452
  if (!this.refreshTimer) {
371
453
  this.refreshTimer = setInterval(() => {
372
- void this.refreshDueAccounts().catch(() => { });
454
+ void run().catch(() => { });
373
455
  }, BACKGROUND_TICK_MS);
374
456
  if ('unref' in this.refreshTimer)
375
457
  this.refreshTimer.unref();
376
458
  }
377
- if (!this.quotaTimer) {
378
- this.quotaTimer = setInterval(() => {
379
- void this.refreshQuotaForDueAccounts().catch(() => { });
380
- }, BACKGROUND_TICK_MS);
381
- if ('unref' in this.quotaTimer)
382
- this.quotaTimer.unref();
383
- }
384
459
  }
385
460
  stopBackgroundRefresh() {
386
461
  if (this.refreshTimer)
@@ -402,6 +477,11 @@ export class FallbackAccountManager {
402
477
  try {
403
478
  let next = account;
404
479
  if (tokenNeedsRefresh(next, storage, this.now())) {
480
+ const refreshError = next.lastRefreshError;
481
+ if (refreshError &&
482
+ refreshBackoffActive(refreshError, next.refresh, this.now())) {
483
+ throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
484
+ }
405
485
  next = await this.refreshAccount(next, storage);
406
486
  changed = true;
407
487
  }
@@ -444,6 +524,9 @@ export class FallbackAccountManager {
444
524
  continue;
445
525
  if (!tokenNeedsRefresh(account, storage, this.now()))
446
526
  continue;
527
+ if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) {
528
+ continue;
529
+ }
447
530
  try {
448
531
  await this.refreshAccount(account, storage);
449
532
  changed = true;
@@ -469,6 +552,9 @@ export class FallbackAccountManager {
469
552
  let next = account;
470
553
  try {
471
554
  if (tokenNeedsRefresh(next, storage, this.now())) {
555
+ if (refreshBackoffActive(next.lastRefreshError, next.refresh, this.now())) {
556
+ continue;
557
+ }
472
558
  next = await this.refreshAccount(next, storage);
473
559
  changed = true;
474
560
  }
@@ -499,6 +585,11 @@ export class FallbackAccountManager {
499
585
  let next = account;
500
586
  try {
501
587
  if (tokenNeedsRefresh(next, storage, this.now())) {
588
+ const refreshError = next.lastRefreshError;
589
+ if (refreshError &&
590
+ refreshBackoffActive(refreshError, next.refresh, this.now())) {
591
+ throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
592
+ }
502
593
  next = await this.refreshAccount(next, storage);
503
594
  changed = true;
504
595
  }
@@ -545,29 +636,16 @@ export class FallbackAccountManager {
545
636
  }
546
637
  const sourceAccount = latestAccount ?? account;
547
638
  const refreshToken = sourceAccount.refresh;
548
- const response = await this.fetchImpl(TOKEN_URL, {
549
- method: 'POST',
550
- headers: {
551
- 'Content-Type': 'application/json',
552
- Accept: 'application/json, text/plain, */*',
553
- 'User-Agent': 'axios/1.13.6',
554
- },
555
- body: JSON.stringify({
556
- grant_type: 'refresh_token',
557
- refresh_token: refreshToken,
558
- client_id: CLIENT_ID,
559
- }),
639
+ const refreshed = await refreshClaudeOAuthToken({
640
+ refreshToken,
641
+ fetchImpl: this.fetchImpl,
642
+ now: this.now,
560
643
  });
561
- if (!response.ok) {
562
- const body = await response.text().catch(() => '');
563
- throw new Error(`Fallback token refresh failed: ${response.status} — ${body}`);
564
- }
565
- const json = (await response.json());
566
- const refreshedAt = this.now();
567
- sourceAccount.access = json.access_token;
568
- sourceAccount.refresh = json.refresh_token ?? refreshToken;
569
- sourceAccount.expires = refreshedAt + json.expires_in * 1000;
570
- sourceAccount.lastRefreshedAt = refreshedAt;
644
+ sourceAccount.access = refreshed.access;
645
+ sourceAccount.refresh = refreshed.refresh;
646
+ sourceAccount.expires = refreshed.expires;
647
+ sourceAccount.lastRefreshedAt =
648
+ refreshed.expires - refreshed.expiresIn * 1000;
571
649
  sourceAccount.lastRefreshError = undefined;
572
650
  updateStoredAccount(storage, sourceAccount);
573
651
  return sourceAccount;
package/dist/auth.d.ts CHANGED
@@ -1,3 +1,19 @@
1
+ export declare class ClaudeOAuthRefreshError extends Error {
2
+ readonly status: number;
3
+ readonly body: string;
4
+ constructor(status: number, body: string);
5
+ }
6
+ export type ClaudeOAuthRefreshResult = {
7
+ access: string;
8
+ refresh: string;
9
+ expires: number;
10
+ expiresIn: number;
11
+ };
12
+ export declare function refreshClaudeOAuthToken(input: {
13
+ refreshToken: string;
14
+ fetchImpl?: typeof fetch;
15
+ now?: () => number;
16
+ }): Promise<ClaudeOAuthRefreshResult>;
1
17
  export type AuthorizationResult = {
2
18
  url: string;
3
19
  redirectUri: string;
package/dist/auth.js CHANGED
@@ -1,5 +1,42 @@
1
1
  import { AUTHORIZE_URLS, CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES, TOKEN_URL, } from "./constants.js";
2
2
  import { generatePKCE } from "./pkce.js";
3
+ export class ClaudeOAuthRefreshError extends Error {
4
+ status;
5
+ body;
6
+ constructor(status, body) {
7
+ super(`Claude OAuth refresh failed: ${status} — ${body}`);
8
+ this.status = status;
9
+ this.body = body;
10
+ this.name = 'ClaudeOAuthRefreshError';
11
+ }
12
+ }
13
+ export async function refreshClaudeOAuthToken(input) {
14
+ 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);
30
+ }
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
+ };
39
+ }
3
40
  function generateState() {
4
41
  return crypto.randomUUID().replace(/-/g, '');
5
42
  }
package/dist/cch.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import xxhashInit from 'xxhash-wasm';
3
- import { CLAUDE_CODE_VERSION } from "./constants.js";
3
+ import { CLAUDE_CODE_BUILD_HASH, CLAUDE_CODE_VERSION } from "./constants.js";
4
4
  const CCH_SEED = 0x6e52736ac806831en;
5
5
  export const CCH_PATTERN = /\bcch=([0-9a-f]{5});/;
6
6
  let xxhashPromise = null;
@@ -58,6 +58,8 @@ export async function signRequestBody(bodyString) {
58
58
  * busting prompt-cache prefixes on every turn.
59
59
  */
60
60
  export function computeVersionSuffix(version = CLAUDE_CODE_VERSION, date = new Date()) {
61
+ if (version === CLAUDE_CODE_VERSION)
62
+ return CLAUDE_CODE_BUILD_HASH;
61
63
  const dayStamp = date.toISOString().slice(0, 10);
62
64
  return createHash('sha256')
63
65
  .update(`${dayStamp}${version}`)
@@ -0,0 +1,19 @@
1
+ export type ClaudeCodeIdentity = {
2
+ deviceId: string;
3
+ accountUuid?: string;
4
+ sessionId: string;
5
+ };
6
+ export declare function getClaudeCodeIdentity(seed: string): ClaudeCodeIdentity;
7
+ export declare function resolveClaudeCodeIdentity(accessToken: string, model?: string): Promise<ClaudeCodeIdentity>;
8
+ export declare function buildClaudeCodeMetadataUserId(identity: ClaudeCodeIdentity): string | null;
9
+ export declare function applyClaudeCodeMetadata(body: Record<string, unknown>, identity: ClaudeCodeIdentity): boolean;
10
+ export declare const CLAUDE_CODE_FULL_AGENT_BETAS: readonly ["claude-code-20250219", "oauth-2025-04-20", "interleaved-thinking-2025-05-14", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", "advisor-tool-2026-03-01", "advanced-tool-use-2025-11-20", "context-1m-2025-08-07", "effort-2025-11-24", "extended-cache-ttl-2025-04-11", "cache-diagnosis-2026-04-07"];
11
+ export declare function selectClaudeCodeBetas(body?: Record<string, unknown> | null, extraBetas?: string[]): string;
12
+ export declare function applyClaudeCodeHeaders(headers: Headers, accessToken: string, options?: {
13
+ body?: Record<string, unknown> | null;
14
+ identity?: ClaudeCodeIdentity;
15
+ extraBetas?: string[];
16
+ }): Headers;
17
+ export declare function orderClaudeCodeBody<T extends Record<string, unknown>>(body: T): T;
18
+ export declare function claudeCodeBuildHash(): string;
19
+ export declare function claudeCodeEntrypoint(): string;
@@ -0,0 +1,258 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { CLAUDE_CODE_BUILD_HASH, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_STAINLESS_PACKAGE_VERSION, CLAUDE_CODE_STAINLESS_RUNTIME_VERSION, CLAUDE_CODE_VERSION, FAST_MODE_BETA, USER_AGENT, } from "./constants.js";
3
+ const IDENTITY_CACHE_LIMIT = 1_000;
4
+ const identityCache = new Map();
5
+ function setBounded(map, key, value) {
6
+ if (!map.has(key) && map.size >= IDENTITY_CACHE_LIMIT) {
7
+ const oldest = map.keys().next().value;
8
+ if (oldest !== undefined)
9
+ map.delete(oldest);
10
+ }
11
+ map.set(key, value);
12
+ }
13
+ export function getClaudeCodeIdentity(seed) {
14
+ const cacheKey = seed || 'anonymous';
15
+ const cached = identityCache.get(cacheKey);
16
+ if (cached)
17
+ return cached;
18
+ const identity = {
19
+ deviceId: randomBytes(32).toString('hex'),
20
+ sessionId: randomUUID(),
21
+ };
22
+ setBounded(identityCache, cacheKey, identity);
23
+ return identity;
24
+ }
25
+ const bootstrapFetches = new Map();
26
+ async function fetchClaudeCodeAccountUuid(accessToken, model) {
27
+ if (!accessToken.startsWith('sk-ant-oat'))
28
+ return null;
29
+ const controller = new AbortController();
30
+ const timeout = setTimeout(() => controller.abort(), 5_000);
31
+ try {
32
+ const url = new URL('https://api.anthropic.com/api/claude_cli/bootstrap');
33
+ url.searchParams.set('entrypoint', CLAUDE_CODE_ENTRYPOINT);
34
+ if (model)
35
+ url.searchParams.set('model', model);
36
+ const response = await fetch(url, {
37
+ method: 'GET',
38
+ headers: {
39
+ accept: 'application/json, text/plain, */*',
40
+ authorization: `Bearer ${accessToken}`,
41
+ 'content-type': 'application/json',
42
+ 'anthropic-beta': 'oauth-2025-04-20',
43
+ 'user-agent': `claude-code/${CLAUDE_CODE_VERSION}`,
44
+ },
45
+ signal: controller.signal,
46
+ });
47
+ if (!response.ok)
48
+ return null;
49
+ const data = (await response.json().catch(() => null));
50
+ const accountUuid = data?.oauth_account?.account_uuid;
51
+ return typeof accountUuid === 'string' && accountUuid ? accountUuid : null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ finally {
57
+ clearTimeout(timeout);
58
+ }
59
+ }
60
+ export async function resolveClaudeCodeIdentity(accessToken, model) {
61
+ const identity = getClaudeCodeIdentity(accessToken);
62
+ if (!accessToken.startsWith('sk-ant-oat'))
63
+ return identity;
64
+ let fetchPromise = bootstrapFetches.get(accessToken);
65
+ if (!fetchPromise) {
66
+ fetchPromise = fetchClaudeCodeAccountUuid(accessToken, model);
67
+ bootstrapFetches.set(accessToken, fetchPromise);
68
+ }
69
+ const accountUuid = await fetchPromise;
70
+ if (!accountUuid) {
71
+ bootstrapFetches.delete(accessToken);
72
+ return identity;
73
+ }
74
+ const accountCacheKey = `account:${accountUuid}`;
75
+ const accountIdentity = identityCache.get(accountCacheKey);
76
+ if (accountIdentity) {
77
+ setBounded(identityCache, accessToken, accountIdentity);
78
+ return accountIdentity;
79
+ }
80
+ identity.accountUuid = accountUuid;
81
+ setBounded(identityCache, accountCacheKey, identity);
82
+ return identity;
83
+ }
84
+ function isRecord(value) {
85
+ return value != null && typeof value === 'object' && !Array.isArray(value);
86
+ }
87
+ export function buildClaudeCodeMetadataUserId(identity) {
88
+ if (!identity.accountUuid)
89
+ return null;
90
+ return JSON.stringify({
91
+ device_id: identity.deviceId,
92
+ account_uuid: identity.accountUuid,
93
+ session_id: identity.sessionId,
94
+ });
95
+ }
96
+ export function applyClaudeCodeMetadata(body, identity) {
97
+ const userId = buildClaudeCodeMetadataUserId(identity);
98
+ if (!userId) {
99
+ if (isRecord(body.metadata))
100
+ delete body.metadata.user_id;
101
+ return false;
102
+ }
103
+ if (!isRecord(body.metadata))
104
+ body.metadata = {};
105
+ const metadata = body.metadata;
106
+ if (isRecord(metadata))
107
+ metadata.user_id = userId;
108
+ return true;
109
+ }
110
+ export const CLAUDE_CODE_FULL_AGENT_BETAS = [
111
+ 'claude-code-20250219',
112
+ 'oauth-2025-04-20',
113
+ 'interleaved-thinking-2025-05-14',
114
+ 'context-management-2025-06-27',
115
+ 'prompt-caching-scope-2026-01-05',
116
+ 'advisor-tool-2026-03-01',
117
+ 'advanced-tool-use-2025-11-20',
118
+ 'context-1m-2025-08-07',
119
+ 'effort-2025-11-24',
120
+ 'extended-cache-ttl-2025-04-11',
121
+ 'cache-diagnosis-2026-04-07',
122
+ ];
123
+ const CLAUDE_CODE_STRUCTURED_OUTPUT_BETAS = [
124
+ 'oauth-2025-04-20',
125
+ 'interleaved-thinking-2025-05-14',
126
+ 'context-management-2025-06-27',
127
+ 'prompt-caching-scope-2026-01-05',
128
+ 'advisor-tool-2026-03-01',
129
+ 'structured-outputs-2025-12-15',
130
+ 'cache-diagnosis-2026-04-07',
131
+ ];
132
+ const CLAUDE_CODE_BASE_BETAS = [
133
+ 'oauth-2025-04-20',
134
+ 'interleaved-thinking-2025-05-14',
135
+ 'context-management-2025-06-27',
136
+ 'prompt-caching-scope-2026-01-05',
137
+ 'advisor-tool-2026-03-01',
138
+ 'cache-diagnosis-2026-04-07',
139
+ ];
140
+ function hasStructuredOutput(body) {
141
+ const outputConfig = body.output_config;
142
+ const format = outputConfig?.format;
143
+ return format?.type === 'json_schema';
144
+ }
145
+ function hasFullAgentShape(body) {
146
+ return (Array.isArray(body.tools) &&
147
+ body.tools.length > 0 &&
148
+ Array.isArray(body.system) &&
149
+ isRecord(body.thinking) &&
150
+ isRecord(body.context_management) &&
151
+ isRecord(body.output_config) &&
152
+ isRecord(body.diagnostics));
153
+ }
154
+ export function selectClaudeCodeBetas(body, extraBetas = []) {
155
+ const selected = body
156
+ ? hasFullAgentShape(body)
157
+ ? [...CLAUDE_CODE_FULL_AGENT_BETAS]
158
+ : hasStructuredOutput(body)
159
+ ? [...CLAUDE_CODE_STRUCTURED_OUTPUT_BETAS]
160
+ : [...CLAUDE_CODE_BASE_BETAS]
161
+ : [...CLAUDE_CODE_BASE_BETAS];
162
+ if (body?.speed === 'fast')
163
+ selected.push(FAST_MODE_BETA);
164
+ for (const beta of extraBetas) {
165
+ const trimmed = beta.trim();
166
+ if (trimmed)
167
+ selected.push(trimmed);
168
+ }
169
+ return [...new Set(selected)].join(',');
170
+ }
171
+ function stainlessOS() {
172
+ switch (process.platform) {
173
+ case 'darwin':
174
+ return 'MacOS';
175
+ case 'win32':
176
+ return 'Windows';
177
+ case 'linux':
178
+ return 'Linux';
179
+ case 'freebsd':
180
+ return 'FreeBSD';
181
+ default:
182
+ return 'Unknown';
183
+ }
184
+ }
185
+ function stainlessArch() {
186
+ switch (process.arch) {
187
+ case 'arm64':
188
+ return 'arm64';
189
+ case 'x64':
190
+ return 'x64';
191
+ case 'ia32':
192
+ return 'x32';
193
+ default:
194
+ return process.arch;
195
+ }
196
+ }
197
+ export function applyClaudeCodeHeaders(headers, accessToken, options = {}) {
198
+ const identity = options.identity ?? getClaudeCodeIdentity(accessToken);
199
+ const incomingBetas = (headers.get('anthropic-beta') ?? '')
200
+ .split(',')
201
+ .map((beta) => beta.trim())
202
+ .filter(Boolean);
203
+ const extraBetas = [...incomingBetas, ...(options.extraBetas ?? [])];
204
+ headers.set('accept', 'application/json');
205
+ headers.set('authorization', `Bearer ${accessToken}`);
206
+ headers.set('content-type', 'application/json');
207
+ headers.set('user-agent', USER_AGENT);
208
+ headers.set('anthropic-beta', selectClaudeCodeBetas(options.body, extraBetas));
209
+ headers.set('anthropic-dangerous-direct-browser-access', 'true');
210
+ headers.set('anthropic-version', '2023-06-01');
211
+ headers.set('x-app', 'cli');
212
+ headers.set('x-client-request-id', randomUUID());
213
+ headers.set('x-claude-code-session-id', identity.sessionId);
214
+ headers.set('x-stainless-arch', stainlessArch());
215
+ headers.set('x-stainless-lang', 'js');
216
+ headers.set('x-stainless-os', stainlessOS());
217
+ headers.set('x-stainless-package-version', CLAUDE_CODE_STAINLESS_PACKAGE_VERSION);
218
+ headers.set('x-stainless-retry-count', '0');
219
+ headers.set('x-stainless-runtime', 'node');
220
+ headers.set('x-stainless-runtime-version', CLAUDE_CODE_STAINLESS_RUNTIME_VERSION);
221
+ headers.set('x-stainless-timeout', '600');
222
+ headers.delete('x-api-key');
223
+ return headers;
224
+ }
225
+ const BODY_FIELD_ORDER = [
226
+ 'model',
227
+ 'messages',
228
+ 'system',
229
+ 'tools',
230
+ 'tool_choice',
231
+ 'metadata',
232
+ 'max_tokens',
233
+ 'temperature',
234
+ 'thinking',
235
+ 'context_management',
236
+ 'output_config',
237
+ 'diagnostics',
238
+ 'stream',
239
+ 'speed',
240
+ ];
241
+ export function orderClaudeCodeBody(body) {
242
+ const ordered = {};
243
+ for (const key of BODY_FIELD_ORDER) {
244
+ if (Object.hasOwn(body, key))
245
+ ordered[key] = body[key];
246
+ }
247
+ for (const [key, value] of Object.entries(body)) {
248
+ if (!Object.hasOwn(ordered, key))
249
+ ordered[key] = value;
250
+ }
251
+ return ordered;
252
+ }
253
+ export function claudeCodeBuildHash() {
254
+ return CLAUDE_CODE_BUILD_HASH;
255
+ }
256
+ export function claudeCodeEntrypoint() {
257
+ return CLAUDE_CODE_ENTRYPOINT;
258
+ }
@@ -8,13 +8,19 @@ export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
8
8
  export declare const OAUTH_SCOPES: string[];
9
9
  export declare const TOOL_PREFIX = "mcp_";
10
10
  export declare const REQUIRED_BETAS: string[];
11
+ export declare const FAST_MODE_BETA = "fast-mode-2026-02-01";
12
+ export declare function mergeAnthropicBetas(existing: string | null | undefined, betas: string[]): string;
13
+ export declare function isFastModeSupportedModel(model: unknown): boolean;
11
14
  export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
12
15
  export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
13
16
  export declare const CCH_SALT = "59cf53e54c78";
14
17
  export declare const CCH_POSITIONS: number[];
15
- export declare const CLAUDE_CODE_VERSION = "2.1.87";
18
+ export declare const CLAUDE_CODE_VERSION = "2.1.141";
19
+ export declare const CLAUDE_CODE_BUILD_HASH = "67b";
16
20
  export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
17
- export declare const USER_AGENT = "claude-cli/2.1.87 (external, cli)";
21
+ export declare const CLAUDE_CODE_STAINLESS_PACKAGE_VERSION = "0.94.0";
22
+ export declare const CLAUDE_CODE_STAINLESS_RUNTIME_VERSION = "v24.3.0";
23
+ export declare const USER_AGENT = "claude-cli/2.1.141 (external, sdk-cli)";
18
24
  export declare const CACHE_1H_MODES: readonly ["explicit", "automatic", "hybrid"];
19
25
  export type Cache1hMode = (typeof CACHE_1H_MODES)[number];
20
26
  export declare const DEFAULT_CACHE_1H_MODE: Cache1hMode;
package/dist/constants.js CHANGED
@@ -18,13 +18,28 @@ export const REQUIRED_BETAS = [
18
18
  'oauth-2025-04-20',
19
19
  'interleaved-thinking-2025-05-14',
20
20
  ];
21
+ export const FAST_MODE_BETA = 'fast-mode-2026-02-01';
22
+ export function mergeAnthropicBetas(existing, betas) {
23
+ const incoming = (existing ?? '')
24
+ .split(',')
25
+ .map((beta) => beta.trim())
26
+ .filter(Boolean);
27
+ return [...new Set([...incoming, ...betas])].join(',');
28
+ }
29
+ export function isFastModeSupportedModel(model) {
30
+ return (typeof model === 'string' &&
31
+ (model.startsWith('claude-opus-4-6') || model.startsWith('claude-opus-4-7')));
32
+ }
21
33
  export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
22
34
  export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
23
35
  export const CCH_SALT = '59cf53e54c78';
24
36
  export const CCH_POSITIONS = [4, 7, 20];
25
- export const CLAUDE_CODE_VERSION = '2.1.87';
37
+ export const CLAUDE_CODE_VERSION = '2.1.141';
38
+ export const CLAUDE_CODE_BUILD_HASH = '67b';
26
39
  export const CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
27
- export const USER_AGENT = 'claude-cli/2.1.87 (external, cli)';
40
+ export const CLAUDE_CODE_STAINLESS_PACKAGE_VERSION = '0.94.0';
41
+ export const CLAUDE_CODE_STAINLESS_RUNTIME_VERSION = 'v24.3.0';
42
+ export const USER_AGENT = 'claude-cli/2.1.141 (external, sdk-cli)';
28
43
  export const CACHE_1H_MODES = ['explicit', 'automatic', 'hybrid'];
29
44
  export const DEFAULT_CACHE_1H_MODE = 'explicit';
30
45
  /**
package/dist/fast.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export declare const CLAUDE_FAST_COMMAND_NAME = "claude-fast";
2
+ export type FastModeCommandAction = {
3
+ type: 'status';
4
+ } | {
5
+ type: 'enable';
6
+ } | {
7
+ type: 'disable';
8
+ } | {
9
+ type: 'usage';
10
+ };
11
+ export declare function isFastModeEnabled(): boolean;
12
+ export declare function setFastModeEnabled(enabled: boolean): void;
13
+ export declare function resetFastModeState(): void;
14
+ export declare function parseFastModeCommandAction(argumentsText: string): FastModeCommandAction;
15
+ export declare function buildFastModeStatusSummary(input?: {
16
+ enabled?: boolean;
17
+ }): string;
18
+ export declare function executeFastModeCommand(input: {
19
+ argumentsText: string;
20
+ enabled?: boolean;
21
+ }): string;
package/dist/fast.js ADDED
@@ -0,0 +1,66 @@
1
+ export const CLAUDE_FAST_COMMAND_NAME = 'claude-fast';
2
+ const FAST_STATUS_TITLE = '## Claude Fast Mode Status';
3
+ const FAST_ENABLED_TITLE = '## Claude Fast Mode Enabled';
4
+ const FAST_DISABLED_TITLE = '## Claude Fast Mode Disabled';
5
+ const FAST_USAGE_TITLE = '## Claude Fast Mode Usage';
6
+ const FAST_USAGE = 'Usage: `/claude-fast`, `/claude-fast on`, or `/claude-fast off`.';
7
+ let fastModeEnabled = false;
8
+ export function isFastModeEnabled() {
9
+ return fastModeEnabled;
10
+ }
11
+ export function setFastModeEnabled(enabled) {
12
+ fastModeEnabled = enabled;
13
+ }
14
+ export function resetFastModeState() {
15
+ fastModeEnabled = false;
16
+ }
17
+ export function parseFastModeCommandAction(argumentsText) {
18
+ const normalized = argumentsText.trim().split(/\s+/).filter(Boolean);
19
+ if (normalized.length === 0)
20
+ return { type: 'status' };
21
+ if (normalized.length === 1 && normalized[0] === 'on')
22
+ return { type: 'enable' };
23
+ if (normalized.length === 1 && normalized[0] === 'off')
24
+ return { type: 'disable' };
25
+ return { type: 'usage' };
26
+ }
27
+ export function buildFastModeStatusSummary(input) {
28
+ const enabled = input?.enabled ?? fastModeEnabled;
29
+ return [
30
+ FAST_STATUS_TITLE,
31
+ '',
32
+ `- Enabled: ${enabled ? 'enabled' : 'disabled'}`,
33
+ '- Persisted: ~/.config/opencode/anthropic-auth.json',
34
+ '- Scope: adds Anthropic fast mode to supported Opus requests',
35
+ '- Supported models: claude-opus-4-6 and claude-opus-4-7',
36
+ '- Request changes: adds `speed: "fast"` and the `fast-mode-2026-02-01` beta header',
37
+ '- Note: fast and standard speeds do not share prompt-cache prefixes',
38
+ ].join('\n');
39
+ }
40
+ export function executeFastModeCommand(input) {
41
+ const action = parseFastModeCommandAction(input.argumentsText);
42
+ const enabled = input.enabled ?? fastModeEnabled;
43
+ if (action.type === 'status')
44
+ return buildFastModeStatusSummary({ enabled });
45
+ if (action.type === 'enable') {
46
+ return [
47
+ FAST_ENABLED_TITLE,
48
+ '',
49
+ buildFastModeStatusSummary({ enabled: true }),
50
+ ].join('\n');
51
+ }
52
+ if (action.type === 'disable') {
53
+ return [
54
+ FAST_DISABLED_TITLE,
55
+ '',
56
+ buildFastModeStatusSummary({ enabled: false }),
57
+ ].join('\n');
58
+ }
59
+ return [
60
+ FAST_USAGE_TITLE,
61
+ '',
62
+ FAST_USAGE,
63
+ '',
64
+ buildFastModeStatusSummary({ enabled }),
65
+ ].join('\n');
66
+ }
package/dist/index.d.ts CHANGED
@@ -2,8 +2,10 @@ export * from './accounts.ts';
2
2
  export * from './auth.ts';
3
3
  export * from './cache1h.ts';
4
4
  export * from './cch.ts';
5
+ export * from './claude-code.ts';
5
6
  export * from './constants.ts';
6
7
  export * from './dump.ts';
8
+ export * from './fast.ts';
7
9
  export * from './logger.ts';
8
10
  export * from './pkce.ts';
9
11
  export * from './quotas.ts';
package/dist/index.js CHANGED
@@ -2,8 +2,10 @@ export * from "./accounts.js";
2
2
  export * from "./auth.js";
3
3
  export * from "./cache1h.js";
4
4
  export * from "./cch.js";
5
+ export * from "./claude-code.js";
5
6
  export * from "./constants.js";
6
7
  export * from "./dump.js";
8
+ export * from "./fast.js";
7
9
  export * from "./logger.js";
8
10
  export * from "./pkce.js";
9
11
  export * from "./quotas.js";
package/dist/relay.d.ts CHANGED
@@ -22,6 +22,7 @@ export declare function sendViaRelay(options: {
22
22
  headers: Headers;
23
23
  body: RequestInit['body'] | null | undefined;
24
24
  fallback: () => Promise<Response>;
25
+ affinity?: string | null;
25
26
  optimisticResponse?: boolean;
26
27
  }): Promise<Response>;
27
- 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\nfunction 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\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 = 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 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";
package/dist/relay.js CHANGED
@@ -107,7 +107,7 @@ function isRelayableAnthropicRequest(input, body) {
107
107
  return false;
108
108
  try {
109
109
  const url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
110
- return url.pathname === '/v1/messages';
110
+ return url.pathname === '/v1/messages' || url.pathname === '/messages';
111
111
  }
112
112
  catch {
113
113
  return false;
@@ -115,8 +115,11 @@ function isRelayableAnthropicRequest(input, body) {
115
115
  }
116
116
  function jsonHeaders(headers) {
117
117
  const result = {};
118
- for (const [key, value] of headers.entries())
118
+ for (const [key, value] of headers.entries()) {
119
+ if (key === 'x-session-affinity' || key === 'x-opencode-session')
120
+ continue;
119
121
  result[key] = value;
122
+ }
120
123
  return result;
121
124
  }
122
125
  async function postRelay(config, payload) {
@@ -363,6 +366,10 @@ class PersistentRelaySession {
363
366
  this.connecting = undefined;
364
367
  }
365
368
  if (this.pending) {
369
+ if (this.pending.retryingBeforeResponse)
370
+ return;
371
+ if (this.retryPendingBeforeResponse(this.pending, 'closed before response'))
372
+ return;
366
373
  const error = new Error('relay websocket closed before response');
367
374
  this.failPending(error);
368
375
  }
@@ -397,6 +404,8 @@ class PersistentRelaySession {
397
404
  rejectDone,
398
405
  sentAt,
399
406
  optimisticResponse,
407
+ retryAttempts: 0,
408
+ retryingBeforeResponse: false,
400
409
  };
401
410
  this.pending = pending;
402
411
  this.resetPendingTimeout(pending);
@@ -415,10 +424,60 @@ class PersistentRelaySession {
415
424
  pending.timeout = setTimeout(() => {
416
425
  if (this.pending !== pending)
417
426
  return;
427
+ if (this.retryPendingBeforeResponse(pending, 'response timed out'))
428
+ return;
418
429
  this.failPending(new Error('relay websocket response timed out'));
419
430
  this.socket?.close();
420
431
  }, 45_000);
421
432
  }
433
+ retryPendingBeforeResponse(pending, reason) {
434
+ if (pending.responseStartedAt != null)
435
+ return false;
436
+ if (pending.streamDone)
437
+ return false;
438
+ if (pending.retryAttempts >= 1)
439
+ return false;
440
+ pending.retryAttempts += 1;
441
+ pending.retryingBeforeResponse = true;
442
+ clearTimeout(pending.timeout);
443
+ const attempt = pending.retryAttempts;
444
+ const originalId = pending.payload.id;
445
+ relayLog(`websocket ${reason}; reconnecting and retrying full_sync session=${shortAffinity(this.affinity)} request=${originalId} retry=${attempt}`);
446
+ this.socket?.close();
447
+ this.socket = undefined;
448
+ this.serverState = undefined;
449
+ this.connecting = undefined;
450
+ void (async () => {
451
+ try {
452
+ await this.ensureConnected();
453
+ if (this.pending !== pending)
454
+ return;
455
+ const retryPayload = createFullSyncPayload(pending.payload, pending.bodyText);
456
+ retryPayload.protocol = 2;
457
+ retryPayload.id = createRequestId();
458
+ retryPayload.revision = (this.serverState?.revision ?? 0) + 1;
459
+ pending.payload = retryPayload;
460
+ pending.accepted = false;
461
+ pending.acceptedAt = undefined;
462
+ pending.responseStartedAt = undefined;
463
+ pending.sentAt = perfNowMs();
464
+ pending.retryingBeforeResponse = false;
465
+ const socket = this.socket;
466
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
467
+ throw new Error('relay websocket is not connected');
468
+ }
469
+ this.resetPendingTimeout(pending);
470
+ relayLog(`perf websocket retry_send session=${shortAffinity(this.affinity)} request=${retryPayload.id} previous=${originalId} mode=full_sync retry=${attempt} bodyBytes=${pending.bodyText.length} relayBytes=${JSON.stringify(retryPayload).length}`);
471
+ socket.send(JSON.stringify(retryPayload));
472
+ }
473
+ catch (error) {
474
+ pending.retryingBeforeResponse = false;
475
+ if (this.pending === pending)
476
+ this.failPending(error);
477
+ }
478
+ })();
479
+ return true;
480
+ }
422
481
  handleBinaryChunk(data) {
423
482
  const pending = this.pending;
424
483
  if (!pending?.responseStarted)
@@ -485,6 +544,10 @@ class PersistentRelaySession {
485
544
  return;
486
545
  }
487
546
  if (message.type === 'error') {
547
+ if (message.status === 409 &&
548
+ this.retryPendingBeforeResponse(pending, message.message || 'state mismatch')) {
549
+ return;
550
+ }
488
551
  const error = message.status === 409
489
552
  ? new RelayStateMismatchError(message.message || 'state mismatch')
490
553
  : new Error(message.message || 'relay websocket error');
@@ -529,10 +592,12 @@ function getPersistentRelaySession(config, affinity) {
529
592
  return session;
530
593
  }
531
594
  export async function sendViaRelay(options) {
532
- const { config, input, init, headers, body, fallback, optimisticResponse } = options;
595
+ const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, } = options;
533
596
  if (!config || !isRelayableAnthropicRequest(input, body))
534
597
  return fallback();
535
- const affinity = headers.get('x-session-affinity') || headers.get('x-opencode-session');
598
+ const affinity = explicitAffinity ||
599
+ headers.get('x-session-affinity') ||
600
+ headers.get('x-opencode-session');
536
601
  if (!affinity) {
537
602
  relayLog('skipping relay: missing x-session-affinity header');
538
603
  return fallback();
@@ -700,7 +765,7 @@ function checkpointWebSocketState(env, payload, body, nextState) {
700
765
  })
701
766
  }
702
767
 
703
- function prepareWebSocketUpstream(env, state, payload) {
768
+ async function prepareWebSocketUpstream(env, state, payload) {
704
769
  if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {
705
770
  return { error: 'invalid payload', status: 400 }
706
771
  }
@@ -708,6 +773,9 @@ function prepareWebSocketUpstream(env, state, payload) {
708
773
  const body = resolveBodyFromState(state, payload)
709
774
  if (body && typeof body === 'object' && 'error' in body) return body
710
775
  if (typeof body !== 'string') return { error: 'invalid body', status: 400 }
776
+ if ((await hashBody(body)) !== payload.next_hash) {
777
+ return { error: 'hash mismatch', status: 409 }
778
+ }
711
779
 
712
780
  const nextState = { body, hash: payload.next_hash, revision: payload.revision }
713
781
  const checkpoint = checkpointWebSocketState(env, payload, body, nextState)
@@ -749,7 +817,7 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
749
817
  }
750
818
  }, 5000)
751
819
  try {
752
- const result = prepareWebSocketUpstream(env, getState(), payload)
820
+ const result = await prepareWebSocketUpstream(env, getState(), payload)
753
821
  if (result.error) {
754
822
  socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))
755
823
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",