@cortexkit/pi-anthropic-auth 1.22.0 → 2.0.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/dist/stream.js CHANGED
@@ -1,16 +1,15 @@
1
- import { applyClaudeCodeHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, createStickyNoRouteResponse, decideStickyQuotaFailure, dumpDirectRequest, FAST_MODE_BETA, FallbackAccountManager, getCache1hPersistentMode, getDefaultCacheKeepRegistryDirectory, getFallbackReauthLabels, getOrCreateMainAccountId, getRelayConfig, getRoutingMode, getStickyRoutingStatePath, getThinkingPrefixMismatchBehavior, hasThinkingBindingControls, isApiKeyAccount, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, isKillswitchEnabled, isOAuthAccount, isPermanentRefreshError, isValidApiBaseURL, killswitchPassesPolicy, loadAccounts, MID_CONVERSATION_OUTPUT_CONFIG_BETA, mergeAnthropicBetas, QuotaManager, quotaSnapshotHasStandardWindows, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, refreshBackoffActive, resolveClaudeCodeIdentity, STICKY_ROUTING_MAIN_ACCOUNT_ID, StickySessionRouter, sendViaRelay, setDumpEnabled, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, stickyRetryAfterWithJitter, stickyRouteFamilyForModel, THINKING_BINDING_CONTROLS_BETA, tokenFingerprint, usesMidConversationOutputConfig, } from '@cortexkit/anthropic-auth-core';
1
+ import { applyClaudeCodeHeaders, applyCustomHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, createProviderErrorResponseScope, createSseFramer, createStickyNoRouteResponse, decideStickyQuotaFailure, decodeSseFrame, dumpDirectRequest, FAST_MODE_BETA, getCache1hPersistentMode, getClaudeCodeIdentityForVerifiedAccount, getDefaultCacheKeepRegistryDirectory, getFallbackReauthLabels, getQuotaCheckIntervalMs, getRoutingMode, getScopedQuotaWindowForModel, getThinkingPrefixMismatchBehavior, hasThinkingBindingControls, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, isKillswitchEnabled, isNativeTransientAccountError, isOAuthAccount, isPermanentRefreshError, isValidApiBaseURL, killswitchPassesPolicy, logger, MID_CONVERSATION_OUTPUT_CONFIG_BETA, mergeAnthropicBetas, NativeVaultNoAccountError, parseSseFrame, QuotaManager, quotaBackoffActive, quotaSnapshotHasStandardWindows, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, STICKY_ROUTING_MAIN_ACCOUNT_ID, StickySessionRouter, sendViaRelay, setDumpEnabled, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, stickyRetryAfterWithJitter, stickyRouteFamilyForModel, THINKING_BINDING_CONTROLS_BETA, usesMidConversationOutputConfig, } from '@cortexkit/anthropic-auth-core';
2
2
  import { calculateCost, createAssistantMessageEventStream, } from '@earendil-works/pi-ai';
3
3
  import { buildAnthropicRequest, fromClaudeCodeToolName } from './convert.js';
4
+ import { getPiNativeRuntime, piAttemptAccessToken, } from './native.js';
4
5
  import { getPiAccountStoragePath } from './paths.js';
5
- function errorText(error) {
6
- return error instanceof Error ? error.message : String(error);
7
- }
6
+ import { buildPiOAuthMessagesUrl, fetchPiOAuth, requirePiOAuthOrigin, } from './transport.js';
7
+ /** Dump label for a physical send that failed before any response arrived. */
8
+ const PHYSICAL_SEND_FAILED = 'physical send failed before a response';
8
9
  let cacheKeepRegistry;
9
10
  let cacheKeepRegistryDirectory;
10
11
  const stickyRouters = new Map();
11
12
  const quotaManagers = new Map();
12
- const fallbackManagers = new Map();
13
- const mainAccountIdInitializations = new Map();
14
13
  const PI_SERVICE_CACHE_LIMIT = 16;
15
14
  function setBoundedService(map, key, value) {
16
15
  map.delete(key);
@@ -22,48 +21,92 @@ function setBoundedService(map, key, value) {
22
21
  map.delete(oldest);
23
22
  }
24
23
  }
