@cortexkit/anthropic-auth-core 1.21.0 → 1.23.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/accounts.d.ts +100 -3
  3. package/dist/accounts.js +1099 -169
  4. package/dist/auth.js +6 -10
  5. package/dist/cachekeep.d.ts +18 -1
  6. package/dist/cachekeep.js +108 -82
  7. package/dist/cch.d.ts +23 -9
  8. package/dist/cch.js +104 -21
  9. package/dist/claude-code.d.ts +8 -3
  10. package/dist/claude-code.js +24 -7
  11. package/dist/claustrum-enrollment-registry.d.ts +17 -0
  12. package/dist/claustrum-enrollment-registry.js +194 -0
  13. package/dist/claustrum-enrollment.d.ts +70 -0
  14. package/dist/claustrum-enrollment.js +485 -0
  15. package/dist/claustrum-scoped-roster.d.ts +23 -0
  16. package/dist/claustrum-scoped-roster.js +194 -0
  17. package/dist/claustrum-scoped-runtime.d.ts +28 -0
  18. package/dist/claustrum-scoped-runtime.js +230 -0
  19. package/dist/claustrum-scoped.d.ts +39 -0
  20. package/dist/claustrum-scoped.js +193 -0
  21. package/dist/claustrum.d.ts +252 -0
  22. package/dist/claustrum.js +1471 -0
  23. package/dist/commands/account.d.ts +43 -11
  24. package/dist/commands/account.js +147 -4
  25. package/dist/constants.d.ts +5 -5
  26. package/dist/constants.js +5 -5
  27. package/dist/custom-headers.d.ts +3 -0
  28. package/dist/custom-headers.js +102 -0
  29. package/dist/dump.js +2 -1
  30. package/dist/fast.js +1 -1
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.js +11 -0
  33. package/dist/json.d.ts +1 -0
  34. package/dist/json.js +10 -0
  35. package/dist/logger.js +3 -1
  36. package/dist/mid-conversation-output-config.d.ts +13 -0
  37. package/dist/mid-conversation-output-config.js +89 -0
  38. package/dist/model-remap.d.ts +27 -0
  39. package/dist/model-remap.js +72 -0
  40. package/dist/models.d.ts +46 -1
  41. package/dist/models.js +87 -8
  42. package/dist/network-errors.d.ts +1 -0
  43. package/dist/network-errors.js +27 -0
  44. package/dist/oauth-profile.d.ts +2 -1
  45. package/dist/oauth-profile.js +6 -0
  46. package/dist/prime.d.ts +11 -3
  47. package/dist/prime.js +25 -7
  48. package/dist/quota-header-feed.d.ts +32 -7
  49. package/dist/quota-header-feed.js +93 -10
  50. package/dist/quota-headers.js +59 -6
  51. package/dist/quota-manager.d.ts +20 -2
  52. package/dist/quota-manager.js +101 -24
  53. package/dist/relay.d.ts +8 -0
  54. package/dist/relay.js +72 -12
  55. package/dist/sticky-routing.d.ts +2 -0
  56. package/dist/sticky-routing.js +19 -1
  57. package/dist/thinking-binding.d.ts +7 -0
  58. package/dist/thinking-binding.js +54 -0
  59. package/package.json +4 -1
