@cortexkit/anthropic-auth-core 1.22.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 (48) hide show
  1. package/dist/accounts.d.ts +59 -2
  2. package/dist/accounts.js +666 -115
  3. package/dist/auth.js +3 -0
  4. package/dist/cachekeep.d.ts +18 -1
  5. package/dist/cachekeep.js +108 -82
  6. package/dist/cch.d.ts +11 -1
  7. package/dist/cch.js +48 -3
  8. package/dist/claude-code.d.ts +7 -1
  9. package/dist/claude-code.js +18 -3
  10. package/dist/claustrum-enrollment-registry.d.ts +17 -0
  11. package/dist/claustrum-enrollment-registry.js +194 -0
  12. package/dist/claustrum-enrollment.d.ts +70 -0
  13. package/dist/claustrum-enrollment.js +485 -0
  14. package/dist/claustrum-scoped-roster.d.ts +23 -0
  15. package/dist/claustrum-scoped-roster.js +194 -0
  16. package/dist/claustrum-scoped-runtime.d.ts +28 -0
  17. package/dist/claustrum-scoped-runtime.js +230 -0
  18. package/dist/claustrum-scoped.d.ts +39 -0
  19. package/dist/claustrum-scoped.js +193 -0
  20. package/dist/claustrum.d.ts +149 -6
  21. package/dist/claustrum.js +990 -48
  22. package/dist/commands/account.d.ts +40 -12
  23. package/dist/commands/account.js +138 -9
  24. package/dist/constants.d.ts +3 -2
  25. package/dist/constants.js +3 -2
  26. package/dist/custom-headers.d.ts +3 -0
  27. package/dist/custom-headers.js +102 -0
  28. package/dist/fast.js +1 -1
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +7 -0
  31. package/dist/logger.js +3 -1
  32. package/dist/model-remap.d.ts +27 -0
  33. package/dist/model-remap.js +72 -0
  34. package/dist/models.d.ts +19 -0
  35. package/dist/models.js +48 -12
  36. package/dist/oauth-profile.d.ts +2 -1
  37. package/dist/oauth-profile.js +6 -0
  38. package/dist/prime.d.ts +11 -3
  39. package/dist/prime.js +25 -7
  40. package/dist/quota-header-feed.d.ts +13 -0
  41. package/dist/quota-header-feed.js +60 -8
  42. package/dist/quota-manager.d.ts +15 -1
  43. package/dist/quota-manager.js +73 -22
  44. package/dist/relay.d.ts +8 -0
  45. package/dist/relay.js +72 -12
  46. package/dist/sticky-routing.d.ts +2 -0
  47. package/dist/sticky-routing.js +19 -1
  48. package/package.json +3 -2