25
- function ensurePiMainAccountId(storagePath) {
26
- let initialization = mainAccountIdInitializations.get(storagePath);
27
- if (!initialization) {
28
- initialization = getOrCreateMainAccountId(storagePath).catch((error) => {
29
- if (mainAccountIdInitializations.get(storagePath) === initialization) {
30
- mainAccountIdInitializations.delete(storagePath);
31
- }
32
- throw error;
24
+ function getPiRoutingServices(storagePath, snapshot) {
25
+ const storage = snapshot.policyStorage;
26
+ const native = getPiNativeRuntime(storagePath);
27
+ const identityKey = JSON.stringify(snapshot.accounts.map((account) => [
28
+ account.id,
29
+ account.accountIdentity,
30
+ account.source,
31
+ account.credentialId,
32
+ account.binding?.rowId,
33
+ account.binding?.credentialEpoch,
34
+ ]));
35
+ const existing = quotaManagers.get(storagePath);
36
+ let quotaManager = existing?.identityKey === identityKey ? existing.manager : undefined;
37
+ if (!quotaManager) {
38
+ quotaManager = new QuotaManager({
39
+ storage,
40
+ fetchQuotaSnapshot: async (request, dispatch) => {
41
+ const id = request.kind === 'main' ? 'main' : request.accountId;
42
+ if (!id)
43
+ throw new Error('Native quota request has no account identity');
44
+ return (await native.service()).fetchQuota(id, fetchPiOAuth, undefined, dispatch);
45
+ },
33
46
  });
34
- setBoundedService(mainAccountIdInitializations, storagePath, initialization);
35
- }
36
- return initialization;
37
- }
38
- function getPiRoutingServices(storagePath, storage) {
39
- let quotaManager = quotaManagers.get(storagePath);
40
- let fallbackManager = fallbackManagers.get(storagePath);
41
- if (!quotaManager || !fallbackManager) {
42
- quotaManager = new QuotaManager({ storage });
43
- fallbackManager = new FallbackAccountManager({
44
- configPath: storagePath,
45
- quotaManager,
47
+ setBoundedService(quotaManagers, storagePath, {
48
+ identityKey,
49
+ manager: quotaManager,
46
50
  });
47
- setBoundedService(quotaManagers, storagePath, quotaManager);
48
- setBoundedService(fallbackManagers, storagePath, fallbackManager);
49
51
  }
50
52
  else {
51
53
  quotaManager.updateStorage(storage);
52
54
  }
53
- return { quotaManager, fallbackManager };
55
+ const quotas = quotaManager;
56
+ const getUsableFallbackAccounts = async (current, options = {}) => {
57
+ const snapshot = await native.view();
58
+ const accounts = new Map(snapshot.accounts.map((account) => [account.id, account]));
59
+ const usable = [];
60
+ for (const account of current.accounts) {
61
+ const view = accounts.get(account.id);
62
+ if (!isOAuthAccount(account) || !view?.enabled || view.type !== 'oauth')
63
+ continue;
64
+ if (view.source === 'vault' && view.state !== 'active')
65
+ continue;
66
+ if (isPermanentRefreshError(view.lastRefreshError) ||
67
+ (view.source === 'local' &&
68
+ (view.lastRefreshError?.nextRetryAt ?? 0) > Date.now()))
69
+ continue;
70
+ const needsProbe = view.source !== 'vault' ||
71
+ Boolean(current.quota?.minimumRemaining) ||
72
+ isKillswitchEnabled(current);
73
+ if (needsProbe &&
74
+ quotas.isFallbackStale(account.id, undefined, options.modelId, account) &&
75
+ !quotaBackoffActive(account.lastQuotaRefreshError, Date.now())) {
76
+ try {
77
+ await quotas.refreshFallback(account.id, '', account);
78
+ }
79
+ catch (error) {
80
+ // A transient account error means another writer is still saving
81
+ // this fallback. Rethrow it: swallowing it would leave the fallback
82
+ // without a quota reading, which can exclude it below and send the
83
+ // request through a different account. Other probe errors are
84
+ // ignored.
85
+ if (isNativeTransientAccountError(error))
86
+ throw error;
87
+ }
88
+ }
89
+ const quota = quotas.getFallback(account.id, account)?.quota ?? account.quota;
90
+ if ((!needsProbe || quotaSnapshotPassesPolicy(quota, current)) &&
91
+ quotaSnapshotPassesModelScope(quota, options.modelId))
92
+ usable.push({ ...account, quota });
93
+ }
94
+ return usable;
95
+ };
96
+ return { quotaManager, getUsableFallbackAccounts };
54
97
  }
55
- function getPiStickyRouter(storagePath) {
56
- const path = process.env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE ||
57
- getStickyRoutingStatePath(storagePath);
98
+ async function getPiStickyRouter(storagePath) {
99
+ const { path, vaultSeed } = await getPiNativeRuntime(storagePath).routingStorage();
58
100
  let router = stickyRouters.get(path);
59
101
  if (!router) {
60
- router = new StickySessionRouter({ path });
102
+ router = new StickySessionRouter({ path, vaultSeed });
61
103
  setBoundedService(stickyRouters, path, router);
62
104
  }
105
+ router.setVaultSeed(vaultSeed);
63
106
  return router;
64
107
  }
65
108
  export async function clearPiStickyRoutingSession(storagePath, sessionId) {
66
- await getPiStickyRouter(storagePath).clear(sessionId);
109
+ await (await getPiStickyRouter(storagePath)).clear(sessionId);
67
110
  }
68
111
  function getPiCacheKeepRegistry() {
69
112
  const directory = process.env.PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR ||
@@ -74,45 +117,81 @@ function getPiCacheKeepRegistry() {
74
117
  }
75
118
  return cacheKeepRegistry;
76
119
  }
120
+ const nativePrewarmAttempts = new Map();
77
121
  const cacheKeepManager = new CacheKeepManager({
78
- loadStorage: () => loadAccounts(getPiAccountStoragePath()),
122
+ fetchImpl: fetchPiOAuth,
123
+ loadStorage: async () => (await getPiNativeRuntime().view()).policyStorage,
79
124
  onTrackedSessionsChanged: (sessions) => getPiCacheKeepRegistry().publish(sessions),
80
- prepareHeaders: async (headers, target) => {
81
- const authorization = headers.get('authorization') ?? '';
82
- const match = /^Bearer\s+(.+)$/i.exec(authorization);
83
- const accessToken = match?.[1];
84
- if (!accessToken)
85
- return headers;
86
- try {
87
- const body = JSON.parse(target.bodyText);
88
- const storagePath = getPiAccountStoragePath();
89
- await ensurePiMainAccountId(storagePath);
90
- const storage = await loadAccounts(storagePath);
91
- const identity = await resolveClaudeCodeIdentity(accessToken, typeof body.model === 'string' ? body.model : undefined, target.oauthAccountId ?? storage?.mainAccountId);
92
- headers.delete('anthropic-beta');
93
- applyClaudeCodeHeaders(headers, accessToken, {
94
- body,
95
- identity,
96
- extraBetas: [
97
- CACHE_KEEP_EXTENDED_TTL_BETA,
98
- ...(hasThinkingBindingControls(body)
99
- ? [THINKING_BINDING_CONTROLS_BETA]
100
- : []),
101
- ],
102
- });
103
- if (body.speed === 'fast') {
104
- headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
105
- }
106
- }
107
- catch {
108
- applyClaudeCodeHeaders(headers, accessToken);
109
- }
125
+ prepareHeaders: async (headers, target, attempt) => {
126
+ requirePiOAuthOrigin(target.url);
127
+ const runtime = getPiNativeRuntime(target.accountStoragePath ?? getPiAccountStoragePath());
128
+ const id = target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID;
129
+ const current = await runtime.view();
130
+ const body = JSON.parse(target.bodyText);
131
+ const served = await runtime.authorize(id, attempt.signal, typeof body.model === 'string' ? body.model : undefined);
132
+ if (!target.oauthAccountIdentity ||
133
+ served.accountIdentity !== target.oauthAccountIdentity)
134
+ throw new Error('CacheKeep account identity changed');
135
+ const identity = getClaudeCodeIdentityForVerifiedAccount(id === STICKY_ROUTING_MAIN_ACCOUNT_ID
136
+ ? (current.policyStorage.mainAccountId ?? id)
137
+ : id, served.accountIdentity);
138
+ headers.delete('anthropic-beta');
139
+ applyClaudeCodeHeaders(headers, piAttemptAccessToken(served), {
140
+ body,
141
+ identity,
142
+ extraBetas: [
143
+ CACHE_KEEP_EXTENDED_TTL_BETA,
144
+ ...(hasThinkingBindingControls(body)
145
+ ? [THINKING_BINDING_CONTROLS_BETA]
146
+ : []),
147
+ ],
148
+ });
149
+ if (body.speed === 'fast')
150
+ headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
151
+ nativePrewarmAttempts.set(attempt.id, { runtime, served, reported: false });
110
152
  return headers;
111
153
  },
154
+ retryOnUnauthorized: async ({ target, headers, attempt }) => {
155
+ const entry = nativePrewarmAttempts.get(attempt.id);
156
+ if (!entry)
157
+ return undefined;
158
+ await entry.runtime.report(entry.served, 401, 'direct').catch(() => {
159
+ logger.warn('native-auth', 'Pi CacheKeep auth-failure report unavailable');
160
+ });
161
+ entry.reported = true;
162
+ const retryBody = JSON.parse(target.bodyText);
163
+ const rotated = await entry.runtime
164
+ .retry(target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID, entry.served, attempt.signal, typeof retryBody.model === 'string' ? retryBody.model : undefined)
165
+ .catch(() => undefined);
166
+ if (!rotated)
167
+ return undefined;
168
+ const rotatedHeaders = new Headers(headers);
169
+ rotatedHeaders.set('authorization', `Bearer ${piAttemptAccessToken(rotated)}`);
170
+ nativePrewarmAttempts.set(attempt.id, {
171
+ runtime: entry.runtime,
172
+ served: rotated,
173
+ reported: false,
174
+ });
175
+ return rotatedHeaders;
176
+ },
177
+ onResponse: async ({ attempt, status }) => {
178
+ const entry = nativePrewarmAttempts.get(attempt.id);
179
+ if (entry && status === 401 && !entry.reported)
180
+ await entry.runtime.report(entry.served, status, 'direct').catch(() => {
181
+ logger.warn('native-auth', 'Pi CacheKeep auth-failure report unavailable');
182
+ });
183
+ },
184
+ onComplete: ({ attempt }) => {
185
+ nativePrewarmAttempts.delete(attempt.id);
186
+ },
112
187
  });
113
188
  export async function getPiTrackedCacheKeepSessions() {
114
189
  return getPiCacheKeepRegistry().list(cacheKeepManager.trackedSessions());
115
190
  }
191
+ /** Call prewarmNow directly to exercise its provider request without clock or timer mocks. */
192
+ export function __prewarmPiCacheKeepForTest(input) {
193
+ return cacheKeepManager.prewarmNow(input);
194
+ }
116
195
  function mapStopReason(reason) {
117
196
  switch (reason) {
118
197
  case 'end_turn':
@@ -164,7 +243,16 @@ function updateUsage(model, output, usage) {
164
243
  }
165
244
  export function buildExplicitBaseMessagesUrl(baseURL) {
166
245
  const url = new URL(baseURL);
167
- url.pathname = `${url.pathname.replace(/\/$/, '')}/v1/messages`;
246
+ const basePath = url.pathname.replace(/\/$/, '');
247
+ if (/\/v\d[^/]*\/messages$/.test(basePath)) {
248
+ url.pathname = basePath;
249
+ }
250
+ else if (/\/v\d[^/]*$/.test(basePath)) {
251
+ url.pathname = `${basePath}/messages`;
252
+ }
253
+ else {
254
+ url.pathname = `${basePath}/v1/messages`;
255
+ }
168
256
  url.searchParams.set('beta', 'true');
169
257
  return url;
170
258
  }
@@ -183,57 +271,66 @@ export function configureApiRouteHeaders(account, fastMode) {
183
271
  if (fastMode) {
184
272
  headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
185
273
  }
274
+ applyCustomHeaders(headers);
186
275
  return headers;
187
276
  }
188
277
  export async function* parseSse(response) {
189
278
  if (!response.body)
190
279
  return;
191
280
  const reader = response.body.getReader();
192
- const decoder = new TextDecoder();
193
- let buffer = '';
281
+ // Core's byte framer ends frames at blank lines with CRLF, LF or CR line
282
+ // endings and keeps a multibyte character split across reads in one frame,
283
+ // so each frame is decoded once, whole. A frame without its closing blank
284
+ // line at end of stream is incomplete and dispatches nothing, as in
285
+ // EventSource.
286
+ const framer = createSseFramer();
287
+ // Only the stream's first frame may drop a leading byte-order mark.
288
+ let streamStart = true;
194
289
  try {
195
290
  while (true) {
196
291
  const { done, value } = await reader.read();
197
292
  if (done)
198
293
  break;
199
- buffer += decoder.decode(value, { stream: true });
200
- let boundary = buffer.indexOf('\n\n');
201
- while (boundary !== -1) {
202
- const frame = buffer.slice(0, boundary);
203
- buffer = buffer.slice(boundary + 2);
204
- boundary = buffer.indexOf('\n\n');
205
- for (const line of frame.split('\n')) {
206
- if (!line.startsWith('data:'))
207
- continue;
208
- const data = line.slice(5).trim();
209
- if (!data || data === '[DONE]')
210
- continue;
211
- yield JSON.parse(data);
212
- }
294
+ for (const frame of framer.push(value)) {
295
+ const data = parseSseFrame(decodeSseFrame(frame, { streamStart })).data;
296
+ streamStart = false;
297
+ if (!data || data === '[DONE]')
298
+ continue;
299
+ yield JSON.parse(data);
213
300
  }
214
301
  }
215
302
  }
216
303
  finally {
217
- // Do not cancel the reader on early abandon. `firstStreamingError()` peeks
218
- // the first SSE event from a `response.clone()` and then abandons this
219
- // generator; cancelling the cloned (tee'd) reader tears down the shared
220
- // underlying body, so the real `parseSse(response)` that streams the reply
221
- // reads zero events and the assistant message comes back empty. Releasing
222
- // the lock is enough — the abandoned clone branch is garbage-collected.
304
+ // Only the lock is released when the consumer stops early: cancelling the
305
+ // body on abandonment is the consumer's decision, not the parser's.
223
306
  reader.releaseLock();
224
307
  }
225
308
  }
226
309
  async function sendAnthropicRequest(options) {
227
- await ensurePiMainAccountId(options.storagePath);
228
- const storage = await loadAccounts(options.storagePath);
310
+ const native = getPiNativeRuntime(options.storagePath);
311
+ // Capture the serving service while authorization is open. Shutdown rejects
312
+ // new serving work immediately but drains writes for already-served attempts.
313
+ const servingRuntime = await native.service();
314
+ const snapshot = await native.view();
315
+ const storage = snapshot.policyStorage;
229
316
  setDumpEnabled(isDumpPersistentlyEnabled(storage));
230
- const accountIdentity = options.oauthAccountId === STICKY_ROUTING_MAIN_ACCOUNT_ID
231
- ? storage?.mainAccountId
232
- : options.oauthAccountId;
233
- const identity = options.accessToken
234
- ? await resolveClaudeCodeIdentity(options.accessToken, options.model.id, accountIdentity)
317
+ const routeId = options.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID;
318
+ const selected = snapshot.accounts.find((account) => account.id === routeId);
319
+ let verifiedUuid = options.apiAccount ? undefined : selected?.accountIdentity;
320
+ if (!options.apiAccount && !verifiedUuid) {
321
+ if (selected?.source === 'vault')
322
+ throw new Error('Pi vault route identity is unasserted');
323
+ // Validation can learn the UUID of an account that has no verified identity.
324
+ // This lookup returns only that UUID; the model send authorizes the credential again.
325
+ verifiedUuid = (await native.authorize(routeId, options.streamOptions?.signal, options.model.id)).accountIdentity;
326
+ }
327
+ let nextAttempt;
328
+ const identity = verifiedUuid
329
+ ? getClaudeCodeIdentityForVerifiedAccount(routeId === STICKY_ROUTING_MAIN_ACCOUNT_ID
330
+ ? (storage.mainAccountId ?? routeId)
331
+ : routeId, verifiedUuid)
235
332
  : undefined;
236
- const { body, bodyText } = await buildAnthropicRequest(options.model.id, options.context, options.streamOptions, {
333
+ const { body, bodyText, hostTools } = await buildAnthropicRequest(options.model.id, options.context, options.streamOptions, {
237
334
  enabled: isCache1hPersistentlyEnabled(storage),
238
335
  mode: getCache1hPersistentMode(storage),
239
336
  }, isFastModePersistentlyEnabled(storage), identity, {
@@ -242,10 +339,11 @@ async function sendAnthropicRequest(options) {
242
339
  ? 'account-default'
243
340
  : getThinkingPrefixMismatchBehavior(storage),
244
341
  });
342
+ options.onResolvedTools?.(hostTools);
245
343
  const fastMode = body.speed === 'fast';
246
344
  const headers = options.apiAccount
247
- ? configureApiRouteHeaders(options.apiAccount, fastMode)
248
- : applyClaudeCodeHeaders(new Headers(), options.accessToken ?? '', {
345
+ ? new Headers()
346
+ : applyClaudeCodeHeaders(new Headers(), '', {
249
347
  body,
250
348
  identity,
251
349
  extraBetas: [
@@ -257,31 +355,122 @@ async function sendAnthropicRequest(options) {
257
355
  : []),
258
356
  ],
259
357
  });
260
- if (!options.apiAccount && fastMode) {
358
+ if (!options.apiAccount && fastMode)
261
359
  headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
262
- }
263
360
  const relayAffinity = options.streamOptions?.sessionId ?? null;
264
- const input = options.apiAccount
265
- ? buildExplicitBaseMessagesUrl(options.apiAccount.baseURL)
266
- : new URL('/v1/messages?beta=true', options.model.baseUrl);
361
+ let input = options.apiAccount
362
+ ? new URL('https://api.anthropic.com/v1/messages')
363
+ : buildPiOAuthMessagesUrl(options.model.baseUrl);
267
364
  const init = {
268
365
  method: 'POST',
269
366
  headers,
270
367
  body: bodyText,
271
368
  signal: options.streamOptions?.signal,
369
+ ...(!options.apiAccount && { redirect: 'error' }),
370
+ };
371
+ if (!options.apiAccount)
372
+ await cacheKeepManager.track({
373
+ sessionId: relayAffinity,
374
+ url: input.toString(),
375
+ headers,
376
+ bodyText,
377
+ storage,
378
+ cacheMode: isCacheKeepHybridActive(storage) ? 'hybrid' : 'disabled',
379
+ oauthAccountId: routeId,
380
+ oauthAccountIdentity: verifiedUuid,
381
+ accountStoragePath: options.storagePath,
382
+ });
383
+ const reported = new Set();
384
+ let retried401 = false;
385
+ let lastTransport;
386
+ const used = new Set();
387
+ const report = async (attempt, status, source) => {
388
+ if (status !== 401 || reported.has(attempt))
389
+ return;
390
+ reported.add(attempt);
391
+ await native.report(attempt, status, source).catch(() => {
392
+ logger.warn('native-auth', 'Pi auth-failure report unavailable');
393
+ });
394
+ };
395
+ const markServed = (attempt) => {
396
+ if (used.has(attempt))
397
+ return;
398
+ used.add(attempt);
399
+ native.usagePublications.publish(() => native.markUsed(attempt, servingRuntime));
400
+ };
401
+ const takeAttempt = async () => {
402
+ const attempt = nextAttempt ??
403
+ (await native.authorize(routeId, init.signal ?? undefined, options.model.id));
404
+ nextAttempt = undefined;
405
+ if (attempt.accountIdentity !== verifiedUuid)
406
+ throw new Error('Pi native OAuth route identity changed');
407
+ return attempt;
272
408
  };
273
- await cacheKeepManager.track({
274
- sessionId: relayAffinity,
275
- url: input.toString(),
276
- headers,
277
- bodyText,
278
- storage,
279
- cacheMode: isCacheKeepHybridActive(storage) ? 'hybrid' : 'disabled',
280
- oauthAccountId: options.oauthAccountId,
281
- });
282
409
  const directFetch = async () => {
410
+ let attempt;
411
+ let apiSubject;
412
+ // The secret this physical send goes out with; a provider error may repeat it.
413
+ let secret = '';
414
+ if (options.apiAccount) {
415
+ const credential = await (await native.service()).authorizeApi(options.apiAccount.id, init.signal ?? undefined);
416
+ apiSubject = credential.subject;
417
+ secret = credential.apiKey ?? '';
418
+ input = buildExplicitBaseMessagesUrl(credential.baseURL);
419
+ const authorized = configureApiRouteHeaders({
420
+ ...credential,
421
+ id: options.apiAccount.id,
422
+ }, fastMode);
423
+ init.headers = authorized;
424
+ }
425
+ else {
426
+ attempt = await takeAttempt();
427
+ secret = piAttemptAccessToken(attempt);
428
+ headers.set('authorization', `Bearer ${secret}`);
429
+ }
430
+ lastTransport = 'direct';
431
+ // The response scope of the latest physical send. Each send gets its own
432
+ // before the request leaves, so a rejected 401 is already scrubbed when
433
+ // it is reported, dumped or cancelled.
434
+ const physical = {};
435
+ const send = async () => {
436
+ const scope = createProviderErrorResponseScope([secret], 'event-stream');
437
+ physical.scope = scope;
438
+ return scope.redactResponse(await fetch(input, init));
439
+ };
440
+ let response;
283
441
  try {
284
- const response = await fetch(input, init);
442
+ response = await send();
443
+ if (attempt && response.status === 401) {
444
+ // Report the rejected credential version before retrying, even if its replacement later succeeds.
445
+ await report(attempt, response.status, 'direct');
446
+ const rotated = !retried401 && !init.signal?.aborted
447
+ ? await native
448
+ .retry(routeId, attempt, init.signal ?? undefined, options.model.id)
449
+ .catch(() => undefined)
450
+ : undefined;
451
+ if (rotated) {
452
+ retried401 = true;
453
+ await dumpDirectRequest({
454
+ affinity: relayAffinity,
455
+ route: options.route ?? 'oauth',
456
+ status: response.status,
457
+ bodyText,
458
+ url: input.toString(),
459
+ method: init.method,
460
+ headers,
461
+ });
462
+ // Retire the rejected send's credentials before cancelling its
463
+ // body, since that cancellation may never settle.
464
+ physical.scope?.dispose();
465
+ await response.body?.cancel().catch(() => { });
466
+ attempt = rotated;
467
+ secret = piAttemptAccessToken(rotated);
468
+ headers.set('authorization', `Bearer ${secret}`);
469
+ response = await send();
470
+ }
471
+ }
472
+ if (attempt)
473
+ await report(attempt, response.status, 'direct');
285
474
  await dumpDirectRequest({
286
475
  affinity: relayAffinity,
287
476
  route: options.route ??
@@ -290,35 +479,95 @@ async function sendAnthropicRequest(options) {
290
479
  bodyText,
291
480
  url: input.toString(),
292
481
  method: init.method,
293
- headers,
482
+ headers: init.headers,
294
483
  });
295
- return response;
296
484
  }
297
- catch (error) {
485
+ catch (caught) {
486
+ // No response leaves this send, so its credentials retire here.
487
+ physical.scope?.dispose();
488
+ const scope = createProviderErrorResponseScope([secret], 'event-stream');
489
+ const error = scope.redactError(caught);
490
+ scope.dispose();
298
491
  await dumpDirectRequest({
299
492
  affinity: relayAffinity,
300
493
  route: options.route ??
301
494
  (options.apiAccount ? `api:${options.apiAccount.id}` : 'oauth'),
302
- error: errorText(error),
495
+ // A fixed label: the error text can repeat provider output or a credential.
496
+ error: PHYSICAL_SEND_FAILED,
303
497
  bodyText,
304
498
  url: input.toString(),
305
499
  method: init.method,
306
- headers,
500
+ headers: init.headers,
307
501
  });
308
502
  throw error;
309
503
  }
504
+ if (apiSubject && response.ok) {
505
+ const subject = apiSubject;
506
+ native.usagePublications.publish(() => servingRuntime.publishApi(subject, { lastUsed: Date.now() }));
507
+ }
508
+ if (attempt && response.ok)
509
+ markServed(attempt);
510
+ return response;
310
511
  };
311
512
  if (options.apiAccount)
312
513
  return directFetch();
313
- return sendViaRelay({
314
- config: getRelayConfig(storage),
514
+ let relay401Attempt;
515
+ let relayAttempt;
516
+ const relayReports = [];
517
+ // The relay scrubs each physical relay attempt's responses and errors with
518
+ // that attempt's own credentials and the relay token; a direct fallback is
519
+ // scrubbed by directFetch.
520
+ const sendRelayAttempt = async () => sendViaRelay({
521
+ config: await (await native.service()).getRelayConfig(),
315
522
  input,
316
523
  init,
317
524
  headers,
318
525
  body: bodyText,
319
526
  fallback: directFetch,
320
527
  affinity: relayAffinity,
528
+ authorizeAttempt: async () => {
529
+ const attempt = await takeAttempt();
530
+ relayAttempt = attempt;
531
+ lastTransport = 'relay';
532
+ const authorizedHeaders = new Headers(headers);
533
+ const access = piAttemptAccessToken(attempt);
534
+ authorizedHeaders.set('authorization', `Bearer ${access}`);
535
+ return {
536
+ headers: authorizedHeaders,
537
+ errorSecrets: [access],
538
+ onUpstreamStatus: (status) => {
539
+ if (status === 401) {
540
+ relay401Attempt = attempt;
541
+ // The relay can report an upstream 401 after returning the streaming response.
542
+ relayReports.push(report(attempt, status, 'relay_status_field'));
543
+ }
544
+ },
545
+ };
546
+ },
321
547
  });
548
+ let response = await sendRelayAttempt();
549
+ await Promise.all(relayReports);
550
+ if (relay401Attempt &&
551
+ response.status === 401 &&
552
+ !retried401 &&
553
+ !init.signal?.aborted) {
554
+ const rotated = await native
555
+ .retry(routeId, relay401Attempt, init.signal ?? undefined, options.model.id)
556
+ .catch(() => undefined);
557
+ if (rotated) {
558
+ retried401 = true;
559
+ await response.body?.cancel().catch(() => { });
560
+ // Use the replacement credential only for the retried relay request.
561
+ // A direct request after relay failure obtains its own authorization.
562
+ nextAttempt = rotated;
563
+ relay401Attempt = undefined;
564
+ response = await sendRelayAttempt();
565
+ await Promise.all(relayReports);
566
+ }
567
+ }
568
+ if (response.ok && lastTransport === 'relay' && relayAttempt)
569
+ markServed(relayAttempt);
570
+ return response;
322
571
  }
323
572
  function quotaSnapshotIsExhausted(quota) {
324
573
  return ['five_hour', 'seven_day'].some((key) => (quota?.[key]?.remainingPercent ?? 1) <= 0);
@@ -327,50 +576,192 @@ export function primaryResponseAllowsApiFallback(preflight) {
327
576
  return (preflight === 'rate_limit_error' ||
328
577
  (preflight instanceof Response && preflight.status === 429));
329
578
  }
330
- async function firstStreamingError(response) {
331
- if (!response.ok)
332
- return response;
333
- const clone = response.clone();
579
+ /**
580
+ * Bytes read while looking for a successful stream's first event. Comments
581
+ * and heartbeats carry no event, so a stream of them alone must not keep the
582
+ * inspection reading (and buffering) without end.
583
+ */
584
+ const FIRST_EVENT_INSPECTION_BYTES = 65_536;
585
+ /**
586
+ * Classify a successful stream by its first event without a tee: the bytes
587
+ * read are replayed in front of the rest of the body, so cancelling the
588
+ * returned response cancels the provider body itself. `preflight` is
589
+ * `rate_limit_error` for a streamed rate limit (the provider body is then
590
+ * cancelled and only the bytes read are replayed), else the returned
591
+ * response. A stream whose first event does not arrive within the budget, or
592
+ * cannot be read, is returned unclassified.
593
+ */
594
+ export async function firstStreamingError(response) {
595
+ if (!response.ok || !response.body)
596
+ return { response, preflight: response };
597
+ const reader = response.body.getReader();
598
+ const chunks = [];
599
+ const framer = createSseFramer({
600
+ maxFrameBytes: FIRST_EVENT_INSPECTION_BYTES,
601
+ });
602
+ let bytes = 0;
603
+ let rateLimited = false;
604
+ let decided = false;
605
+ // Only the stream's first frame may drop a leading byte-order mark.
606
+ let streamStart = true;
334
607
  try {
335
- for await (const event of parseSse(clone)) {
336
- if (event.type === 'error' &&
337
- typeof event.delta?.type === 'string' &&
338
- event.delta.type === 'rate_limit_error') {
339
- return 'rate_limit_error';
608
+ while (!decided && bytes < FIRST_EVENT_INSPECTION_BYTES) {
609
+ const { done, value } = await reader.read();
610
+ if (done)
611
+ break;
612
+ chunks.push(value);
613
+ bytes += value.byteLength;
614
+ for (const frame of framer.push(value)) {
615
+ const data = parseSseFrame(decodeSseFrame(frame, { streamStart })).data;
616
+ streamStart = false;
617
+ if (!data || data === '[DONE]')
618
+ continue;
619
+ const event = JSON.parse(data);
620
+ // Anthropic reports a streamed rate limit as an `error` event whose
621
+ // `error.type` is `rate_limit_error`; only that event is a rate limit.
622
+ rateLimited =
623
+ event.type === 'error' && event.error?.type === 'rate_limit_error';
624
+ decided = true;
625
+ break;
340
626
  }
341
- return response;
342
627
  }
343
628
  }
344
629
  catch {
345
- return response;
630
+ // An unreadable body, an oversized first frame or a first event that is
631
+ // not JSON is not treated as a rate limit: the response is returned for
632
+ // the consumer to read, and an unreadable body fails that read too.
346
633
  }
347
- return response;
634
+ // The provider reader is released as soon as nothing will read it again:
635
+ // at end of stream, after a read failure, or once a cancellation settles.
636
+ const release = () => {
637
+ try {
638
+ reader.releaseLock();
639
+ }
640
+ catch {
641
+ // Already released.
642
+ }
643
+ };
644
+ const replay = (rest) => new Response(new ReadableStream({
645
+ start(controller) {
646
+ for (const chunk of chunks)
647
+ controller.enqueue(chunk);
648
+ if (!rest)
649
+ controller.close();
650
+ },
651
+ async pull(controller) {
652
+ let next;
653
+ try {
654
+ next = await reader.read();
655
+ }
656
+ catch (error) {
657
+ release();
658
+ throw error;
659
+ }
660
+ if (next.done) {
661
+ release();
662
+ controller.close();
663
+ }
664
+ else
665
+ controller.enqueue(next.value);
666
+ },
667
+ async cancel(reason) {
668
+ if (!rest)
669
+ return;
670
+ try {
671
+ await reader.cancel(reason);
672
+ }
673
+ finally {
674
+ release();
675
+ }
676
+ },
677
+ }), {
678
+ status: response.status,
679
+ statusText: response.statusText,
680
+ headers: response.headers,
681
+ });
682
+ if (rateLimited) {
683
+ // Routing replaces this response. Cancel the provider body and clear its
684
+ // copied redaction material; retain the inspected error event for callers
685
+ // that still return the replay response.
686
+ await reader.cancel().catch(() => { });
687
+ release();
688
+ return { response: replay(false), preflight: 'rate_limit_error' };
689
+ }
690
+ const inspected = replay(true);
691
+ return { response: inspected, preflight: inspected };
348
692
  }
349
693
  async function executeWithFallback(options) {
350
- await ensurePiMainAccountId(options.storagePath);
351
- const storage = await loadAccounts(options.storagePath);
352
- const { quotaManager, fallbackManager: manager } = getPiRoutingServices(options.storagePath, storage);
353
- const mainAccountId = storage?.mainAccountId;
694
+ const native = getPiNativeRuntime(options.storagePath);
695
+ let snapshot = await native.requestView(options.streamOptions?.signal);
696
+ let storage = snapshot.policyStorage;
697
+ const accountView = (id) => snapshot.accounts.find((account) => account.id === id);
698
+ const vaultAccountIdentities = () => {
699
+ if (snapshot.mode !== 'claustrum')
700
+ return undefined;
701
+ const identities = new Map();
702
+ for (const account of snapshot.accounts) {
703
+ if (account.source === 'vault' && account.accountIdentity)
704
+ identities.set(account.id, account.accountIdentity);
705
+ }
706
+ return identities;
707
+ };
708
+ const nativeReady = (id) => {
709
+ const account = accountView(id);
710
+ return Boolean(account?.type === 'oauth' &&
711
+ account.enabled &&
712
+ (account.source !== 'vault' || account.state === 'active') &&
713
+ !isPermanentRefreshError(account.lastRefreshError) &&
714
+ (account.source !== 'local' ||
715
+ (account.lastRefreshError?.nextRetryAt ?? 0) <= Date.now()));
716
+ };
717
+ const primaryAvailable = () => nativeReady(STICKY_ROUTING_MAIN_ACCOUNT_ID);
718
+ let { quotaManager, getUsableFallbackAccounts } = getPiRoutingServices(options.storagePath, snapshot);
719
+ let mainAccountId = accountView(STICKY_ROUTING_MAIN_ACCOUNT_ID)?.accountIdentity;
354
720
  quotaManager.seedMainFromStorage(storage, mainAccountId);
355
721
  quotaManager.seedFallbacksFromAccounts((storage?.accounts ?? []).filter(isOAuthAccount));
722
+ function quotaObservationIsFresh(quota, modelId) {
723
+ if (stickyQuotaSnapshotIsFresh(quota, storage, Date.now(), modelId))
724
+ return true;
725
+ // A successful usage response without quota windows is not a failed poll.
726
+ // Pi can serve that OAuth account without a known remaining capacity.
727
+ if (!quota ||
728
+ quota.five_hour ||
729
+ quota.seven_day ||
730
+ quota.source !== 'poll' ||
731
+ quota.checkedAt === undefined)
732
+ return false;
733
+ const age = getQuotaCheckIntervalMs(storage);
734
+ const scopedWindow = getScopedQuotaWindowForModel(quota, modelId);
735
+ return (Date.now() - quota.checkedAt < age &&
736
+ (!scopedWindow || Date.now() - scopedWindow.checkedAt < age));
737
+ }
738
+ async function refreshRoutingSnapshot() {
739
+ snapshot = await native.view();
740
+ storage = snapshot.policyStorage;
741
+ mainAccountId = accountView(STICKY_ROUTING_MAIN_ACCOUNT_ID)?.accountIdentity;
742
+ ({ quotaManager, getUsableFallbackAccounts } = getPiRoutingServices(options.storagePath, snapshot));
743
+ quotaManager.seedMainFromStorage(storage, mainAccountId);
744
+ quotaManager.seedFallbacksFromAccounts(storage.accounts.filter(isOAuthAccount));
745
+ }
356
746
  async function buildStickyRoutes(modelId) {
747
+ await refreshRoutingSnapshot();
357
748
  const mainEntry = quotaManager.getMain(mainAccountId);
358
749
  let mainQuota = mainEntry?.quota;
359
- if (!stickyQuotaSnapshotIsFresh(mainEntry?.quota, storage, Date.now(), modelId)) {
750
+ if (primaryAvailable() &&
751
+ !stickyQuotaSnapshotIsFresh(mainEntry?.quota, storage, Date.now(), modelId)) {
360
752
  try {
361
- mainQuota = await quotaManager.refreshMain(mainAccountId, options.primaryAccessToken);
753
+ mainQuota = await quotaManager.refreshMain(mainAccountId, '');
362
754
  }
363
755
  catch { }
364
756
  }
365
- const usableFallbacks = await manager.getUsableFallbackAccounts(storage, {
757
+ const usableFallbacks = await getUsableFallbackAccounts(storage, {
366
758
  modelId,
367
759
  });
368
760
  const usableById = new Map(usableFallbacks.map((account) => [account.id, account]));
369
761
  const allRoutes = [];
370
- if (!isPermanentRefreshError(storage?.refresh?.mainLastRefreshError)) {
762
+ if (primaryAvailable()) {
371
763
  allRoutes.push({
372
764
  id: STICKY_ROUTING_MAIN_ACCOUNT_ID,
373
- access: options.primaryAccessToken,
374
765
  quota: mainQuota,
375
766
  order: 0,
376
767
  });
@@ -379,18 +770,17 @@ async function executeWithFallback(options) {
379
770
  if (configured.enabled === false || !isOAuthAccount(configured))
380
771
  continue;
381
772
  const account = usableById.get(configured.id) ?? configured;
382
- if (!account.access || isPermanentRefreshError(account.lastRefreshError))
773
+ if (!nativeReady(account.id))
383
774
  continue;
384
775
  let accountQuota = quotaManager.getFallback(account.id, account)?.quota ?? account.quota;
385
776
  if (!stickyQuotaSnapshotIsFresh(accountQuota, storage, Date.now(), modelId)) {
386
777
  try {
387
- accountQuota = await quotaManager.refreshFallback(account.id, account.access, account);
778
+ accountQuota = await quotaManager.refreshFallback(account.id, '', account);
388
779
  }
389
780
  catch { }
390
781
  }
391
782
  allRoutes.push({
392
783
  id: account.id,
393
- access: account.access,
394
784
  quota: accountQuota,
395
785
  order: index + 1,
396
786
  account,
@@ -443,8 +833,11 @@ async function executeWithFallback(options) {
443
833
  return { allRoutes, candidates, retainAccountIds };
444
834
  }
445
835
  async function primaryQuotaRefreshConfirmsExhausted() {
836
+ await refreshRoutingSnapshot();
837
+ if (!primaryAvailable() || !mainAccountId)
838
+ return false;
446
839
  try {
447
- const quota = await quotaManager.refreshMain(mainAccountId, options.primaryAccessToken);
840
+ const quota = await quotaManager.refreshMain(mainAccountId, '');
448
841
  const entry = quotaManager.getMain(mainAccountId);
449
842
  return Boolean(entry &&
450
843
  entry.refreshAfter > Date.now() &&
@@ -455,8 +848,11 @@ async function executeWithFallback(options) {
455
848
  }
456
849
  }
457
850
  async function primaryQuotaRefreshConfirmsModelScopeExhausted() {
851
+ await refreshRoutingSnapshot();
852
+ if (!primaryAvailable())
853
+ return false;
458
854
  try {
459
- const quota = await quotaManager.refreshMain(mainAccountId, options.primaryAccessToken);
855
+ const quota = await quotaManager.refreshMain(mainAccountId, '');
460
856
  const entry = quotaManager.getMain(mainAccountId);
461
857
  return Boolean(entry &&
462
858
  entry.refreshAfter > Date.now() &&
@@ -467,62 +863,51 @@ async function executeWithFallback(options) {
467
863
  }
468
864
  }
469
865
  function primaryCachedModelScopeExhausted() {
866
+ if (!primaryAvailable())
867
+ return false;
470
868
  const entry = quotaManager.getMain(mainAccountId);
471
869
  return Boolean(entry &&
472
870
  quotaSnapshotModelScopeIsExhausted(entry.quota, options.model.id));
473
871
  }
474
872
  function primaryFreshModelScopeExhausted() {
873
+ if (!primaryAvailable())
874
+ return false;
475
875
  const entry = quotaManager.getMain(mainAccountId);
476
876
  return Boolean(entry &&
477
877
  !quotaManager.isMainStale(options.model.id) &&
478
878
  quotaSnapshotModelScopeIsExhausted(entry.quota, options.model.id));
479
879
  }
480
880
  async function tryFallbackAccounts(routeOptions = {}) {
481
- const usableOAuth = await manager.getUsableFallbackAccounts(storage, {
881
+ const usableOAuth = await getUsableFallbackAccounts(storage, {
482
882
  modelId: options.model.id,
483
883
  });
484
884
  const usableOAuthById = new Map(usableOAuth.map((account) => [account.id, account]));
485
- for (const configured of storage?.accounts ?? []) {
486
- let response = null;
487
- let account = isOAuthAccount(configured)
488
- ? usableOAuthById.get(configured.id)
489
- : configured;
490
- if (!account) {
491
- if (!isOAuthAccount(configured) ||
492
- !configured.access ||
493
- (configured.expires !== undefined &&
494
- configured.expires <= Date.now()) ||
495
- isPermanentRefreshError(configured.lastRefreshError) ||
496
- refreshBackoffActive(configured.lastRefreshError, configured.id, Date.now(), tokenFingerprint(configured.refresh)))
885
+ for (const configured of storage.accounts) {
886
+ if (configured.enabled === false)
887
+ continue;
888
+ let response;
889
+ if (isOAuthAccount(configured)) {
890
+ if (routeOptions.apiOnly || !nativeReady(configured.id))
497
891
  continue;
498
- account = configured;
499
- const quota = quotaManager.getFallback(configured.id, configured)?.quota ??
500
- configured.quota;
501
- if (quotaSnapshotHasStandardWindows(quota))
892
+ const account = usableOAuthById.get(configured.id) ?? configured;
893
+ const quota = quotaManager.getFallback(account.id, account)?.quota ?? account.quota;
894
+ if (!usableOAuthById.has(account.id) &&
895
+ quotaSnapshotHasStandardWindows(quota))
502
896
  continue;
503
897
  if (isKillswitchEnabled(storage) &&
504
- !killswitchPassesPolicy(quota, storage, configured.id, options.model.id))
505
- continue;
506
- }
507
- else if (isOAuthAccount(account) &&
508
- isKillswitchEnabled(storage) &&
509
- !killswitchPassesPolicy(quotaManager.getFallback(account.id, account)?.quota ?? account.quota, storage, account.id, options.model.id)) {
510
- continue;
511
- }
512
- if (isOAuthAccount(account)) {
513
- if (routeOptions.apiOnly === true || !account.access)
898
+ !killswitchPassesPolicy(quota, storage, account.id, options.model.id))
514
899
  continue;
515
900
  response = await sendAnthropicRequest({
516
901
  ...options,
517
- accessToken: account.access,
518
902
  oauthAccountId: account.id,
519
903
  });
520
904
  }
521
- else if (routeOptions.includeApiRoutes === true &&
522
- isApiKeyAccount(account) &&
523
- account.enabled !== false &&
524
- account.apiKey &&
525
- isValidApiBaseURL(account.baseURL)) {
905
+ else if (routeOptions.includeApiRoutes) {
906
+ const account = accountView(configured.id);
907
+ if (!account?.enabled ||
908
+ account.type !== 'api' ||
909
+ !isValidApiBaseURL(account.baseURL))
910
+ continue;
526
911
  response = await sendAnthropicRequest({
527
912
  ...options,
528
913
  apiAccount: account,
@@ -530,35 +915,36 @@ async function executeWithFallback(options) {
530
915
  }
531
916
  if (!response)
532
917
  continue;
533
- const preflight = await firstStreamingError(response);
534
- if (preflight instanceof Response && preflight.ok) {
535
- await manager.markUsed(account);
918
+ const inspected = await firstStreamingError(response);
919
+ const preflight = inspected.preflight;
920
+ if (preflight instanceof Response && preflight.ok)
536
921
  return preflight;
537
- }
538
922
  if (preflight instanceof Response &&
539
- !shouldFallbackStatus(preflight.status, storage)) {
923
+ !shouldFallbackStatus(preflight.status, storage))
540
924
  return preflight;
541
- }
542
- await response.body?.cancel().catch(() => { });
925
+ await inspected.response.body?.cancel().catch(() => { });
543
926
  }
544
927
  return null;
545
928
  }
546
929
  const routingMode = getRoutingMode(storage);
547
930
  if (routingMode === 'sticky-balanced' && options.streamOptions?.sessionId) {
548
931
  const sessionId = options.streamOptions.sessionId;
549
- const router = getPiStickyRouter(options.storagePath);
932
+ const router = await getPiStickyRouter(options.storagePath);
550
933
  const initialInputBytes = Math.max(1, Buffer.byteLength(JSON.stringify(options.context)));
551
934
  let routes = await buildStickyRoutes(options.model.id);
552
- const mainPermanentlyUnavailable = isPermanentRefreshError(storage?.refresh?.mainLastRefreshError);
935
+ const mainPermanentlyUnavailable = !primaryAvailable();
553
936
  const incompleteQuotaPool = (routes.allRoutes.length === 0 && !mainPermanentlyUnavailable) ||
554
- routes.allRoutes.some((candidate) => !candidate.quota ||
555
- !stickyQuotaSnapshotIsFresh(candidate.quota, storage, Date.now(), options.model.id));
937
+ routes.allRoutes.some((candidate) => !quotaObservationIsFresh(candidate.quota, options.model.id));
556
938
  let resolution = await router.resolve({
557
939
  sessionId,
558
940
  family: stickyRouteFamilyForModel(options.model.id),
559
941
  modelId: options.model.id,
560
- candidates: routes.candidates,
942
+ affinityModelId: options.model.id,
943
+ // Keep an existing route after a temporary quota poll failure, but do not
944
+ // choose a new balanced route until the account pool has current quota data.
945
+ candidates: incompleteQuotaPool ? [] : routes.candidates,
561
946
  retainAccountIds: routes.retainAccountIds,
947
+ accountIdentities: vaultAccountIdentities(),
562
948
  storage,
563
949
  inputBytes: initialInputBytes,
564
950
  });
@@ -571,6 +957,8 @@ async function executeWithFallback(options) {
571
957
  throw error;
572
958
  }
573
959
  if (!resolution) {
960
+ if (snapshot.mode === 'claustrum')
961
+ throw new NativeVaultNoAccountError();
574
962
  return createStickyNoRouteResponse({
575
963
  mainRefreshError: storage?.refresh?.mainLastRefreshError,
576
964
  fallbackReauthLabels: getFallbackReauthLabels(storage),
@@ -582,15 +970,10 @@ async function executeWithFallback(options) {
582
970
  if (resolution && route) {
583
971
  const sendRoute = (selected) => sendAnthropicRequest({
584
972
  ...options,
585
- accessToken: selected.access,
586
973
  oauthAccountId: selected.id,
587
974
  route: `sticky:${selected.id}`,
588
975
  });
589
- const completeRoute = async (selected, response, markUsed = true) => {
590
- if (markUsed && selected.account)
591
- await manager.markUsed(selected.account);
592
- return response;
593
- };
976
+ const completeRoute = async (_selected, response, _markUsed = true) => response;
594
977
  const proactiveQuotaDecision = stickyQuotaSnapshotIsFresh(route.quota, storage, Date.now(), options.model.id)
595
978
  ? decideStickyQuotaFailure({
596
979
  quota: route.quota,
@@ -612,43 +995,11 @@ async function executeWithFallback(options) {
612
995
  },
613
996
  }), false);
614
997
  }
615
- let response = await sendRoute(route);
616
- let preflight = await firstStreamingError(response);
998
+ const { response, preflight } = await firstStreamingError(await sendRoute(route));
617
999
  if (preflight instanceof Response && preflight.ok) {
618
1000
  return completeRoute(route, preflight);
619
1001
  }
620
- let permanentAuthFailure = preflight instanceof Response &&
621
- preflight.status === 401 &&
622
- route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID;
623
- if (preflight instanceof Response &&
624
- preflight.status === 401 &&
625
- route.account &&
626
- storage) {
627
- const authRouteId = route.id;
628
- try {
629
- const refreshed = await manager.refreshAccount(route.account, storage, {
630
- force: true,
631
- });
632
- if (refreshed.access) {
633
- route = { ...route, access: refreshed.access, account: refreshed };
634
- }
635
- await preflight.body?.cancel().catch(() => { });
636
- response = await sendRoute(route);
637
- preflight = await firstStreamingError(response);
638
- if (preflight instanceof Response && preflight.ok) {
639
- return completeRoute(route, preflight);
640
- }
641
- permanentAuthFailure =
642
- preflight instanceof Response && preflight.status === 401;
643
- }
644
- catch (error) {
645
- const latest = await loadAccounts(options.storagePath);
646
- const refreshError = latest?.accounts.find((account) => account.id === authRouteId && isOAuthAccount(account))?.lastRefreshError;
647
- if (!isPermanentRefreshError(refreshError))
648
- throw error;
649
- permanentAuthFailure = true;
650
- }
651
- }
1002
+ const permanentAuthFailure = preflight instanceof Response && preflight.status === 401;
652
1003
  let migrate = (preflight instanceof Response && preflight.status === 403) ||
653
1004
  permanentAuthFailure;
654
1005
  if (primaryResponseAllowsApiFallback(preflight)) {
@@ -656,8 +1007,8 @@ async function executeWithFallback(options) {
656
1007
  try {
657
1008
  quota =
658
1009
  route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID
659
- ? await quotaManager.refreshMain(mainAccountId, route.access)
660
- : await quotaManager.refreshFallback(route.id, route.access, route.account);
1010
+ ? await quotaManager.refreshMain(mainAccountId, '')
1011
+ : await quotaManager.refreshFallback(route.id, '', route.account);
661
1012
  }
662
1013
  catch {
663
1014
  // Retain affinity when the quota probe itself is unavailable.
@@ -707,6 +1058,7 @@ async function executeWithFallback(options) {
707
1058
  modelId: options.model.id,
708
1059
  candidates: routes.candidates,
709
1060
  retainAccountIds: routes.retainAccountIds,
1061
+ accountIdentities: vaultAccountIdentities(),
710
1062
  storage,
711
1063
  inputBytes: initialInputBytes,
712
1064
  excludeAccountIds: new Set([failedRouteId]),
@@ -749,11 +1101,26 @@ async function executeWithFallback(options) {
749
1101
  if (fallback)
750
1102
  return fallback;
751
1103
  }
1104
+ if (!primaryAvailable()) {
1105
+ if (!fallbackFirst) {
1106
+ const fallback = await tryFallbackAccounts();
1107
+ if (fallback)
1108
+ return fallback;
1109
+ }
1110
+ if (snapshot.mode === 'claustrum')
1111
+ throw new NativeVaultNoAccountError();
1112
+ return createStickyNoRouteResponse({
1113
+ routeQuotas: (storage?.accounts ?? [])
1114
+ .filter(isOAuthAccount)
1115
+ .flatMap((account) => (account.quota ? [account.quota] : [])),
1116
+ modelId: options.model.id,
1117
+ });
1118
+ }
752
1119
  if (isKillswitchEnabled(storage)) {
753
1120
  let mainQuota = quotaManager.getMain(mainAccountId)?.quota;
754
1121
  if (!mainQuota || quotaManager.isMainStale(options.model.id)) {
755
1122
  try {
756
- mainQuota = await quotaManager.refreshMain(mainAccountId, options.primaryAccessToken);
1123
+ mainQuota = await quotaManager.refreshMain(mainAccountId, '');
757
1124
  }
758
1125
  catch { }
759
1126
  }
@@ -778,12 +1145,10 @@ async function executeWithFallback(options) {
778
1145
  });
779
1146
  }
780
1147
  }
781
- const primary = await sendAnthropicRequest({
1148
+ const { response: primary, preflight: primaryPreflight } = await firstStreamingError(await sendAnthropicRequest({
782
1149
  ...options,
783
- accessToken: options.primaryAccessToken,
784
1150
  oauthAccountId: STICKY_ROUTING_MAIN_ACCOUNT_ID,
785
- });
786
- const primaryPreflight = await firstStreamingError(primary);
1151
+ }));
787
1152
  if (primaryPreflight instanceof Response) {
788
1153
  if (!shouldFallbackStatus(primaryPreflight.status, storage))
789
1154
  return primaryPreflight;
@@ -811,17 +1176,18 @@ export function streamCortexKitAnthropic(model, context, options, effortTransiti
811
1176
  const output = createOutput(model);
812
1177
  stream.push({ type: 'start', partial: output });
813
1178
  try {
814
- const accessToken = options?.apiKey ?? '';
815
- if (!accessToken)
816
- throw new Error('Missing Anthropic OAuth access token');
1179
+ buildPiOAuthMessagesUrl(model.baseUrl);
817
1180
  const storagePath = getPiAccountStoragePath();
1181
+ let hostTools = [];
818
1182
  const response = await executeWithFallback({
819
1183
  model,
820
1184
  context,
821
1185
  streamOptions: options,
822
- primaryAccessToken: accessToken,
823
1186
  storagePath,
824
1187
  effortTransitions,
1188
+ onResolvedTools: (tools) => {
1189
+ hostTools = tools;
1190
+ },
825
1191
  });
826
1192
  if (!response.ok) {
827
1193
  throw new Error(`Anthropic request failed: HTTP ${response.status} ${await response.text()}`);
@@ -876,7 +1242,7 @@ export function streamCortexKitAnthropic(model, context, options, effortTransiti
876
1242
  output.content.push({
877
1243
  type: 'toolCall',
878
1244
  id: String(block.id),
879
- name: fromClaudeCodeToolName(String(block.name), context.tools),
1245
+ name: fromClaudeCodeToolName(String(block.name), hostTools),
880
1246
  arguments: {},
881
1247
  partialJson: '',
882
1248
  index: event.index,