@@ -51,6 +51,8 @@ export class QuotaManager {
51
51
  // --- Inflight deduplication ---
52
52
  inflightMain = null;
53
53
  inflightMainAccountId;
54
+ inflightMainAccessToken;
55
+ inflightMainToken = 0;
54
56
  inflightFallbacks = new Map();
55
57
  // --- Rate-limiting (scoped per route so a fallback 429 never backs off the
56
58
  // main account or vice versa) ---
@@ -62,15 +64,21 @@ export class QuotaManager {
62
64
  // --- Config ---
63
65
  storage;
64
66
  fetchImpl;
67
+ fetchQuotaSnapshot;
65
68
  now;
66
69
  onMainQuotaFetched;
67
70
  onApiError;
71
+ onFallbackQuotaFetched;
72
+ onFallbackApiError;
68
73
  constructor(opts) {
69
74
  this.storage = opts.storage;
70
75
  this.fetchImpl = opts.fetchImpl ?? fetch;
76
+ this.fetchQuotaSnapshot = opts.fetchQuotaSnapshot;
71
77
  this.now = opts.now ?? Date.now;
72
78
  this.onMainQuotaFetched = opts.onMainQuotaFetched;
73
79
  this.onApiError = opts.onApiError;
80
+ this.onFallbackQuotaFetched = opts.onFallbackQuotaFetched;
81
+ this.onFallbackApiError = opts.onFallbackApiError;
74
82
  this.seedMainFromStorage(opts.storage, opts.storage?.mainAccountId);
75
83
  this.seedMainBackoffFromStorage(opts.storage);
76
84
  }
@@ -99,6 +107,12 @@ export class QuotaManager {
99
107
  this.bindFallbackLineage(accountId, account);
100
108
  return this.fallbacks.get(accountId) ?? null;
101
109
  }
110
+ /** A host transport resolves scoped credentials at dispatch time. */
111
+ canFetchWithoutAccessToken() {
112
+ return Boolean(this.fetchQuotaSnapshot &&
113
+ (this.storage === null ||
114
+ this.storage?.claustrum?.scopedRoster === true));
115
+ }
102
116
  getAllFallbacks() {
103
117
  return this.fallbacks;
104
118
  }
@@ -109,11 +123,16 @@ export class QuotaManager {
109
123
  this.mainAccountId = mainAccountId;
110
124
  this.main = entry;
111
125
  }
126
+ clearMain() {
127
+ this.mainAccountId = undefined;
128
+ this.main = null;
129
+ }
112
130
  /** Bind quota state to the account behind the current main credential. */
113
- setMainQuotaAccountIdentity(accountIdentity, strict = true) {
131
+ setMainQuotaAccountIdentity(accountIdentity, strict = true, force = false) {
114
132
  const previousIdentityKnown = this.mainQuotaIdentityKnown;
115
133
  const previousAccountIdentity = this.mainAccountId;
116
- const changed = !previousIdentityKnown ||
134
+ const changed = force ||
135
+ !previousIdentityKnown ||
117
136
  this.mainAccountId !== accountIdentity ||
118
137
  this.mainQuotaIdentityStrict !== strict;
119
138
  this.mainQuotaIdentityKnown = true;
@@ -122,7 +141,13 @@ export class QuotaManager {
122
141
  return undefined;
123
142
  this.mainGeneration += 1;
124
143
  this.mainAccountId = accountIdentity;
125
- if (accountIdentity !== undefined &&
144
+ if (force) {
145
+ this.main = null;
146
+ this.mainLastApiError = undefined;
147
+ this.mainQuotaErrorGeneration += 1;
148
+ this.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt(this.mainQuotaErrorClearedAt, this.now());
149
+ }
150
+ else if (accountIdentity !== undefined &&
126
151
  this.main?.quota.accountIdentity !== accountIdentity) {
127
152
  this.main = null;
128
153
  }
@@ -191,14 +216,14 @@ export class QuotaManager {
191
216
  async refreshMain(mainAccountIdOrAccessToken, accessToken, expectedIdentityGeneration) {
192
217
  const mainAccountId = mainAccountIdOrAccessToken;
193
218
  const credential = accessToken ?? mainAccountIdOrAccessToken;
194
- if (!credential)
219
+ if (!credential && !this.canFetchWithoutAccessToken())
195
220
  throw new Error('Main OAuth access token is unavailable');
196
221
  return (await this.refreshMainWithMetadata(mainAccountId, credential, expectedIdentityGeneration)).quota;
197
222
  }
198
223
  async refreshMainWithMetadata(mainAccountIdOrAccessToken, accessToken, expectedIdentityGeneration) {
199
224
  const effectiveAccountId = mainAccountIdOrAccessToken;
200
225
  const credential = accessToken ?? mainAccountIdOrAccessToken;
201
- if (!credential)
226
+ if (!credential && !this.canFetchWithoutAccessToken())
202
227
  throw new Error('Main OAuth access token is unavailable');
203
228
  if (expectedIdentityGeneration !== undefined &&
204
229
  (this.mainGeneration !== expectedIdentityGeneration ||
@@ -211,7 +236,9 @@ export class QuotaManager {
211
236
  if (this.mainQuotaIdentityKnown)
212
237
  this.mainGeneration += 1;
213
238
  }
214
- if (this.inflightMain && this.inflightMainAccountId === effectiveAccountId)
239
+ if (this.inflightMain &&
240
+ this.inflightMainAccountId === effectiveAccountId &&
241
+ this.inflightMainAccessToken === credential)
215
242
  return this.inflightMain;
216
243
  // Rate-limit — if API recently 429'd, return stale or throw
217
244
  if (this.isBackedOff()) {
@@ -221,14 +248,19 @@ export class QuotaManager {
221
248
  throw new Error('Quota API rate-limited — try again later');
222
249
  }
223
250
  this.inflightMainAccountId = effectiveAccountId;
251
+ this.inflightMainAccessToken = credential;
224
252
  const generation = this.mainGeneration;
225
- this.inflightMain = this._fetchMain(effectiveAccountId, credential, generation);
253
+ const inflightToken = ++this.inflightMainToken;
254
+ this.inflightMain = this._fetchMain(effectiveAccountId, credential ?? '', generation, inflightToken);
226
255
  return this.inflightMain;
227
256
  }
228
257
  async refreshFallback(accountId, accessToken, account) {
229
258
  return (await this.refreshFallbackWithMetadata(accountId, accessToken, account)).quota;
230
259
  }
231
260
  async refreshFallbackWithMetadata(accountId, accessToken, account) {
261
+ if (!accessToken && !this.canFetchWithoutAccessToken()) {
262
+ throw new Error('Fallback OAuth access token is unavailable');
263
+ }
232
264
  this.bindFallbackLineage(accountId, account);
233
265
  const inflightKey = QuotaManager.fallbackInflightKey(accountId);
234
266
  const inflight = this.inflightFallbacks.get(inflightKey);
@@ -255,19 +287,26 @@ export class QuotaManager {
255
287
  });
256
288
  return promise;
257
289
  }
258
- async refreshAllFallbacks(accounts) {
290
+ async refreshAllFallbacks(accounts, resolveAccessToken) {
259
291
  const now = this.now();
260
292
  for (const account of accounts) {
261
293
  if (account.enabled === false)
262
294
  continue;
263
- if (!account.access)
295
+ const accessToken = resolveAccessToken !== undefined
296
+ ? resolveAccessToken(account)
297
+ : account.access &&
298
+ account.expires !== undefined &&
299
+ account.expires > now
300
+ ? account.access
301
+ : undefined;
302
+ if (!accessToken)
264
303
  continue;
265
304
  this.bindFallbackLineage(account.id, account);
266
305
  const cached = this.getFallback(account.id);
267
306
  if (cached && now < cached.refreshAfter)
268
307
  continue;
269
308
  try {
270
- await this.refreshFallback(account.id, account.access, account);
309
+ await this.refreshFallback(account.id, accessToken, account);
271
310
  }
272
311
  catch {
273
312
  // Best-effort — keep stale cache entry if fetch fails
@@ -462,6 +501,18 @@ export class QuotaManager {
462
501
  continue;
463
502
  if (account.enabled === false)
464
503
  continue;
504
+ const persistedError = account.lastQuotaRefreshError;
505
+ const persistedClearAt = quotaSnapshotCheckedAt(account.quota);
506
+ const currentError = this.fallbackApiErrors.get(account.id);
507
+ if (persistedError &&
508
+ quotaBackoffActive(persistedError, this.now()) &&
509
+ persistedClearAt <= (persistedError.checkedAt ?? 0) &&
510
+ (!currentError || persistedError.checkedAt >= currentError.checkedAt)) {
511
+ this.fallbackApiErrors.set(account.id, persistedError);
512
+ }
513
+ else if (currentError && persistedClearAt >= currentError.checkedAt) {
514
+ this.fallbackApiErrors.delete(account.id);
515
+ }
465
516
  if (!account.quota)
466
517
  continue;
467
518
  const checkedAt = quotaSnapshotCheckedAt(account.quota);
@@ -553,6 +604,12 @@ export class QuotaManager {
553
604
  this.fallbackAuthLineages.set(accountId, observedLineage);
554
605
  return true;
555
606
  }
607
+ isClosed = false;
608
+ close() {
609
+ this.isClosed = true;
610
+ this.inflightFallbacks.clear();
611
+ this.inflightMain = null;
612
+ }
556
613
  /**
557
614
  * Serialize API calls through a shared gate so only one
558
615
  * quota API request runs at a time, with a minimum gap
@@ -561,6 +618,8 @@ export class QuotaManager {
561
618
  */
562
619
  _enqueueApiFetch(fn) {
563
620
  const gatedFn = async () => {
621
+ if (this.isClosed)
622
+ throw new Error('QuotaManager is closed');
564
623
  // Wait until minimum gap since last API call
565
624
  const elapsed = this.now() - this.lastApiCallAt;
566
625
  if (elapsed < QuotaManager.API_CALL_GAP_MS) {
@@ -570,6 +629,8 @@ export class QuotaManager {
570
629
  id.unref();
571
630
  });
572
631
  }
632
+ if (this.isClosed)
633
+ throw new Error('QuotaManager is closed');
573
634
  this.lastApiCallAt = this.now();
574
635
  return fn();
575
636
  };
@@ -577,7 +638,7 @@ export class QuotaManager {
577
638
  this.apiGate = queued.catch(() => { });
578
639
  return queued;
579
640
  }
580
- async _fetchMain(mainAccountId, accessToken, generation) {
641
+ async _fetchMain(mainAccountId, accessToken, generation, inflightToken) {
581
642
  return this._enqueueApiFetch(async () => {
582
643
  try {
583
644
  // Re-check backoff inside gate — may have been set by
@@ -611,11 +672,17 @@ export class QuotaManager {
611
672
  }
612
673
  try {
613
674
  const fetchStartedAt = this.now();
614
- const quota = await fetchOAuthQuotaSnapshot({
615
- accessToken,
616
- fetchImpl: this.fetchImpl,
617
- now: this.now,
618
- });
675
+ const quota = this.fetchQuotaSnapshot
676
+ ? await this.fetchQuotaSnapshot({
677
+ kind: 'main',
678
+ accountId: mainAccountId,
679
+ accessToken,
680
+ })
681
+ : await fetchOAuthQuotaSnapshot({
682
+ accessToken,
683
+ fetchImpl: this.fetchImpl,
684
+ now: this.now,
685
+ });
619
686
  const now = this.now();
620
687
  if (this.mainQuotaIdentityKnown &&
621
688
  this.mainQuotaIdentityStrict &&
@@ -664,7 +731,7 @@ export class QuotaManager {
664
731
  }
665
732
  }
666
733
  finally {
667
- if (this.inflightMainAccountId === mainAccountId) {
734
+ if (this.inflightMainToken === inflightToken) {
668
735
  this.inflightMain = null;
669
736
  this.inflightMainAccountId = undefined;
670
737
  }
@@ -696,11 +763,18 @@ export class QuotaManager {
696
763
  throw new Error('Quota refresh is already in progress');
697
764
  }
698
765
  try {
699
- const quota = await fetchOAuthQuotaSnapshot({
700
- accessToken,
701
- fetchImpl: this.fetchImpl,
702
- now: this.now,
703
- });
766
+ const fetchStartedAt = this.now();
767
+ const quota = this.fetchQuotaSnapshot
768
+ ? await this.fetchQuotaSnapshot({
769
+ kind: 'fallback',
770
+ accountId,
771
+ accessToken,
772
+ })
773
+ : await fetchOAuthQuotaSnapshot({
774
+ accessToken,
775
+ fetchImpl: this.fetchImpl,
776
+ now: this.now,
777
+ });
704
778
  const now = this.now();
705
779
  if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation) {
706
780
  return {
@@ -715,6 +789,7 @@ export class QuotaManager {
715
789
  checkedAt: completedQuota.checkedAt ?? now,
716
790
  }, account);
717
791
  this.fallbackApiErrors.delete(accountId);
792
+ this.onFallbackQuotaFetched?.(accountId, completedQuota, now, fetchStartedAt);
718
793
  return { quota: completedQuota, fetched: true };
719
794
  }
720
795
  finally {
@@ -769,11 +844,13 @@ export class QuotaManager {
769
844
  if (QuotaManager.isAuthError(error))
770
845
  return;
771
846
  const previous = this.fallbackApiErrors.get(accountId);
772
- this.fallbackApiErrors.set(accountId, buildQuotaOperationError({
847
+ const quotaError = buildQuotaOperationError({
773
848
  error,
774
849
  now: this.now(),
775
850
  previous,
776
851
  accountIdentity: accountId,
777
- }));
852
+ });
853
+ this.fallbackApiErrors.set(accountId, quotaError);
854
+ this.onFallbackApiError?.(accountId, quotaError);
778
855
  }
779
856
  }
package/dist/relay.d.ts CHANGED
@@ -11,6 +11,12 @@ export type RelayPatch = {
11
11
  deleteCount: number;
12
12
  insert: string;
13
13
  };
14
+ export type RelayAttemptAuthorization = {
15
+ headers: Headers;
16
+ /** Called only for a genuine upstream response, never optimistic/transport status. */
17
+ onUpstreamStatus?: (status: number) => void;
18
+ };
19
+ export type AuthorizeRelayAttempt = () => Promise<RelayAttemptAuthorization>;
14
20
  export declare function getRelayConfig(storage: AccountStorage | null): RelayConfig | null;
15
21
  export declare function generateRelayToken(): string;
16
22
  export declare function hashBody(body: string): string;
@@ -24,6 +30,8 @@ export declare function sendViaRelay(options: {
24
30
  fallback: () => Promise<Response>;
25
31
  affinity?: string | null;
26
32
  optimisticResponse?: boolean;
33
+ /** Fresh authorization for every physical relay attempt, including internal retries. */
34
+ authorizeAttempt?: AuthorizeRelayAttempt;
27
35
  /**
28
36
  * Observes genuine upstream headers once per delivered response. WebSocket
29
37
  * retries before downstream bytes replace staged headers, so the callback
package/dist/relay.js CHANGED
@@ -2,6 +2,28 @@ import { Buffer } from 'node:buffer';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
3
  import { dumpRelayRequest } from './dump.js';
4
4
  import { relayLog } from './logger.js';
5
+ class RelayAuthorizationError extends Error {
6
+ failure;
7
+ constructor(failure) {
8
+ super('Relay request authorization failed');
9
+ this.failure = failure;
10
+ }
11
+ }
12
+ async function authorizeRelayPayload(payload, authorize) {
13
+ if (!authorize)
14
+ return undefined;
15
+ try {
16
+ const attempt = await authorize();
17
+ payload.upstream = {
18
+ ...payload.upstream,
19
+ headers: jsonHeaders(attempt.headers),
20
+ };
21
+ return attempt;
22
+ }
23
+ catch (error) {
24
+ throw new RelayAuthorizationError(error);
25
+ }
26
+ }
5
27
  class RelayStateMismatchError extends Error {
6
28
  }
7
29
  class RelayWebSocketConnectionResetError extends Error {
@@ -229,15 +251,29 @@ function updateLocalRelayState(affinity, bodyText, nextHash, revision) {
229
251
  cleanupRelaySessionCaches(now);
230
252
  }
231
253
  async function sendRelayHttp(options) {
232
- const { config, payload, bodyText, fallback, signal } = options;
254
+ const { config, payload, bodyText, fallback, signal, authorizeAttempt } = options;
233
255
  let actualPayload = payload;
256
+ let authorization = authorizeAttempt
257
+ ? await authorizeRelayPayload(actualPayload, authorizeAttempt)
258
+ : undefined;
234
259
  let response = await postRelay(config, actualPayload, signal);
235
260
  if (response.status === 409 && actualPayload.mode === 'patch') {
236
261
  relayLog(`state mismatch from relay session=${shortAffinity(actualPayload.affinity)}; retrying full_sync`);
237
262
  await response.body?.cancel().catch(() => { });
238
263
  actualPayload = createFullSyncPayload(actualPayload, bodyText);
264
+ authorization = authorizeAttempt
265
+ ? await authorizeRelayPayload(actualPayload, authorizeAttempt)
266
+ : undefined;
239
267
  response = await postRelay(config, actualPayload, signal);
240
268
  }
269
+ // Old workers already forward Anthropic request IDs. Without upstream
270
+ // provenance a relay's own 401 must never invalidate an OAuth credential.
271
+ if (/^req_[A-Za-z0-9_-]+$/.test(response.headers.get('request-id') ?? '')) {
272
+ try {
273
+ authorization?.onUpstreamStatus?.(response.status);
274
+ }
275
+ catch { }
276
+ }
241
277
  if (!response.ok && response.status >= 500 && config.fallbackToDirect) {
242
278
  relayLog(`relay returned ${response.status}; falling back direct session=${shortAffinity(actualPayload.affinity)}`);
243
279
  await response.body?.cancel().catch(() => { });
@@ -325,12 +361,12 @@ class PersistentRelaySession {
325
361
  this.serverState = undefined;
326
362
  this.connecting = undefined;
327
363
  }
328
- send(payload, bodyText, optimisticResponse = false, onResponseHeaders) {
364
+ send(payload, bodyText, optimisticResponse = false, onResponseHeaders, authorizeAttempt) {
329
365
  this.touch();
330
366
  const enqueuedAt = perfNowMs();
331
367
  const start = this.queue
332
368
  .catch(() => { })
333
- .then(() => this.startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders));
369
+ .then(() => this.startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders, authorizeAttempt));
334
370
  const result = start.then(async ({ response, getPayload }) => ({
335
371
  response: await response,
336
372
  payload: getPayload(),
@@ -341,7 +377,7 @@ class PersistentRelaySession {
341
377
  this.queue = start.then(({ done }) => done.catch(() => { }), () => { });
342
378
  return result;
343
379
  }
344
- async startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders) {
380
+ async startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders, authorizeAttempt) {
345
381
  const connectStart = perfNowMs();
346
382
  await this.ensureConnected();
347
383
  const connectedAt = perfNowMs();
@@ -364,11 +400,14 @@ class PersistentRelaySession {
364
400
  requestPayload.patch = createRelayPatch(localState.body, bodyText);
365
401
  }
366
402
  relayLog(`perf websocket send_start session=${shortAffinity(this.affinity)} request=${requestPayload.id} mode=${requestPayload.mode} queueMs=${formatMs(connectedAt - enqueuedAt)} connectMs=${formatMs(connectedAt - connectStart)} bodyBytes=${bodyText.length} relayBytes=${JSON.stringify(requestPayload).length}`);
403
+ const authorization = authorizeAttempt
404
+ ? await authorizeRelayPayload(requestPayload, authorizeAttempt)
405
+ : undefined;
367
406
  let activePayload = requestPayload;
368
- const first = this.sendPayload(requestPayload, bodyText, optimisticResponse, onResponseHeaders);
407
+ const first = this.sendPayload(requestPayload, bodyText, optimisticResponse, onResponseHeaders, authorizeAttempt, authorization);
369
408
  void first.done.catch(() => { });
370
409
  let activeDone = first.done;
371
- const response = first.response.catch((error) => {
410
+ const response = first.response.catch(async (error) => {
372
411
  if (!(error instanceof RelayStateMismatchError) ||
373
412
  requestPayload.mode !== 'patch') {
374
413
  throw error;
@@ -377,8 +416,11 @@ class PersistentRelaySession {
377
416
  fullSync.protocol = 2;
378
417
  fullSync.id = createRequestId();
379
418
  fullSync.revision = (this.serverState?.revision ?? 0) + 1;
419
+ const retryAuthorization = authorizeAttempt
420
+ ? await authorizeRelayPayload(fullSync, authorizeAttempt)
421
+ : undefined;
380
422
  activePayload = fullSync;
381
- const retry = this.sendPayload(fullSync, bodyText, optimisticResponse, onResponseHeaders);
423
+ const retry = this.sendPayload(fullSync, bodyText, optimisticResponse, onResponseHeaders, authorizeAttempt, retryAuthorization);
382
424
  void retry.done.catch(() => { });
383
425
  activeDone = retry.done;
384
426
  return retry.response;
@@ -459,7 +501,7 @@ class PersistentRelaySession {
459
501
  });
460
502
  await this.connecting;
461
503
  }
462
- sendPayload(payload, bodyText, optimisticResponse, onResponseHeaders) {
504
+ sendPayload(payload, bodyText, optimisticResponse, onResponseHeaders, authorizeAttempt, authorization) {
463
505
  const socket = this.socket;
464
506
  if (!socket || socket.readyState !== WebSocket.OPEN) {
465
507
  throw new Error('relay websocket is not connected');
@@ -489,6 +531,8 @@ class PersistentRelaySession {
489
531
  retryAttempts: 0,
490
532
  retryingBeforeResponse: false,
491
533
  onResponseHeaders,
534
+ authorizeAttempt,
535
+ authorization,
492
536
  stagedResponseHeaders: undefined,
493
537
  responseHeadersDelivered: false,
494
538
  };
@@ -541,6 +585,12 @@ class PersistentRelaySession {
541
585
  retryPayload.protocol = 2;
542
586
  retryPayload.id = createRequestId();
543
587
  retryPayload.revision = (this.serverState?.revision ?? 0) + 1;
588
+ const authorization = pending.authorizeAttempt
589
+ ? await authorizeRelayPayload(retryPayload, pending.authorizeAttempt)
590
+ : undefined;
591
+ if (this.pending !== pending || pending.streamDone)
592
+ return;
593
+ pending.authorization = authorization;
544
594
  pending.payload = retryPayload;
545
595
  pending.accepted = false;
546
596
  pending.acceptedAt = undefined;
@@ -560,7 +610,7 @@ class PersistentRelaySession {
560
610
  catch (error) {
561
611
  pending.retryingBeforeResponse = false;
562
612
  if (this.pending === pending)
563
- this.failPending(error);
613
+ this.failPending(error instanceof RelayAuthorizationError ? error.failure : error);
564
614
  }
565
615
  })();
566
616
  return true;
@@ -631,8 +681,12 @@ class PersistentRelaySession {
631
681
  return;
632
682
  const responseStartedAt = perfNowMs();
633
683
  pending.responseStartedAt = responseStartedAt;
684
+ try {
685
+ pending.authorization?.onUpstreamStatus?.(message.status);
686
+ }
687
+ catch { }
634
688
  relayLog(`perf websocket response_start session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(responseStartedAt - pending.sentAt)} upstreamMs=${pending.acceptedAt == null ? 'unknown' : formatMs(responseStartedAt - pending.acceptedAt)} status=${message.status}`);
635
- clearTimeout(pending.timeout);
689
+ this.timers.clearTimeout(pending.timeout);
636
690
  if (message.headers)
637
691
  pending.stagedResponseHeaders = new Headers(message.headers);
638
692
  this.resolvePendingResponse(pending, message.status, message.statusText, message.headers);
@@ -752,7 +806,7 @@ function getPersistentRelaySession(config, affinity, timers) {
752
806
  return session;
753
807
  }
754
808
  export async function sendViaRelay(options) {
755
- const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, onResponseHeaders, dumpTag, onDumpCreated, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, } = options;
809
+ const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, authorizeAttempt, onResponseHeaders, dumpTag, onDumpCreated, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, } = options;
756
810
  if (!config || !isRelayableAnthropicRequest(input, body))
757
811
  return fallback();
758
812
  const affinity = explicitAffinity ||
@@ -797,9 +851,11 @@ export async function sendViaRelay(options) {
797
851
  setTimeout: setTimeoutImpl,
798
852
  clearTimeout: clearTimeoutImpl,
799
853
  });
800
- result = await session.send(payload, bodyText, optimisticResponse === true, onResponseHeaders);
854
+ result = await session.send(payload, bodyText, optimisticResponse === true, onResponseHeaders, authorizeAttempt);
801
855
  }
802
856
  catch (error) {
857
+ if (error instanceof RelayAuthorizationError)
858
+ throw error;
803
859
  relayLog(`websocket relay failed session=${shortAffinity(affinity)}; trying http relay: ${error instanceof Error ? error.message : String(error)}`);
804
860
  result = await sendRelayHttp({
805
861
  config,
@@ -807,6 +863,7 @@ export async function sendViaRelay(options) {
807
863
  bodyText,
808
864
  fallback,
809
865
  signal: init?.signal,
866
+ authorizeAttempt,
810
867
  });
811
868
  }
812
869
  }
@@ -817,6 +874,7 @@ export async function sendViaRelay(options) {
817
874
  bodyText,
818
875
  fallback,
819
876
  signal: init?.signal,
877
+ authorizeAttempt,
820
878
  });
821
879
  }
822
880
  relayPerfLog('send', {
@@ -873,6 +931,8 @@ export async function sendViaRelay(options) {
873
931
  return result.response;
874
932
  }
875
933
  catch (error) {
934
+ if (error instanceof RelayAuthorizationError)
935
+ throw error.failure;
876
936
  if (!config.fallbackToDirect) {
877
937
  relayLog(`relay failed session=${shortAffinity(affinity)} and fallbackToDirect=false: ${error instanceof Error ? error.message : String(error)}`);
878
938
  throw error;
@@ -23,6 +23,7 @@ export type StickyRouteCandidate = {
23
23
  export type StickyRouteAssignment = {
24
24
  accountId: string;
25
25
  family: StickyRouteFamily;
26
+ affinityModelId?: string;
26
27
  assignedAt: number;
27
28
  lastSeenAt: number;
28
29
  initialInputBytes: number;
@@ -99,6 +100,7 @@ export declare class StickySessionRouter {
99
100
  sessionId: string;
100
101
  family: StickyRouteFamily;
101
102
  modelId?: string;
103
+ affinityModelId?: string;
102
104
  candidates: readonly StickyRouteCandidate[];
103
105
  retainAccountIds: ReadonlySet<string>;
104
106
  storage: AccountStorage | null;
@@ -91,9 +91,16 @@ function normalizeAssignment(value) {
91
91
  quotaCheckedAt === undefined) {
92
92
  return;
93
93
  }
94
+ if (Object.hasOwn(value, 'affinityModelId') &&
95
+ typeof value.affinityModelId !== 'string') {
96
+ return;
97
+ }
94
98
  return {
95
99
  accountId: value.accountId,
96
100
  family: value.family,
101
+ affinityModelId: typeof value.affinityModelId === 'string'
102
+ ? value.affinityModelId
103
+ : undefined,
97
104
  assignedAt,
98
105
  lastSeenAt,
99
106
  initialInputBytes: Math.max(0, initialInputBytes),
@@ -429,9 +436,14 @@ export class StickySessionRouter {
429
436
  return null;
430
437
  await this.refreshStateIfChanged();
431
438
  const key = sessionKey(input.sessionId);
439
+ const affinityModelId = input.affinityModelId ?? input.modelId;
440
+ const matchesAffinity = (assignment) => affinityModelId !== undefined
441
+ ? assignment.affinityModelId === affinityModelId
442
+ : assignment.family === input.family;
432
443
  const cached = this.state.assignments[key];
433
444
  if (cached &&
434
445
  this.assignmentIsActive(cached) &&
446
+ matchesAffinity(cached) &&
435
447
  input.retainAccountIds.has(cached.accountId) &&
436
448
  !input.excludeAccountIds?.has(cached.accountId)) {
437
449
  if (this.now() - cached.lastSeenAt >= STICKY_ROUTING_TOUCH_INTERVAL_MS) {
@@ -451,6 +463,7 @@ export class StickySessionRouter {
451
463
  this.prune(state);
452
464
  const current = state.assignments[key];
453
465
  if (current &&
466
+ matchesAffinity(current) &&
454
467
  input.retainAccountIds.has(current.accountId) &&
455
468
  !input.excludeAccountIds?.has(current.accountId)) {
456
469
  current.lastSeenAt = this.now();
@@ -462,8 +475,12 @@ export class StickySessionRouter {
462
475
  migrated: false,
463
476
  };
464
477
  }
478
+ const affinityChanged = Boolean(current && !matchesAffinity(current));
479
+ if (affinityChanged) {
480
+ delete state.assignments[key];
481
+ }
465
482
  const candidates = input.candidates.filter((candidate) => !input.excludeAccountIds?.has(candidate.accountId));
466
- const preferred = input.preferredAccountId
483
+ const preferred = !affinityChanged && input.preferredAccountId
467
484
  ? candidates.find((candidate) => candidate.accountId === input.preferredAccountId &&
468
485
  stickyRouteCandidateWeight({
469
486
  candidate,
@@ -486,6 +503,7 @@ export class StickySessionRouter {
486
503
  const assignment = {
487
504
  accountId: selected.accountId,
488
505
  family: input.family,
506
+ affinityModelId,
489
507
  assignedAt: now,
490
508
  lastSeenAt: now,
491
509
  initialInputBytes: Math.max(1, input.inputBytes),
@@ -0,0 +1,7 @@
1
+ import type { AccountStorage } from './accounts.ts';
2
+ export declare const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01";
3
+ export type ThinkingPrefixMismatchBehavior = 'account-default' | 'error' | 'drop_block';
4
+ export declare function getThinkingPrefixMismatchBehavior(storage: Pick<AccountStorage, 'thinkingBinding'> | null | undefined): ThinkingPrefixMismatchBehavior;
5
+ export declare function hasReplayableThinkingBlocks(body: Record<string, unknown>): boolean;
6
+ export declare function applyThinkingBindingControls(body: Record<string, unknown>, behavior?: ThinkingPrefixMismatchBehavior): boolean;
7
+ export declare function hasThinkingBindingControls(body: Record<string, unknown>): boolean;
@@ -0,0 +1,54 @@
1
+ import { isClaudeFable51Model } from './models.js';
2
+ export const THINKING_BINDING_CONTROLS_BETA = 'thinking-binding-controls-2026-08-01';
3
+ function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ function isReplayableThinkingBlock(block) {
7
+ if (!isRecord(block))
8
+ return false;
9
+ if (block.type === 'thinking') {
10
+ return typeof block.signature === 'string' && block.signature.length > 0;
11
+ }
12
+ if (block.type === 'redacted_thinking') {
13
+ return typeof block.data === 'string' && block.data.length > 0;
14
+ }
15
+ return false;
16
+ }
17
+ export function getThinkingPrefixMismatchBehavior(storage) {
18
+ const behavior = storage?.thinkingBinding?.prefixMismatchBehavior;
19
+ return behavior === 'error' || behavior === 'drop_block'
20
+ ? behavior
21
+ : 'account-default';
22
+ }
23
+ export function hasReplayableThinkingBlocks(body) {
24
+ if (!Array.isArray(body.messages))
25
+ return false;
26
+ return body.messages.some((message) => {
27
+ if (!isRecord(message) || message.role !== 'assistant')
28
+ return false;
29
+ return (Array.isArray(message.content) &&
30
+ message.content.some(isReplayableThinkingBlock));
31
+ });
32
+ }
33
+ export function applyThinkingBindingControls(body, behavior = 'account-default') {
34
+ if (behavior === 'account-default')
35
+ return false;
36
+ if (!isClaudeFable51Model(body.model))
37
+ return false;
38
+ if (!hasReplayableThinkingBlocks(body))
39
+ return false;
40
+ if (!isRecord(body.thinking) || body.thinking.type === 'disabled')
41
+ return false;
42
+ body.thinking.block_binding = {
43
+ prefix_mismatch_behavior: behavior,
44
+ };
45
+ return true;
46
+ }
47
+ export function hasThinkingBindingControls(body) {
48
+ if (!isRecord(body.thinking))
49
+ return false;
50
+ if (!isRecord(body.thinking.block_binding))
51
+ return false;
52
+ const behavior = body.thinking.block_binding.prefix_mismatch_behavior;
53
+ return behavior === 'error' || behavior === 'drop_block';
54
+ }