@@ -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,6 +64,7 @@ export class QuotaManager {
62
64
  // --- Config ---
63
65
  storage;
64
66
  fetchImpl;
67
+ fetchQuotaSnapshot;
65
68
  now;
66
69
  onMainQuotaFetched;
67
70
  onApiError;
@@ -70,6 +73,7 @@ export class QuotaManager {
70
73
  constructor(opts) {
71
74
  this.storage = opts.storage;
72
75
  this.fetchImpl = opts.fetchImpl ?? fetch;
76
+ this.fetchQuotaSnapshot = opts.fetchQuotaSnapshot;
73
77
  this.now = opts.now ?? Date.now;
74
78
  this.onMainQuotaFetched = opts.onMainQuotaFetched;
75
79
  this.onApiError = opts.onApiError;
@@ -103,6 +107,12 @@ export class QuotaManager {
103
107
  this.bindFallbackLineage(accountId, account);
104
108
  return this.fallbacks.get(accountId) ?? null;
105
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
+ }
106
116
  getAllFallbacks() {
107
117
  return this.fallbacks;
108
118
  }
@@ -113,11 +123,16 @@ export class QuotaManager {
113
123
  this.mainAccountId = mainAccountId;
114
124
  this.main = entry;
115
125
  }
126
+ clearMain() {
127
+ this.mainAccountId = undefined;
128
+ this.main = null;
129
+ }
116
130
  /** Bind quota state to the account behind the current main credential. */
117
- setMainQuotaAccountIdentity(accountIdentity, strict = true) {
131
+ setMainQuotaAccountIdentity(accountIdentity, strict = true, force = false) {
118
132
  const previousIdentityKnown = this.mainQuotaIdentityKnown;
119
133
  const previousAccountIdentity = this.mainAccountId;
120
- const changed = !previousIdentityKnown ||
134
+ const changed = force ||
135
+ !previousIdentityKnown ||
121
136
  this.mainAccountId !== accountIdentity ||
122
137
  this.mainQuotaIdentityStrict !== strict;
123
138
  this.mainQuotaIdentityKnown = true;
@@ -126,7 +141,13 @@ export class QuotaManager {
126
141
  return undefined;
127
142
  this.mainGeneration += 1;
128
143
  this.mainAccountId = accountIdentity;
129
- 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 &&
130
151
  this.main?.quota.accountIdentity !== accountIdentity) {
131
152
  this.main = null;
132
153
  }
@@ -195,14 +216,14 @@ export class QuotaManager {
195
216
  async refreshMain(mainAccountIdOrAccessToken, accessToken, expectedIdentityGeneration) {
196
217
  const mainAccountId = mainAccountIdOrAccessToken;
197
218
  const credential = accessToken ?? mainAccountIdOrAccessToken;
198
- if (!credential)
219
+ if (!credential && !this.canFetchWithoutAccessToken())
199
220
  throw new Error('Main OAuth access token is unavailable');
200
221
  return (await this.refreshMainWithMetadata(mainAccountId, credential, expectedIdentityGeneration)).quota;
201
222
  }
202
223
  async refreshMainWithMetadata(mainAccountIdOrAccessToken, accessToken, expectedIdentityGeneration) {
203
224
  const effectiveAccountId = mainAccountIdOrAccessToken;
204
225
  const credential = accessToken ?? mainAccountIdOrAccessToken;
205
- if (!credential)
226
+ if (!credential && !this.canFetchWithoutAccessToken())
206
227
  throw new Error('Main OAuth access token is unavailable');
207
228
  if (expectedIdentityGeneration !== undefined &&
208
229
  (this.mainGeneration !== expectedIdentityGeneration ||
@@ -215,7 +236,9 @@ export class QuotaManager {
215
236
  if (this.mainQuotaIdentityKnown)
216
237
  this.mainGeneration += 1;
217
238
  }
218
- if (this.inflightMain && this.inflightMainAccountId === effectiveAccountId)
239
+ if (this.inflightMain &&
240
+ this.inflightMainAccountId === effectiveAccountId &&
241
+ this.inflightMainAccessToken === credential)
219
242
  return this.inflightMain;
220
243
  // Rate-limit — if API recently 429'd, return stale or throw
221
244
  if (this.isBackedOff()) {
@@ -225,14 +248,19 @@ export class QuotaManager {
225
248
  throw new Error('Quota API rate-limited — try again later');
226
249
  }
227
250
  this.inflightMainAccountId = effectiveAccountId;
251
+ this.inflightMainAccessToken = credential;
228
252
  const generation = this.mainGeneration;
229
- this.inflightMain = this._fetchMain(effectiveAccountId, credential, generation);
253
+ const inflightToken = ++this.inflightMainToken;
254
+ this.inflightMain = this._fetchMain(effectiveAccountId, credential ?? '', generation, inflightToken);
230
255
  return this.inflightMain;
231
256
  }
232
257
  async refreshFallback(accountId, accessToken, account) {
233
258
  return (await this.refreshFallbackWithMetadata(accountId, accessToken, account)).quota;
234
259
  }
235
260
  async refreshFallbackWithMetadata(accountId, accessToken, account) {
261
+ if (!accessToken && !this.canFetchWithoutAccessToken()) {
262
+ throw new Error('Fallback OAuth access token is unavailable');
263
+ }
236
264
  this.bindFallbackLineage(accountId, account);
237
265
  const inflightKey = QuotaManager.fallbackInflightKey(accountId);
238
266
  const inflight = this.inflightFallbacks.get(inflightKey);
@@ -264,12 +292,13 @@ export class QuotaManager {
264
292
  for (const account of accounts) {
265
293
  if (account.enabled === false)
266
294
  continue;
267
- const accessToken = resolveAccessToken?.(account) ??
268
- (account.access &&
295
+ const accessToken = resolveAccessToken !== undefined
296
+ ? resolveAccessToken(account)
297
+ : account.access &&
269
298
  account.expires !== undefined &&
270
299
  account.expires > now
271
300
  ? account.access
272
- : undefined);
301
+ : undefined;
273
302
  if (!accessToken)
274
303
  continue;
275
304
  this.bindFallbackLineage(account.id, account);
@@ -575,6 +604,12 @@ export class QuotaManager {
575
604
  this.fallbackAuthLineages.set(accountId, observedLineage);
576
605
  return true;
577
606
  }
607
+ isClosed = false;
608
+ close() {
609
+ this.isClosed = true;
610
+ this.inflightFallbacks.clear();
611
+ this.inflightMain = null;
612
+ }
578
613
  /**
579
614
  * Serialize API calls through a shared gate so only one
580
615
  * quota API request runs at a time, with a minimum gap
@@ -583,6 +618,8 @@ export class QuotaManager {
583
618
  */
584
619
  _enqueueApiFetch(fn) {
585
620
  const gatedFn = async () => {
621
+ if (this.isClosed)
622
+ throw new Error('QuotaManager is closed');
586
623
  // Wait until minimum gap since last API call
587
624
  const elapsed = this.now() - this.lastApiCallAt;
588
625
  if (elapsed < QuotaManager.API_CALL_GAP_MS) {
@@ -592,6 +629,8 @@ export class QuotaManager {
592
629
  id.unref();
593
630
  });
594
631
  }
632
+ if (this.isClosed)
633
+ throw new Error('QuotaManager is closed');
595
634
  this.lastApiCallAt = this.now();
596
635
  return fn();
597
636
  };
@@ -599,7 +638,7 @@ export class QuotaManager {
599
638
  this.apiGate = queued.catch(() => { });
600
639
  return queued;
601
640
  }
602
- async _fetchMain(mainAccountId, accessToken, generation) {
641
+ async _fetchMain(mainAccountId, accessToken, generation, inflightToken) {
603
642
  return this._enqueueApiFetch(async () => {
604
643
  try {
605
644
  // Re-check backoff inside gate — may have been set by
@@ -633,11 +672,17 @@ export class QuotaManager {
633
672
  }
634
673
  try {
635
674
  const fetchStartedAt = this.now();
636
- const quota = await fetchOAuthQuotaSnapshot({
637
- accessToken,
638
- fetchImpl: this.fetchImpl,
639
- now: this.now,
640
- });
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
+ });
641
686
  const now = this.now();
642
687
  if (this.mainQuotaIdentityKnown &&
643
688
  this.mainQuotaIdentityStrict &&
@@ -686,7 +731,7 @@ export class QuotaManager {
686
731
  }
687
732
  }
688
733
  finally {
689
- if (this.inflightMainAccountId === mainAccountId) {
734
+ if (this.inflightMainToken === inflightToken) {
690
735
  this.inflightMain = null;
691
736
  this.inflightMainAccountId = undefined;
692
737
  }
@@ -719,11 +764,17 @@ export class QuotaManager {
719
764
  }
720
765
  try {
721
766
  const fetchStartedAt = this.now();
722
- const quota = await fetchOAuthQuotaSnapshot({
723
- accessToken,
724
- fetchImpl: this.fetchImpl,
725
- now: this.now,
726
- });
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
+ });
727
778
  const now = this.now();
728
779
  if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation) {
729
780
  return {
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,8 @@
28
28
  "prepublishOnly": "bun run build"
29
29
  },
30
30
  "dependencies": {
31
- "@cortexkit/subc-client": "^0.8.1",
31
+ "@cortexkit/claustrum-client": "0.3.0",
32
+ "@cortexkit/subc-client": "^0.11.1",
32
33
  "xxhash-wasm": "^1.1.0"
33
34
  }
34
35
  }