@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.
- package/README.md +1 -1
- package/dist/accounts.d.ts +100 -3
- package/dist/accounts.js +1099 -169
- package/dist/auth.js +6 -10
- package/dist/cachekeep.d.ts +18 -1
- package/dist/cachekeep.js +108 -82
- package/dist/cch.d.ts +23 -9
- package/dist/cch.js +104 -21
- package/dist/claude-code.d.ts +8 -3
- package/dist/claude-code.js +24 -7
- package/dist/claustrum-enrollment-registry.d.ts +17 -0
- package/dist/claustrum-enrollment-registry.js +194 -0
- package/dist/claustrum-enrollment.d.ts +70 -0
- package/dist/claustrum-enrollment.js +485 -0
- package/dist/claustrum-scoped-roster.d.ts +23 -0
- package/dist/claustrum-scoped-roster.js +194 -0
- package/dist/claustrum-scoped-runtime.d.ts +28 -0
- package/dist/claustrum-scoped-runtime.js +230 -0
- package/dist/claustrum-scoped.d.ts +39 -0
- package/dist/claustrum-scoped.js +193 -0
- package/dist/claustrum.d.ts +252 -0
- package/dist/claustrum.js +1471 -0
- package/dist/commands/account.d.ts +43 -11
- package/dist/commands/account.js +147 -4
- package/dist/constants.d.ts +5 -5
- package/dist/constants.js +5 -5
- package/dist/custom-headers.d.ts +3 -0
- package/dist/custom-headers.js +102 -0
- package/dist/dump.js +2 -1
- package/dist/fast.js +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json.d.ts +1 -0
- package/dist/json.js +10 -0
- package/dist/logger.js +3 -1
- package/dist/mid-conversation-output-config.d.ts +13 -0
- package/dist/mid-conversation-output-config.js +89 -0
- package/dist/model-remap.d.ts +27 -0
- package/dist/model-remap.js +72 -0
- package/dist/models.d.ts +46 -1
- package/dist/models.js +87 -8
- package/dist/network-errors.d.ts +1 -0
- package/dist/network-errors.js +27 -0
- package/dist/oauth-profile.d.ts +2 -1
- package/dist/oauth-profile.js +6 -0
- package/dist/prime.d.ts +11 -3
- package/dist/prime.js +25 -7
- package/dist/quota-header-feed.d.ts +32 -7
- package/dist/quota-header-feed.js +93 -10
- package/dist/quota-headers.js +59 -6
- package/dist/quota-manager.d.ts +20 -2
- package/dist/quota-manager.js +101 -24
- package/dist/relay.d.ts +8 -0
- package/dist/relay.js +72 -12
- package/dist/sticky-routing.d.ts +2 -0
- package/dist/sticky-routing.js +19 -1
- package/dist/thinking-binding.d.ts +7 -0
- package/dist/thinking-binding.js +54 -0
- package/package.json +4 -1
package/dist/quota-manager.js
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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.
|
|
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
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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;
|
package/dist/sticky-routing.d.ts
CHANGED
|
@@ -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;
|
package/dist/sticky-routing.js
CHANGED
|
@@ -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
|
+
}
|