@cortexkit/anthropic-auth-core 1.7.0 → 1.9.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.
@@ -1,22 +1,35 @@
1
1
  import { type Cache1hMode } from './constants.ts';
2
2
  export declare const ACCOUNT_FILE_NAME = "anthropic-auth.json";
3
+ export declare const ACCOUNT_STATE_FILE_NAME = "anthropic-auth-state.json";
3
4
  export declare const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
4
5
  export type QuotaWindowName = 'five_hour' | 'seven_day';
5
- export type OAuthAccount = {
6
+ export type AccountBase = {
6
7
  id: string;
7
8
  label?: string;
9
+ enabled?: boolean;
10
+ addedAt?: number;
11
+ lastUsed?: number;
12
+ };
13
+ export type OAuthAccount = AccountBase & {
8
14
  type: 'oauth';
9
15
  access?: string;
10
16
  refresh: string;
11
17
  expires?: number;
12
- enabled?: boolean;
13
- addedAt?: number;
14
- lastUsed?: number;
15
18
  lastRefreshedAt?: number;
16
19
  lastRefreshError?: AccountOperationError;
17
20
  lastQuotaRefreshError?: AccountOperationError;
18
21
  quota?: Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
19
22
  };
23
+ export type ApiKeyAccount = AccountBase & {
24
+ type: 'api';
25
+ apiKey?: string;
26
+ baseURL: string;
27
+ authHeader?: 'authorization-bearer' | 'x-api-key';
28
+ };
29
+ export type FallbackAccount = OAuthAccount | ApiKeyAccount;
30
+ export declare function isOAuthAccount(account: FallbackAccount): account is OAuthAccount;
31
+ export declare function isApiKeyAccount(account: FallbackAccount): account is ApiKeyAccount;
32
+ export declare function isValidApiBaseURL(value: string | undefined): boolean;
20
33
  export type AccountOperationError = {
21
34
  message: string;
22
35
  checkedAt: number;
@@ -81,6 +94,14 @@ export type AccountStorage = {
81
94
  claudeFast?: {
82
95
  enabled?: boolean;
83
96
  };
97
+ /**
98
+ * Zero out Anthropic OAuth model costs in the provider hook. Default: enabled
99
+ * (OAuth usage is quota-based, not per-token billed, so costs show as $0).
100
+ * Set `enabled: false` to opt out and display the provider's real model costs.
101
+ */
102
+ costZeroing?: {
103
+ enabled?: boolean;
104
+ };
84
105
  cacheKeep?: {
85
106
  enabled?: boolean;
86
107
  startHour?: number;
@@ -94,7 +115,33 @@ export type AccountStorage = {
94
115
  transport?: 'http' | 'websocket';
95
116
  };
96
117
  killswitch?: KillswitchConfig;
97
- accounts: OAuthAccount[];
118
+ accounts: FallbackAccount[];
119
+ };
120
+ /**
121
+ * Whether Anthropic OAuth model costs should be zeroed in the provider hook.
122
+ * Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
123
+ * (to display the provider's real model costs).
124
+ */
125
+ export declare function isCostZeroingEnabled(storage: Pick<AccountStorage, 'costZeroing'>): boolean;
126
+ export type AccountRuntimeEntry = Partial<Pick<OAuthAccount, 'access' | 'refresh' | 'expires' | 'lastUsed' | 'lastRefreshedAt' | 'lastRefreshError' | 'lastQuotaRefreshError' | 'quota'> & Pick<ApiKeyAccount, 'apiKey' | 'lastUsed'>>;
127
+ export type AccountRuntimeState = {
128
+ version: 1;
129
+ main?: {
130
+ quota?: OAuthQuotaSnapshot;
131
+ quotaCheckedAt?: number;
132
+ quotaToken?: string;
133
+ lastQuotaApiError?: AccountOperationError;
134
+ lastRefreshError?: AccountOperationError;
135
+ refreshLeaseId?: string;
136
+ refreshLeaseUntil?: number;
137
+ refreshLeaseTokenHash?: string;
138
+ };
139
+ accounts?: Record<string, AccountRuntimeEntry>;
140
+ };
141
+ export type AccountStateSaveScope = {
142
+ mainQuota?: boolean;
143
+ mainRefresh?: boolean;
144
+ accounts?: true | string[];
98
145
  };
99
146
  export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
100
147
  export type AccountManagerOptions = {
@@ -109,8 +156,10 @@ export type AccountRefreshError = {
109
156
  message: string;
110
157
  };
111
158
  export declare function getAccountStoragePath(): string;
159
+ export declare function getAccountStatePath(configPath?: string): string;
112
160
  export declare function loadAccounts(path?: string): Promise<AccountStorage | null>;
113
161
  export declare function saveAccounts(storage: AccountStorage, path?: string): Promise<void>;
162
+ export declare function saveAccountState(storage: AccountStorage, path?: string, scope?: AccountStateSaveScope): Promise<void>;
114
163
  export declare function acquireRefreshFileLock(options: {
115
164
  name: string;
116
165
  ttlMs: number;
@@ -202,11 +251,11 @@ export declare class FallbackAccountManager {
202
251
  */
203
252
  private seedFallbackQuota;
204
253
  load(): Promise<AccountStorage | null>;
205
- save(storage: AccountStorage): Promise<void>;
254
+ save(storage: AccountStorage, accountIds?: string[]): Promise<void>;
206
255
  startBackgroundRefresh(): void;
207
256
  stopBackgroundRefresh(): void;
208
257
  getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
209
- markUsed(account: OAuthAccount): Promise<void>;
258
+ markUsed(account: FallbackAccount): Promise<void>;
210
259
  accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
211
260
  /**
212
261
  * Return the account with its quota overlaid from the unified QuotaManager
package/dist/accounts.js CHANGED
@@ -8,7 +8,36 @@ import { log } from "./logger.js";
8
8
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
9
9
  const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
10
10
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
11
+ export const ACCOUNT_STATE_FILE_NAME = 'anthropic-auth-state.json';
11
12
  export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
13
+ export function isOAuthAccount(account) {
14
+ return account.type === 'oauth';
15
+ }
16
+ export function isApiKeyAccount(account) {
17
+ return account.type === 'api';
18
+ }
19
+ export function isValidApiBaseURL(value) {
20
+ const raw = value?.trim();
21
+ if (!raw)
22
+ return false;
23
+ try {
24
+ const url = new URL(raw);
25
+ return ((url.protocol === 'http:' || url.protocol === 'https:') &&
26
+ !url.username &&
27
+ !url.password);
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Whether Anthropic OAuth model costs should be zeroed in the provider hook.
35
+ * Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
36
+ * (to display the provider's real model costs).
37
+ */
38
+ export function isCostZeroingEnabled(storage) {
39
+ return storage.costZeroing?.enabled !== false;
40
+ }
12
41
  const DEFAULT_FALLBACK_ON = [401, 403, 429];
13
42
  const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
14
43
  const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
@@ -40,28 +69,55 @@ export function getAccountStoragePath() {
40
69
  return (process.env.OPENCODE_ANTHROPIC_AUTH_FILE?.trim() ||
41
70
  join(getConfigDir(), ACCOUNT_FILE_NAME));
42
71
  }
72
+ export function getAccountStatePath(configPath = getAccountStoragePath()) {
73
+ const explicit = process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE?.trim();
74
+ if (explicit)
75
+ return explicit;
76
+ return configPath.endsWith(ACCOUNT_FILE_NAME)
77
+ ? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME)
78
+ : `${configPath}.state.json`;
79
+ }
43
80
  function isRecord(value) {
44
81
  return value != null && typeof value === 'object' && !Array.isArray(value);
45
82
  }
83
+ function normalizeAccountBase(value) {
84
+ return {
85
+ id: typeof value.id === 'string' && value.id.trim()
86
+ ? value.id.trim()
87
+ : randomUUID(),
88
+ label: typeof value.label === 'string' ? value.label : undefined,
89
+ enabled: typeof value.enabled === 'boolean' ? value.enabled : undefined,
90
+ addedAt: typeof value.addedAt === 'number' ? value.addedAt : undefined,
91
+ lastUsed: typeof value.lastUsed === 'number' ? value.lastUsed : undefined,
92
+ };
93
+ }
46
94
  function normalizeAccount(value) {
47
95
  if (!isRecord(value))
48
96
  return null;
97
+ if (value.type === 'api') {
98
+ const baseURL = typeof value.baseURL === 'string' ? value.baseURL.trim() : '';
99
+ const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : '';
100
+ if (!isValidApiBaseURL(baseURL))
101
+ return null;
102
+ const authHeader = value.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer';
103
+ return {
104
+ ...normalizeAccountBase(value),
105
+ type: 'api',
106
+ apiKey: apiKey || undefined,
107
+ baseURL,
108
+ authHeader,
109
+ };
110
+ }
49
111
  if (value.type !== 'oauth')
50
112
  return null;
51
113
  if (typeof value.refresh !== 'string' || !value.refresh.trim())
52
114
  return null;
53
115
  return {
54
- id: typeof value.id === 'string' && value.id.trim()
55
- ? value.id.trim()
56
- : randomUUID(),
57
- label: typeof value.label === 'string' ? value.label : undefined,
116
+ ...normalizeAccountBase(value),
58
117
  type: 'oauth',
59
118
  access: typeof value.access === 'string' ? value.access : undefined,
60
119
  refresh: value.refresh,
61
120
  expires: typeof value.expires === 'number' ? value.expires : undefined,
62
- enabled: typeof value.enabled === 'boolean' ? value.enabled : undefined,
63
- addedAt: typeof value.addedAt === 'number' ? value.addedAt : undefined,
64
- lastUsed: typeof value.lastUsed === 'number' ? value.lastUsed : undefined,
65
121
  lastRefreshedAt: typeof value.lastRefreshedAt === 'number'
66
122
  ? value.lastRefreshedAt
67
123
  : undefined,
@@ -128,6 +184,7 @@ function normalizeStorage(value) {
128
184
  claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
129
185
  dump: isRecord(value.dump) ? value.dump : undefined,
130
186
  claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
187
+ costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
131
188
  cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
132
189
  relay: isRecord(value.relay) ? value.relay : undefined,
133
190
  killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
@@ -136,41 +193,227 @@ function normalizeStorage(value) {
136
193
  .filter((account) => account != null),
137
194
  };
138
195
  }
139
- export async function loadAccounts(path = getAccountStoragePath()) {
196
+ async function readJsonIfPresent(path) {
140
197
  try {
141
- const raw = await readFile(path, 'utf8');
142
- return normalizeStorage(JSON.parse(raw));
198
+ return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) };
143
199
  }
144
200
  catch (error) {
145
- if (error.code === 'ENOENT')
146
- return null;
147
- return null;
201
+ if (error.code === 'ENOENT') {
202
+ return { exists: false, value: null };
203
+ }
204
+ return { exists: false, value: null };
148
205
  }
149
206
  }
207
+ function objectWithDefinedEntries(value) {
208
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
209
+ }
210
+ function mergeConfigAndState(configValue, stateValue) {
211
+ if (!isRecord(configValue))
212
+ return configValue;
213
+ const state = isRecord(stateValue) ? stateValue : {};
214
+ const mainState = isRecord(state.main) ? state.main : undefined;
215
+ const stateAccounts = isRecord(state.accounts) ? state.accounts : {};
216
+ const quotaConfig = isRecord(configValue.quota) ? configValue.quota : {};
217
+ const refreshConfig = isRecord(configValue.refresh) ? configValue.refresh : {};
218
+ const mainQuotaSource = mainState ?? quotaConfig;
219
+ const mainRefreshSource = mainState ?? refreshConfig;
220
+ const accounts = Array.isArray(configValue.accounts)
221
+ ? configValue.accounts.map((account) => {
222
+ if (!isRecord(account))
223
+ return account;
224
+ const stateAccount = typeof account.id === 'string' && isRecord(stateAccounts[account.id])
225
+ ? stateAccounts[account.id]
226
+ : {};
227
+ return { ...account, ...stateAccount };
228
+ })
229
+ : [];
230
+ return {
231
+ ...configValue,
232
+ refresh: objectWithDefinedEntries({
233
+ ...refreshConfig,
234
+ mainLastRefreshError: mainRefreshSource.lastRefreshError,
235
+ mainRefreshLeaseId: mainRefreshSource.refreshLeaseId,
236
+ mainRefreshLeaseUntil: mainRefreshSource.refreshLeaseUntil,
237
+ mainRefreshLeaseTokenHash: mainRefreshSource.refreshLeaseTokenHash,
238
+ }),
239
+ quota: objectWithDefinedEntries({
240
+ ...quotaConfig,
241
+ mainQuota: mainQuotaSource.quota,
242
+ mainQuotaCheckedAt: mainQuotaSource.quotaCheckedAt,
243
+ mainQuotaToken: mainQuotaSource.quotaToken,
244
+ mainLastQuotaApiError: mainQuotaSource.lastQuotaApiError,
245
+ }),
246
+ accounts,
247
+ };
248
+ }
249
+ export async function loadAccounts(path = getAccountStoragePath()) {
250
+ const config = await readJsonIfPresent(path);
251
+ if (!config.exists)
252
+ return null;
253
+ const state = await readJsonIfPresent(getAccountStatePath(path));
254
+ return normalizeStorage(mergeConfigAndState(config.value, state.value));
255
+ }
150
256
  async function loadExistingTopLevelFields(path) {
151
- try {
152
- const raw = await readFile(path, 'utf8');
153
- const parsed = JSON.parse(raw);
154
- return isRecord(parsed) ? parsed : {};
155
- }
156
- catch {
157
- return {};
158
- }
257
+ const existing = await readJsonIfPresent(path);
258
+ return isRecord(existing.value) ? existing.value : {};
159
259
  }
160
260
  function omitUndefinedTopLevel(value) {
161
261
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
162
262
  }
163
- export async function saveAccounts(storage, path = getAccountStoragePath()) {
263
+ function accountConfig(account) {
264
+ return objectWithDefinedEntries({
265
+ id: account.id,
266
+ label: account.label,
267
+ type: account.type,
268
+ enabled: account.enabled,
269
+ addedAt: account.addedAt,
270
+ baseURL: account.type === 'api' ? account.baseURL : undefined,
271
+ authHeader: account.type === 'api' ? account.authHeader : undefined,
272
+ });
273
+ }
274
+ function accountRuntimeState(account) {
275
+ if (account.type === 'api') {
276
+ return objectWithDefinedEntries({
277
+ apiKey: account.apiKey,
278
+ lastUsed: account.lastUsed,
279
+ });
280
+ }
281
+ return objectWithDefinedEntries({
282
+ access: account.access,
283
+ refresh: account.refresh,
284
+ expires: account.expires,
285
+ lastUsed: account.lastUsed,
286
+ lastRefreshedAt: account.lastRefreshedAt,
287
+ lastRefreshError: account.lastRefreshError,
288
+ lastQuotaRefreshError: account.lastQuotaRefreshError,
289
+ quota: account.quota,
290
+ });
291
+ }
292
+ function configFromStorage(storage) {
293
+ const refresh = storage.refresh
294
+ ? objectWithDefinedEntries({
295
+ enabled: storage.refresh.enabled,
296
+ intervalMinutes: storage.refresh.intervalMinutes,
297
+ refreshBeforeExpiryMinutes: storage.refresh.refreshBeforeExpiryMinutes,
298
+ })
299
+ : undefined;
300
+ const quota = storage.quota
301
+ ? objectWithDefinedEntries({
302
+ enabled: storage.quota.enabled,
303
+ checkIntervalMinutes: storage.quota.checkIntervalMinutes,
304
+ refreshEveryNRequests: storage.quota.refreshEveryNRequests,
305
+ minimumRemaining: storage.quota.minimumRemaining,
306
+ failClosedOnUnknownQuota: storage.quota.failClosedOnUnknownQuota,
307
+ showToasts: storage.quota.showToasts,
308
+ })
309
+ : undefined;
310
+ return omitUndefinedTopLevel({
311
+ version: 1,
312
+ main: storage.main,
313
+ routing: storage.routing,
314
+ fallbackOn: storage.fallbackOn,
315
+ refresh,
316
+ quota,
317
+ claudeCache: storage.claudeCache,
318
+ dump: storage.dump,
319
+ claudeFast: storage.claudeFast,
320
+ costZeroing: storage.costZeroing,
321
+ cacheKeep: storage.cacheKeep,
322
+ relay: storage.relay,
323
+ killswitch: storage.killswitch,
324
+ accounts: storage.accounts.map(accountConfig),
325
+ });
326
+ }
327
+ function stateFromStorage(storage) {
328
+ const accounts = Object.fromEntries(storage.accounts.map((account) => [
329
+ account.id,
330
+ accountRuntimeState(account),
331
+ ]));
332
+ return {
333
+ version: 1,
334
+ main: objectWithDefinedEntries({
335
+ quota: storage.quota?.mainQuota,
336
+ quotaCheckedAt: storage.quota?.mainQuotaCheckedAt,
337
+ quotaToken: storage.quota?.mainQuotaToken,
338
+ lastQuotaApiError: storage.quota?.mainLastQuotaApiError,
339
+ lastRefreshError: storage.refresh?.mainLastRefreshError,
340
+ refreshLeaseId: storage.refresh?.mainRefreshLeaseId,
341
+ refreshLeaseUntil: storage.refresh?.mainRefreshLeaseUntil,
342
+ refreshLeaseTokenHash: storage.refresh?.mainRefreshLeaseTokenHash,
343
+ }),
344
+ accounts,
345
+ };
346
+ }
347
+ async function writeJsonAtomic(path, value) {
164
348
  await mkdir(dirname(path), { recursive: true });
165
- const existing = await loadExistingTopLevelFields(path);
166
- const next = { ...existing, ...omitUndefinedTopLevel(storage) };
167
349
  const tempPath = `${path}.${randomUUID()}.tmp`;
168
- await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, {
350
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
169
351
  encoding: 'utf8',
170
352
  mode: 0o600,
171
353
  });
172
354
  await rename(tempPath, path);
173
355
  }
356
+ export async function saveAccounts(storage, path = getAccountStoragePath()) {
357
+ const existing = await loadExistingTopLevelFields(path);
358
+ const nextConfig = { ...existing, ...configFromStorage(storage) };
359
+ await writeJsonAtomic(path, nextConfig);
360
+ await writeJsonAtomic(getAccountStatePath(path), stateFromStorage(storage));
361
+ }
362
+ function applyMainQuotaStatePatch(state, storage) {
363
+ state.main = state.main ?? {};
364
+ state.main.quota = storage.quota?.mainQuota;
365
+ state.main.quotaCheckedAt = storage.quota?.mainQuotaCheckedAt;
366
+ state.main.quotaToken = storage.quota?.mainQuotaToken;
367
+ state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError;
368
+ }
369
+ function applyMainRefreshStatePatch(state, storage) {
370
+ state.main = state.main ?? {};
371
+ state.main.lastRefreshError = storage.refresh?.mainLastRefreshError;
372
+ state.main.refreshLeaseId = storage.refresh?.mainRefreshLeaseId;
373
+ state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
374
+ state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
375
+ }
376
+ function pruneUndefined(value) {
377
+ if (Array.isArray(value))
378
+ return value.map(pruneUndefined);
379
+ if (!isRecord(value))
380
+ return value;
381
+ return Object.fromEntries(Object.entries(value)
382
+ .filter(([, entry]) => entry !== undefined)
383
+ .map(([key, entry]) => [key, pruneUndefined(entry)]));
384
+ }
385
+ export async function saveAccountState(storage, path = getAccountStoragePath(), scope = {
386
+ mainQuota: true,
387
+ mainRefresh: true,
388
+ accounts: true,
389
+ }) {
390
+ const statePath = getAccountStatePath(path);
391
+ const existing = (await readJsonIfPresent(statePath)).value;
392
+ const next = isRecord(existing)
393
+ ? { ...existing, version: 1 }
394
+ : { version: 1 };
395
+ if (scope.mainQuota)
396
+ applyMainQuotaStatePatch(next, storage);
397
+ if (scope.mainRefresh)
398
+ applyMainRefreshStatePatch(next, storage);
399
+ if (scope.accounts) {
400
+ const ids = scope.accounts === true ? null : new Set(scope.accounts);
401
+ next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
402
+ for (const account of storage.accounts) {
403
+ if (ids && !ids.has(account.id))
404
+ continue;
405
+ next.accounts[account.id] = accountRuntimeState(account);
406
+ }
407
+ if (ids) {
408
+ for (const id of ids) {
409
+ if (!storage.accounts.some((account) => account.id === id)) {
410
+ delete next.accounts[id];
411
+ }
412
+ }
413
+ }
414
+ }
415
+ await writeJsonAtomic(statePath, pruneUndefined(next));
416
+ }
174
417
  export async function acquireRefreshFileLock(options) {
175
418
  const accountPath = options.path ?? getAccountStoragePath();
176
419
  const lockPath = `${accountPath}.${options.name}.lock`;
@@ -786,8 +1029,10 @@ export class FallbackAccountManager {
786
1029
  async load() {
787
1030
  return loadAccounts(this.configPath);
788
1031
  }
789
- async save(storage) {
790
- await saveAccounts(storage, this.configPath);
1032
+ async save(storage, accountIds) {
1033
+ await saveAccountState(storage, this.configPath, {
1034
+ accounts: accountIds ?? true,
1035
+ });
791
1036
  }
792
1037
  startBackgroundRefresh() {
793
1038
  const run = async () => {
@@ -818,7 +1063,7 @@ export class FallbackAccountManager {
818
1063
  const usable = [];
819
1064
  let changed = false;
820
1065
  for (const account of storage.accounts) {
821
- if (account.enabled === false)
1066
+ if (account.enabled === false || !isOAuthAccount(account))
822
1067
  continue;
823
1068
  try {
824
1069
  let next = account;
@@ -898,7 +1143,7 @@ export class FallbackAccountManager {
898
1143
  return;
899
1144
  let changed = false;
900
1145
  for (const account of storage.accounts) {
901
- if (account.enabled === false)
1146
+ if (account.enabled === false || !isOAuthAccount(account))
902
1147
  continue;
903
1148
  if (!tokenNeedsRefresh(account, storage, this.now()))
904
1149
  continue;
@@ -940,7 +1185,7 @@ export class FallbackAccountManager {
940
1185
  return;
941
1186
  let changed = false;
942
1187
  for (const account of storage.accounts) {
943
- if (account.enabled === false)
1188
+ if (account.enabled === false || !isOAuthAccount(account))
944
1189
  continue;
945
1190
  let next = account;
946
1191
  try {
@@ -985,7 +1230,7 @@ export class FallbackAccountManager {
985
1230
  const force = options.force ?? false;
986
1231
  let changed = false;
987
1232
  for (const account of storage.accounts) {
988
- if (account.enabled === false)
1233
+ if (account.enabled === false || !isOAuthAccount(account))
989
1234
  continue;
990
1235
  let next = account;
991
1236
  try {
@@ -1046,7 +1291,7 @@ export class FallbackAccountManager {
1046
1291
  while (Date.now() < deadline) {
1047
1292
  await new Promise((resolve) => setTimeout(resolve, FALLBACK_REFRESH_JOIN_POLL_MS));
1048
1293
  const latestStorage = await this.load();
1049
- const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id);
1294
+ const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
1050
1295
  if (!latestAccount)
1051
1296
  continue;
1052
1297
  const changed = latestAccount.access !== previous.access ||
@@ -1075,7 +1320,7 @@ export class FallbackAccountManager {
1075
1320
  }
1076
1321
  async refreshAccountNow(account, storage, options) {
1077
1322
  let latestStorage = await this.load();
1078
- let latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id);
1323
+ let latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
1079
1324
  if (latestAccount &&
1080
1325
  !options.force &&
1081
1326
  !tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
@@ -1101,7 +1346,7 @@ export class FallbackAccountManager {
1101
1346
  }
1102
1347
  try {
1103
1348
  latestStorage = await this.load();
1104
- latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id);
1349
+ latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
1105
1350
  if (latestAccount &&
1106
1351
  !options.force &&
1107
1352
  !tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
@@ -4,6 +4,9 @@ export declare const CACHE_KEEP_TTL_MS: number;
4
4
  export declare const CACHE_KEEP_PREWARM_LEAD_MS: number;
5
5
  export declare const CACHE_KEEP_TICK_MS = 60000;
6
6
  export declare const CACHE_KEEP_EXTENDED_TTL_BETA = "extended-cache-ttl-2025-04-11";
7
+ export declare const CACHE_KEEP_MAX_TARGETS = 32;
8
+ export declare const CACHE_KEEP_MAX_BODY_BYTES: number;
9
+ export declare const CACHE_KEEP_STALE_TARGET_MS: number;
7
10
  export type CacheKeepWindow = {
8
11
  startHour: number;
9
12
  endHour: number;
@@ -74,6 +77,9 @@ export declare class CacheKeepManager {
74
77
  nextPrewarmAt: number | undefined;
75
78
  };
76
79
  trackedCount(): number;
80
+ private totalBodyBytes;
81
+ private evictOldestTarget;
82
+ private pruneTargets;
77
83
  track(input: {
78
84
  sessionId?: string | null;
79
85
  url: string;
package/dist/cachekeep.js CHANGED
@@ -5,6 +5,9 @@ export const CACHE_KEEP_TTL_MS = 60 * 60_000;
5
5
  export const CACHE_KEEP_PREWARM_LEAD_MS = 5 * 60_000;
6
6
  export const CACHE_KEEP_TICK_MS = 60_000;
7
7
  export const CACHE_KEEP_EXTENDED_TTL_BETA = 'extended-cache-ttl-2025-04-11';
8
+ export const CACHE_KEEP_MAX_TARGETS = 32;
9
+ export const CACHE_KEEP_MAX_BODY_BYTES = 16 * 1024 * 1024;
10
+ export const CACHE_KEEP_STALE_TARGET_MS = 2 * CACHE_KEEP_TTL_MS;
8
11
  const STATUS_TITLE = '## Claude Cache Keep Status';
9
12
  const ENABLED_TITLE = '## Claude Cache Keep Enabled';
10
13
  const DISABLED_TITLE = '## Claude Cache Keep Disabled';
@@ -208,6 +211,33 @@ export class CacheKeepManager {
208
211
  trackedCount() {
209
212
  return this.targets.size;
210
213
  }
214
+ totalBodyBytes() {
215
+ return [...this.targets.values()].reduce((sum, target) => sum + target.bodyText.length, 0);
216
+ }
217
+ evictOldestTarget() {
218
+ const oldest = this.targets.keys().next().value;
219
+ if (oldest !== undefined)
220
+ this.targets.delete(oldest);
221
+ }
222
+ pruneTargets(now, today) {
223
+ for (const [id, target] of this.targets) {
224
+ if (today && target.dayKey !== today) {
225
+ this.targets.delete(id);
226
+ continue;
227
+ }
228
+ if (now - target.cacheExpiresAt > CACHE_KEEP_STALE_TARGET_MS) {
229
+ this.targets.delete(id);
230
+ }
231
+ }
232
+ while (this.targets.size > CACHE_KEEP_MAX_TARGETS)
233
+ this.evictOldestTarget();
234
+ while (this.totalBodyBytes() > CACHE_KEEP_MAX_BODY_BYTES) {
235
+ const before = this.targets.size;
236
+ this.evictOldestTarget();
237
+ if (this.targets.size === before)
238
+ break;
239
+ }
240
+ }
211
241
  track(input) {
212
242
  if (!input.sessionId)
213
243
  return { tracked: false, reason: 'missing session id' };
@@ -217,11 +247,18 @@ export class CacheKeepManager {
217
247
  if (input.cacheMode !== 'hybrid') {
218
248
  return { tracked: false, reason: 'cache mode is not hybrid' };
219
249
  }
250
+ if (input.bodyText.length > CACHE_KEEP_MAX_BODY_BYTES) {
251
+ return { tracked: false, reason: 'body exceeds cachekeep memory budget' };
252
+ }
220
253
  const now = this.options.now?.() ?? Date.now();
221
254
  const window = getCacheKeepWindow(input.storage);
222
255
  if (!isWithinCacheKeepWindow(window, new Date(now))) {
223
256
  return { tracked: false, reason: 'outside configured window' };
224
257
  }
258
+ const today = localWindowKey(window, new Date(now));
259
+ this.pruneTargets(now, today);
260
+ if (this.targets.has(input.sessionId))
261
+ this.targets.delete(input.sessionId);
225
262
  const headers = {};
226
263
  input.headers.forEach((value, key) => {
227
264
  headers[key] = value;
@@ -232,8 +269,9 @@ export class CacheKeepManager {
232
269
  headers,
233
270
  bodyText: input.bodyText,
234
271
  cacheExpiresAt: now + CACHE_KEEP_TTL_MS,
235
- dayKey: localWindowKey(window, new Date(now)),
272
+ dayKey: today,
236
273
  });
274
+ this.pruneTargets(now, today);
237
275
  this.start();
238
276
  return { tracked: true };
239
277
  }
@@ -242,10 +280,7 @@ export class CacheKeepManager {
242
280
  const window = getCacheKeepWindow(storage);
243
281
  const now = this.options.now?.() ?? Date.now();
244
282
  const today = localWindowKey(window, new Date(now));
245
- for (const [id, target] of this.targets) {
246
- if (target.dayKey !== today)
247
- this.targets.delete(id);
248
- }
283
+ this.pruneTargets(now, today);
249
284
  if (!this.targets.size) {
250
285
  this.stop();
251
286
  return;
@@ -22,7 +22,10 @@ export function getClaudeCodeIdentity(seed) {
22
22
  setBounded(identityCache, cacheKey, identity);
23
23
  return identity;
24
24
  }
25
+ const BOOTSTRAP_IDENTITY_CACHE_TTL_MS = 24 * 60 * 60_000;
26
+ const BOOTSTRAP_IDENTITY_NEGATIVE_TTL_MS = 5 * 60_000;
25
27
  const bootstrapFetches = new Map();
28
+ const bootstrapResults = new Map();
26
29
  async function fetchClaudeCodeAccountUuid(accessToken, model) {
27
30
  if (!accessToken.startsWith('sk-ant-oat'))
28
31
  return null;
@@ -61,16 +64,35 @@ export async function resolveClaudeCodeIdentity(accessToken, model) {
61
64
  const identity = getClaudeCodeIdentity(accessToken);
62
65
  if (!accessToken.startsWith('sk-ant-oat'))
63
66
  return identity;
67
+ const now = Date.now();
68
+ const cachedResult = bootstrapResults.get(accessToken);
69
+ if (cachedResult && cachedResult.expiresAt > now) {
70
+ if (!cachedResult.accountUuid)
71
+ return identity;
72
+ const accountCacheKey = `account:${cachedResult.accountUuid}`;
73
+ const accountIdentity = identityCache.get(accountCacheKey);
74
+ if (accountIdentity) {
75
+ setBounded(identityCache, accessToken, accountIdentity);
76
+ return accountIdentity;
77
+ }
78
+ }
64
79
  let fetchPromise = bootstrapFetches.get(accessToken);
65
80
  if (!fetchPromise) {
66
81
  fetchPromise = fetchClaudeCodeAccountUuid(accessToken, model);
67
82
  bootstrapFetches.set(accessToken, fetchPromise);
68
83
  }
69
- const accountUuid = await fetchPromise;
70
- if (!accountUuid) {
84
+ const accountUuid = await fetchPromise.finally(() => {
71
85
  bootstrapFetches.delete(accessToken);
86
+ });
87
+ setBounded(bootstrapResults, accessToken, {
88
+ accountUuid,
89
+ expiresAt: now +
90
+ (accountUuid
91
+ ? BOOTSTRAP_IDENTITY_CACHE_TTL_MS
92
+ : BOOTSTRAP_IDENTITY_NEGATIVE_TTL_MS),
93
+ });
94
+ if (!accountUuid)
72
95
  return identity;
73
- }
74
96
  const accountCacheKey = `account:${accountUuid}`;
75
97
  const accountIdentity = identityCache.get(accountCacheKey);
76
98
  if (accountIdentity) {
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './dump.ts';
9
9
  export * from './fast.ts';
10
10
  export * from './killswitch.ts';
11
11
  export * from './logger.ts';
12
+ export * from './models.ts';
12
13
  export * from './pkce.ts';
13
14
  export * from './quota-manager.ts';
14
15
  export * from './quotas.ts';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./dump.js";
9
9
  export * from "./fast.js";
10
10
  export * from "./killswitch.js";
11
11
  export * from "./logger.js";
12
+ export * from "./models.js";
12
13
  export * from "./pkce.js";
13
14
  export * from "./quota-manager.js";
14
15
  export * from "./quotas.js";
@@ -0,0 +1,20 @@
1
+ export declare const CLAUDE_FABLE_5_MODEL_ID = "claude-fable-5";
2
+ export declare const CLAUDE_MYTHOS_5_MODEL_ID = "claude-mythos-5";
3
+ export declare const CLAUDE_FABLE_MYTHOS_5_MODEL_IDS: readonly ["claude-fable-5", "claude-mythos-5"];
4
+ export type ClaudeFableMythos5ModelId = (typeof CLAUDE_FABLE_MYTHOS_5_MODEL_IDS)[number];
5
+ export declare const CLAUDE_FABLE_MYTHOS_5_PRICING: {
6
+ readonly input: 10;
7
+ readonly output: 50;
8
+ readonly cacheRead: 1;
9
+ readonly cacheWrite5m: 12.5;
10
+ readonly cacheWrite1h: 20;
11
+ };
12
+ export declare const CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW = 1000000;
13
+ export declare const CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS = 128000;
14
+ export declare const CLAUDE_FABLE_MYTHOS_5_RELEASE_DATE = "2026-06-09";
15
+ export declare const CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS: Record<ClaudeFableMythos5ModelId, {
16
+ id: ClaudeFableMythos5ModelId;
17
+ name: string;
18
+ limited?: boolean;
19
+ }>;
20
+ export declare function isClaudeFableOrMythos5Model(model: unknown): boolean;
package/dist/models.js ADDED
@@ -0,0 +1,31 @@
1
+ export const CLAUDE_FABLE_5_MODEL_ID = 'claude-fable-5';
2
+ export const CLAUDE_MYTHOS_5_MODEL_ID = 'claude-mythos-5';
3
+ export const CLAUDE_FABLE_MYTHOS_5_MODEL_IDS = [
4
+ CLAUDE_FABLE_5_MODEL_ID,
5
+ CLAUDE_MYTHOS_5_MODEL_ID,
6
+ ];
7
+ export const CLAUDE_FABLE_MYTHOS_5_PRICING = {
8
+ input: 10,
9
+ output: 50,
10
+ cacheRead: 1,
11
+ cacheWrite5m: 12.5,
12
+ cacheWrite1h: 20,
13
+ };
14
+ export const CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW = 1_000_000;
15
+ export const CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS = 128_000;
16
+ export const CLAUDE_FABLE_MYTHOS_5_RELEASE_DATE = '2026-06-09';
17
+ export const CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS = {
18
+ [CLAUDE_FABLE_5_MODEL_ID]: {
19
+ id: CLAUDE_FABLE_5_MODEL_ID,
20
+ name: 'Claude Fable 5',
21
+ },
22
+ [CLAUDE_MYTHOS_5_MODEL_ID]: {
23
+ id: CLAUDE_MYTHOS_5_MODEL_ID,
24
+ name: 'Claude Mythos 5',
25
+ limited: true,
26
+ },
27
+ };
28
+ export function isClaudeFableOrMythos5Model(model) {
29
+ return (typeof model === 'string' &&
30
+ CLAUDE_FABLE_MYTHOS_5_MODEL_IDS.some((id) => model === id || model.startsWith(`${id}-`)));
31
+ }
@@ -94,6 +94,7 @@ export declare class QuotaManager {
94
94
  /** Minimum gap between consecutive quota API calls (ms). */
95
95
  private static readonly API_CALL_GAP_MS;
96
96
  private static fallbackInflightKey;
97
+ private static quotaLockName;
97
98
  /**
98
99
  * Serialize API calls through a shared gate so only one
99
100
  * quota API request runs at a time, with a minimum gap
@@ -284,6 +284,10 @@ export class QuotaManager {
284
284
  static fallbackInflightKey(accountId, accessToken) {
285
285
  return JSON.stringify([accountId, tokenFingerprint(accessToken)]);
286
286
  }
287
+ static quotaLockName(accountId) {
288
+ const safeId = accountId.replace(/[^a-zA-Z0-9._-]+/g, '-');
289
+ return `opencode-fallback-quota-refresh-${safeId || 'account'}`;
290
+ }
287
291
  /**
288
292
  * Serialize API calls through a shared gate so only one
289
293
  * quota API request runs at a time, with a minimum gap
@@ -367,20 +371,35 @@ export class QuotaManager {
367
371
  return cached.quota;
368
372
  throw new Error('Quota API rate-limited — try again later');
369
373
  }
370
- const quota = await fetchOAuthQuotaSnapshot({
371
- accessToken,
372
- fetchImpl: this.fetchImpl,
373
- now: this.now,
374
+ const fileLock = await acquireRefreshFileLock({
375
+ name: QuotaManager.quotaLockName(accountId),
376
+ ttlMs: 30_000,
374
377
  });
375
- const now = this.now();
376
- this.setFallback(accountId, {
377
- quota,
378
- refreshAfter: now + getQuotaCheckIntervalMs(this.storage),
379
- checkedAt: now,
380
- }, accessToken);
381
- this.fallbackApiErrors.delete(accountId);
382
- this.fallbackErrorTokenFps.delete(accountId);
383
- return quota;
378
+ if (!fileLock) {
379
+ const cached = this.getFallback(accountId, accessToken);
380
+ if (cached)
381
+ return cached.quota;
382
+ throw new Error('Quota refresh is already in progress');
383
+ }
384
+ try {
385
+ const quota = await fetchOAuthQuotaSnapshot({
386
+ accessToken,
387
+ fetchImpl: this.fetchImpl,
388
+ now: this.now,
389
+ });
390
+ const now = this.now();
391
+ this.setFallback(accountId, {
392
+ quota,
393
+ refreshAfter: now + getQuotaCheckIntervalMs(this.storage),
394
+ checkedAt: now,
395
+ }, accessToken);
396
+ this.fallbackApiErrors.delete(accountId);
397
+ this.fallbackErrorTokenFps.delete(accountId);
398
+ return quota;
399
+ }
400
+ finally {
401
+ await fileLock.release();
402
+ }
384
403
  }
385
404
  catch (error) {
386
405
  this._handleFallbackFetchError(accountId, accessToken, error);
package/dist/quotas.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isOAuthAccount } from "./accounts.js";
1
2
  export const CLAUDE_QUOTAS_COMMAND_NAME = 'claude-quota';
2
3
  const WINDOW_LABELS = {
3
4
  five_hour: '5h',
@@ -60,7 +61,7 @@ function accountStoredError(account) {
60
61
  export function buildFallbackQuotaSummaries(storage, errors = new Map()) {
61
62
  if (!storage?.accounts.length)
62
63
  return [];
63
- return storage.accounts.map((account) => {
64
+ return storage.accounts.filter(isOAuthAccount).map((account) => {
64
65
  const error = errors.get(account.id) ?? accountStoredError(account);
65
66
  return {
66
67
  name: accountName(account),
package/dist/relay.d.ts CHANGED
@@ -25,4 +25,4 @@ export declare function sendViaRelay(options: {
25
25
  affinity?: string | null;
26
26
  optimisticResponse?: boolean;
27
27
  }): Promise<Response>;
28
- export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch (error) {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n message: error instanceof Error ? error.message : String(error),\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n socket.send(value)\n }\n }\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id }))\n } catch (error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
28
+ export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\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
@@ -21,6 +21,10 @@ class RelayWebSocketConnectionResetError extends Error {
21
21
  const sessionState = new Map();
22
22
  const websocketSessions = new Map();
23
23
  const loggedRelayConfigMessages = new Set();
24
+ const RELAY_SESSION_STATE_TTL_MS = 2 * 60 * 60_000;
25
+ const RELAY_WEBSOCKET_IDLE_TTL_MS = 30 * 60_000;
26
+ const MAX_RELAY_SESSION_STATES = 128;
27
+ const MAX_RELAY_WEBSOCKET_SESSIONS = 32;
24
28
  let nextRequestId = 0;
25
29
  function createRequestId() {
26
30
  nextRequestId += 1;
@@ -136,14 +140,23 @@ function jsonHeaders(headers) {
136
140
  }
137
141
  return result;
138
142
  }
139
- async function postRelay(config, payload) {
143
+ async function postRelay(config, payload, signal) {
144
+ const stringifyStart = perfNowMs();
145
+ const body = JSON.stringify(payload);
146
+ relayPerfLog('http_payload_stringify', {
147
+ session: shortAffinity(payload.affinity),
148
+ ms: formatMs(perfNowMs() - stringifyStart),
149
+ relayBytes: body.length,
150
+ mode: payload.mode,
151
+ });
140
152
  return fetch(config.url, {
141
153
  method: 'POST',
142
154
  headers: {
143
155
  'content-type': 'application/json',
144
156
  'x-relay-token': config.token,
145
157
  },
146
- body: JSON.stringify(payload),
158
+ body,
159
+ signal: signal ?? undefined,
147
160
  });
148
161
  }
149
162
  function shortAffinity(affinity) {
@@ -155,6 +168,14 @@ function perfNowMs() {
155
168
  function formatMs(value) {
156
169
  return Math.round(value * 10) / 10;
157
170
  }
171
+ function relayPerfLoggingEnabled() {
172
+ return process.env.OPENCODE_ANTHROPIC_AUTH_PERF === '1';
173
+ }
174
+ function relayPerfLog(stage, data) {
175
+ if (!relayPerfLoggingEnabled())
176
+ return;
177
+ relayLog(`[perf] relay ${stage} ${JSON.stringify(data)}`);
178
+ }
158
179
  function createRelayPayload(options) {
159
180
  const { input, init, headers, bodyText, affinity, previous, nextHash } = options;
160
181
  const rewrittenUrl = input instanceof Request ? input.url : input.toString();
@@ -197,21 +218,25 @@ function createFullSyncPayload(payload, bodyText) {
197
218
  };
198
219
  }
199
220
  function updateLocalRelayState(affinity, bodyText, nextHash, revision) {
221
+ const now = Date.now();
222
+ sessionState.delete(affinity);
200
223
  sessionState.set(affinity, {
201
224
  body: bodyText,
202
225
  hash: nextHash,
203
226
  revision,
227
+ updatedAt: now,
204
228
  });
229
+ cleanupRelaySessionCaches(now);
205
230
  }
206
231
  async function sendRelayHttp(options) {
207
- const { config, payload, bodyText, fallback } = options;
232
+ const { config, payload, bodyText, fallback, signal } = options;
208
233
  let actualPayload = payload;
209
- let response = await postRelay(config, actualPayload);
234
+ let response = await postRelay(config, actualPayload, signal);
210
235
  if (response.status === 409 && actualPayload.mode === 'patch') {
211
236
  relayLog(`state mismatch from relay session=${shortAffinity(actualPayload.affinity)}; retrying full_sync`);
212
237
  await response.body?.cancel().catch(() => { });
213
238
  actualPayload = createFullSyncPayload(actualPayload, bodyText);
214
- response = await postRelay(config, actualPayload);
239
+ response = await postRelay(config, actualPayload, signal);
215
240
  }
216
241
  if (!response.ok && response.status >= 500 && config.fallbackToDirect) {
217
242
  relayLog(`relay returned ${response.status}; falling back direct session=${shortAffinity(actualPayload.affinity)}`);
@@ -279,11 +304,27 @@ class PersistentRelaySession {
279
304
  queue = Promise.resolve();
280
305
  pending;
281
306
  serverState;
307
+ lastUsedAt = Date.now();
282
308
  constructor(config, affinity) {
283
309
  this.config = config;
284
310
  this.affinity = affinity;
285
311
  }
312
+ touch(now = Date.now()) {
313
+ this.lastUsedAt = now;
314
+ }
315
+ canEvict(now = Date.now()) {
316
+ return (!this.pending &&
317
+ !this.connecting &&
318
+ now - this.lastUsedAt > RELAY_WEBSOCKET_IDLE_TTL_MS);
319
+ }
320
+ dispose() {
321
+ this.socket?.close();
322
+ this.socket = undefined;
323
+ this.serverState = undefined;
324
+ this.connecting = undefined;
325
+ }
286
326
  send(payload, bodyText, optimisticResponse = false) {
327
+ this.touch();
287
328
  const enqueuedAt = perfNowMs();
288
329
  const start = this.queue
289
330
  .catch(() => { })
@@ -584,7 +625,7 @@ class PersistentRelaySession {
584
625
  return;
585
626
  }
586
627
  if (message.type === 'done') {
587
- this.finishPending();
628
+ this.finishPending(message);
588
629
  return;
589
630
  }
590
631
  if (message.type === 'error') {
@@ -609,12 +650,15 @@ class PersistentRelaySession {
609
650
  this.failPending(error);
610
651
  }
611
652
  }
612
- finishPending() {
653
+ finishPending(done) {
613
654
  const pending = this.pending;
614
655
  if (!pending)
615
656
  return;
616
657
  const finishedAt = perfNowMs();
617
- 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}`);
658
+ const workerStats = done && 'frames' in done
659
+ ? ` upstreamChunks=${done.upstreamChunks ?? 'unknown'} upstreamBytes=${done.upstreamBytes ?? 'unknown'} frames=${done.frames} frameBytes=${done.frameBytes ?? 'unknown'}`
660
+ : '';
661
+ 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}`);
618
662
  clearTimeout(pending.timeout);
619
663
  if (!pending.streamDone) {
620
664
  pending.streamDone = true;
@@ -638,12 +682,48 @@ class PersistentRelaySession {
638
682
  pending.rejectDone(error);
639
683
  }
640
684
  }
685
+ function cleanupRelaySessionCaches(now = Date.now()) {
686
+ for (const [affinity, state] of sessionState) {
687
+ if (now - state.updatedAt > RELAY_SESSION_STATE_TTL_MS) {
688
+ sessionState.delete(affinity);
689
+ }
690
+ }
691
+ while (sessionState.size > MAX_RELAY_SESSION_STATES) {
692
+ const oldest = sessionState.keys().next().value;
693
+ if (oldest === undefined)
694
+ break;
695
+ sessionState.delete(oldest);
696
+ }
697
+ for (const [affinity, session] of websocketSessions) {
698
+ if (session.canEvict(now)) {
699
+ session.dispose();
700
+ websocketSessions.delete(affinity);
701
+ }
702
+ }
703
+ while (websocketSessions.size > MAX_RELAY_WEBSOCKET_SESSIONS) {
704
+ let evicted = false;
705
+ for (const [affinity, session] of websocketSessions) {
706
+ if (!session.canEvict(now))
707
+ continue;
708
+ session.dispose();
709
+ websocketSessions.delete(affinity);
710
+ evicted = true;
711
+ break;
712
+ }
713
+ if (!evicted)
714
+ break;
715
+ }
716
+ }
641
717
  function getPersistentRelaySession(config, affinity) {
718
+ cleanupRelaySessionCaches();
642
719
  const existing = websocketSessions.get(affinity);
643
- if (existing)
720
+ if (existing) {
721
+ existing.touch();
644
722
  return existing;
723
+ }
645
724
  const session = new PersistentRelaySession(config, affinity);
646
725
  websocketSessions.set(affinity, session);
726
+ cleanupRelaySessionCaches();
647
727
  return session;
648
728
  }
649
729
  export async function sendViaRelay(options) {
@@ -658,8 +738,15 @@ export async function sendViaRelay(options) {
658
738
  return fallback();
659
739
  }
660
740
  const bodyText = body;
741
+ const hashStart = perfNowMs();
661
742
  const nextHash = hashBody(bodyText);
743
+ relayPerfLog('hash_body', {
744
+ session: shortAffinity(affinity),
745
+ ms: formatMs(perfNowMs() - hashStart),
746
+ bodyBytes: bodyText.length,
747
+ });
662
748
  const previous = sessionState.get(affinity);
749
+ const payloadStart = perfNowMs();
663
750
  const payload = createRelayPayload({
664
751
  input,
665
752
  init,
@@ -669,8 +756,16 @@ export async function sendViaRelay(options) {
669
756
  previous,
670
757
  nextHash,
671
758
  });
759
+ relayPerfLog('create_payload', {
760
+ session: shortAffinity(affinity),
761
+ ms: formatMs(perfNowMs() - payloadStart),
762
+ mode: payload.mode,
763
+ bodyBytes: bodyText.length,
764
+ previousBytes: previous?.body.length,
765
+ });
672
766
  try {
673
767
  let result;
768
+ const sendStart = perfNowMs();
674
769
  if (config.transport === 'websocket') {
675
770
  try {
676
771
  const session = getPersistentRelaySession(config, affinity);
@@ -678,19 +773,47 @@ export async function sendViaRelay(options) {
678
773
  }
679
774
  catch (error) {
680
775
  relayLog(`websocket relay failed session=${shortAffinity(affinity)}; trying http relay: ${error instanceof Error ? error.message : String(error)}`);
681
- result = await sendRelayHttp({ config, payload, bodyText, fallback });
776
+ result = await sendRelayHttp({
777
+ config,
778
+ payload,
779
+ bodyText,
780
+ fallback,
781
+ signal: init?.signal,
782
+ });
682
783
  }
683
784
  }
684
785
  else {
685
- result = await sendRelayHttp({ config, payload, bodyText, fallback });
786
+ result = await sendRelayHttp({
787
+ config,
788
+ payload,
789
+ bodyText,
790
+ fallback,
791
+ signal: init?.signal,
792
+ });
686
793
  }
794
+ relayPerfLog('send', {
795
+ session: shortAffinity(affinity),
796
+ ms: formatMs(perfNowMs() - sendStart),
797
+ transport: result.transport,
798
+ protocol: result.protocol,
799
+ mode: result.payload.mode,
800
+ status: result.response.status,
801
+ usedRelay: result.usedRelay,
802
+ });
687
803
  if (!result.usedRelay)
688
804
  return result.response;
689
805
  if (result.transport === 'http') {
690
806
  updateLocalRelayState(affinity, bodyText, result.payload.next_hash, result.payload.revision);
691
807
  }
808
+ const payloadStringifyStart = perfNowMs();
692
809
  const actualPayloadBytes = JSON.stringify(result.payload).length;
810
+ relayPerfLog('payload_stringify', {
811
+ session: shortAffinity(affinity),
812
+ ms: formatMs(perfNowMs() - payloadStringifyStart),
813
+ relayBytes: actualPayloadBytes,
814
+ });
693
815
  relayLog(`used relay transport=${result.transport} protocol=${result.protocol} mode=${result.payload.mode} status=${result.response.status} session=${shortAffinity(affinity)} bodyBytes=${bodyText.length} relayBytes=${actualPayloadBytes}`);
816
+ const dumpStart = perfNowMs();
694
817
  await dumpRelayRequest({
695
818
  affinity,
696
819
  transport: result.transport,
@@ -702,6 +825,11 @@ export async function sendViaRelay(options) {
702
825
  payload: result.payload,
703
826
  relayBytes: actualPayloadBytes,
704
827
  });
828
+ relayPerfLog('dump', {
829
+ session: shortAffinity(affinity),
830
+ ms: formatMs(perfNowMs() - dumpStart),
831
+ enabled: true,
832
+ });
705
833
  return result.response;
706
834
  }
707
835
  catch (error) {
@@ -801,6 +929,73 @@ function deferWorkerTask(task) {
801
929
  return new Promise((resolve) => setTimeout(resolve, 0)).then(task)
802
930
  }
803
931
 
932
+ const WEBSOCKET_STREAM_FLUSH_BYTES = 1024
933
+ const WEBSOCKET_STREAM_FLUSH_MS = 20
934
+
935
+ function createWebSocketStreamCoalescer(socket, options = {}) {
936
+ const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES
937
+ const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS
938
+ let chunks = []
939
+ let byteLength = 0
940
+ let flushTimer = null
941
+ const stats = {
942
+ upstreamChunks: 0,
943
+ upstreamBytes: 0,
944
+ frames: 0,
945
+ frameBytes: 0,
946
+ }
947
+
948
+ const clearFlushTimer = () => {
949
+ if (flushTimer === null) return
950
+ clearTimeout(flushTimer)
951
+ flushTimer = null
952
+ }
953
+
954
+ const mergeChunks = () => {
955
+ if (chunks.length === 1) return chunks[0]
956
+ const frame = new Uint8Array(byteLength)
957
+ let offset = 0
958
+ for (const chunk of chunks) {
959
+ frame.set(chunk, offset)
960
+ offset += chunk.byteLength
961
+ }
962
+ return frame
963
+ }
964
+
965
+ const flush = () => {
966
+ clearFlushTimer()
967
+ if (byteLength === 0) return
968
+ const frame = mergeChunks()
969
+ chunks = []
970
+ byteLength = 0
971
+ socket.send(frame)
972
+ stats.frames += 1
973
+ stats.frameBytes += frame.byteLength
974
+ }
975
+
976
+ const scheduleFlush = () => {
977
+ if (flushTimer !== null) return
978
+ flushTimer = setTimeout(flush, flushMs)
979
+ }
980
+
981
+ return {
982
+ push(value) {
983
+ const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)
984
+ if (chunk.byteLength === 0) return
985
+ stats.upstreamChunks += 1
986
+ stats.upstreamBytes += chunk.byteLength
987
+ chunks.push(chunk)
988
+ byteLength += chunk.byteLength
989
+ if (byteLength >= flushBytes) flush()
990
+ else scheduleFlush()
991
+ },
992
+ flush,
993
+ stats() {
994
+ return { ...stats, pendingBytes: byteLength }
995
+ },
996
+ }
997
+ }
998
+
804
999
  function checkpointWebSocketState(env, payload, body, nextState) {
805
1000
  return deferWorkerTask(async () => {
806
1001
  try {
@@ -871,6 +1066,7 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
871
1066
  clearInterval(heartbeat)
872
1067
  }
873
1068
  }, 15000)
1069
+ let streamCoalescer = null
874
1070
  try {
875
1071
  const result = await prepareWebSocketUpstream(env, getState(), payload)
876
1072
  if (result.error) {
@@ -900,14 +1096,20 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
900
1096
 
901
1097
  const reader = upstream.body?.getReader()
902
1098
  if (reader) {
1099
+ streamCoalescer = createWebSocketStreamCoalescer(socket)
903
1100
  while (true) {
904
1101
  const { done, value } = await reader.read()
905
1102
  if (done) break
906
- socket.send(value)
1103
+ streamCoalescer.push(value)
907
1104
  }
1105
+ streamCoalescer.flush()
908
1106
  }
909
- socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id }))
1107
+ const stats = streamCoalescer?.stats?.() || {}
1108
+ socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))
910
1109
  } catch (error) {
1110
+ try {
1111
+ streamCoalescer?.flush?.()
1112
+ } catch {}
911
1113
  socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))
912
1114
  } finally {
913
1115
  clearInterval(heartbeat)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",