@cortexkit/pi-anthropic-auth 1.15.1 → 1.17.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 CHANGED
@@ -42,7 +42,7 @@ Pi state is stored separately from OpenCode at:
42
42
 
43
43
  Override the path with `PI_ANTHROPIC_AUTH_FILE`. The package also respects `PI_AGENT_DIR` when deriving the default sidecar path.
44
44
 
45
- The sidecar uses the same JSON shape as the OpenCode package, including `routing`, `claudeCache`, `cacheKeep`, `claudeFast`, `dump`, `relay`, and fallback `accounts` blocks.
45
+ The sidecar uses the same JSON shape as the OpenCode package, including `routing`, `claudeCache`, `cacheKeep`, `claudeFast`, `dump`, `relay`, and fallback `accounts` blocks. Runtime OAuth/quota state is stored in `anthropic-auth-state.json`; sticky session assignments are stored separately in `anthropic-auth-routing-state.json` with SHA-256-hashed session IDs.
46
46
 
47
47
  ## Commands
48
48
 
@@ -55,6 +55,7 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing
55
55
  /claude-cache mode hybrid
56
56
 
57
57
  /claude-cachekeep
58
+ /claude-cachekeep always
58
59
  /claude-cachekeep 09-23
59
60
  /claude-cachekeep off
60
61
 
@@ -69,11 +70,13 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing
69
70
  /claude-routing
70
71
  /claude-routing main-first
71
72
  /claude-routing fallback-first
73
+ /claude-routing sticky-balanced
74
+ /claude-routing reset
72
75
 
73
76
  /claude-quota
74
77
  ```
75
78
 
76
- `/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep HH-HH` keeps recently used hybrid-mode session caches warm during the configured local time window by sending `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, and `claude-opus-4-8`).
79
+ `/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, and `claude-opus-4-8`).
77
80
 
78
81
  ## Relay
79
82
 
package/dist/commands.js CHANGED
@@ -1,5 +1,6 @@
1
- import { buildClaudeQuotaSummary, buildFallbackQuotaSummaries, CLAUDE_ACCOUNT_COMMAND_NAME, CLAUDE_CACHE_KEEP_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, createEmptyStorage, executeAccountCommand, executeCache1hCommand, executeCacheKeepCommand, executeDumpCommand, executeFastModeCommand, executeLoggingCommand, executeRoutingCommand, getCache1hPersistentMode, getCacheKeepWindow, getPersistedLogLevel, getRoutingMode, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isCacheKeepPersistentlyEnabled, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, loadAccounts, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, parseLoggingCommandAction, parseRoutingCommandAction, removeAccountPersistent, reorderAccountsPersistent, setAccountEnabledPersistent, setCache1hPersistentEnabled, setCache1hPersistentMode, setCacheKeepPersistentEnabled, setCacheKeepPersistentWindow, setCacheKeepSubagentsEnabled, setDumpEnabled, setDumpPersistentEnabled, setFastModePersistentEnabled, setLogLevelPersistent, setRoutingMode, } from '@cortexkit/anthropic-auth-core';
1
+ import { buildClaudeQuotaSummary, buildFallbackQuotaSummaries, CLAUDE_ACCOUNT_COMMAND_NAME, CLAUDE_CACHE_KEEP_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, createEmptyStorage, executeAccountCommand, executeCache1hCommand, executeCacheKeepCommand, executeDumpCommand, executeFastModeCommand, executeLoggingCommand, executeRoutingCommand, getCache1hPersistentMode, getCacheKeepWindow, getPersistedLogLevel, getRoutingMode, isCache1hPersistentlyEnabled, isCacheKeepAlways, isCacheKeepHybridActive, isCacheKeepPersistentlyEnabled, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, loadAccounts, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, parseLoggingCommandAction, parseRoutingCommandAction, removeAccountPersistent, reorderAccountsPersistent, setAccountEnabledPersistent, setCache1hPersistentEnabled, setCache1hPersistentMode, setCacheKeepPersistentAlways, setCacheKeepPersistentEnabled, setCacheKeepPersistentWindow, setCacheKeepSubagentsEnabled, setDumpEnabled, setDumpPersistentEnabled, setFastModePersistentEnabled, setLogLevelPersistent, setRoutingMode, } from '@cortexkit/anthropic-auth-core';
2
2
  import { getPiAccountStoragePath } from "./paths.js";
3
+ import { clearPiStickyRoutingSession, getPiTrackedCacheKeepSessions, } from "./stream.js";
3
4
  function notify(ctx, message, kind = 'info') {
4
5
  ctx.ui.notify(message, kind);
5
6
  }
@@ -35,7 +36,7 @@ export function registerCommands(pi) {
35
36
  },
36
37
  });
37
38
  pi.registerCommand(CLAUDE_CACHE_KEEP_COMMAND_NAME, {
38
- description: 'Keep hybrid Claude cache warm during a local time window',
39
+ description: 'Keep hybrid Claude cache warm always or during a local time window',
39
40
  handler: async (args, ctx) => {
40
41
  const path = getPiAccountStoragePath();
41
42
  let storage = await loadAccounts(path);
@@ -43,17 +44,28 @@ export function registerCommands(pi) {
43
44
  if (action.type === 'window') {
44
45
  storage = await setCacheKeepPersistentWindow(action.startHour, action.endHour, path);
45
46
  }
47
+ else if (action.type === 'always') {
48
+ storage = await setCacheKeepPersistentAlways(path);
49
+ }
46
50
  else if (action.type === 'disable') {
47
51
  storage = await setCacheKeepPersistentEnabled(false, path);
48
52
  }
49
53
  else if (action.type === 'subagents') {
50
54
  storage = await setCacheKeepSubagentsEnabled(action.enabled, path);
51
55
  }
56
+ const trackedSessionDetails = await getPiTrackedCacheKeepSessions();
57
+ const nextPrewarmAt = trackedSessionDetails.length
58
+ ? Math.min(...trackedSessionDetails.map((session) => session.nextPrewarmAt))
59
+ : undefined;
52
60
  notify(ctx, executeCacheKeepCommand({
53
61
  argumentsText: args ?? '',
54
62
  enabled: isCacheKeepPersistentlyEnabled(storage),
63
+ always: isCacheKeepAlways(storage),
55
64
  window: getCacheKeepWindow(storage),
56
65
  hybridActive: isCacheKeepHybridActive(storage),
66
+ trackedSessions: trackedSessionDetails.length,
67
+ trackedSessionDetails,
68
+ nextPrewarmAt,
57
69
  }), action.type === 'usage' ? 'warning' : 'info');
58
70
  },
59
71
  });
@@ -116,6 +128,9 @@ export function registerCommands(pi) {
116
128
  if (action.type === 'mode') {
117
129
  storage = await setRoutingMode(action.mode, path);
118
130
  }
131
+ else if (action.type === 'reset') {
132
+ await clearPiStickyRoutingSession(path, ctx.sessionManager.getSessionId());
133
+ }
119
134
  notify(ctx, executeRoutingCommand({
120
135
  argumentsText: args ?? '',
121
136
  mode: getRoutingMode(storage),
package/dist/stream.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type ApiKeyAccount } from '@cortexkit/anthropic-auth-core';
2
2
  import { type Api, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions } from '@earendil-works/pi-ai';
3
+ export declare function clearPiStickyRoutingSession(storagePath: string, sessionId: string): Promise<void>;
4
+ export declare function getPiTrackedCacheKeepSessions(): Promise<import("@cortexkit/anthropic-auth-core").CacheKeepTrackedSession[]>;
3
5
  type AnthropicEvent = {
4
6
  type?: string;
5
7
  index?: number;
package/dist/stream.js CHANGED
@@ -1,12 +1,68 @@
1
- import { applyClaudeCodeHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, dumpDirectRequest, FAST_MODE_BETA, FallbackAccountManager, getCache1hPersistentMode, getRelayConfig, getRoutingMode, isApiKeyAccount, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, isOAuthAccount, isValidApiBaseURL, loadAccounts, mergeAnthropicBetas, QuotaManager, quotaSnapshotModelScopeIsExhausted, resolveClaudeCodeIdentity, sendViaRelay, setDumpEnabled, shouldFallbackStatus, } from '@cortexkit/anthropic-auth-core';
1
+ import { applyClaudeCodeHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, decideStickyQuotaFailure, dumpDirectRequest, FAST_MODE_BETA, FallbackAccountManager, getCache1hPersistentMode, getDefaultCacheKeepRegistryDirectory, getRelayConfig, getRoutingMode, getStickyRoutingStatePath, isApiKeyAccount, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, isKillswitchEnabled, isOAuthAccount, isPermanentRefreshError, isValidApiBaseURL, killswitchPassesPolicy, loadAccounts, mergeAnthropicBetas, QuotaManager, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, resolveClaudeCodeIdentity, STICKY_ROUTING_MAIN_ACCOUNT_ID, StickySessionRouter, sendViaRelay, setDumpEnabled, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, stickyRetryAfterWithJitter, stickyRouteFamilyForModel, } from '@cortexkit/anthropic-auth-core';
2
2
  import { calculateCost, createAssistantMessageEventStream, } from '@earendil-works/pi-ai';
3
3
  import { buildAnthropicRequest, fromClaudeCodeToolName } from "./convert.js";
4
4
  import { getPiAccountStoragePath } from "./paths.js";
5
5
  function errorText(error) {
6
6
  return error instanceof Error ? error.message : String(error);
7
7
  }
8
+ let cacheKeepRegistry;
9
+ let cacheKeepRegistryDirectory;
10
+ const stickyRouters = new Map();
11
+ const quotaManagers = new Map();
12
+ const fallbackManagers = new Map();
13
+ const PI_SERVICE_CACHE_LIMIT = 16;
14
+ function setBoundedService(map, key, value) {
15
+ map.delete(key);
16
+ map.set(key, value);
17
+ while (map.size > PI_SERVICE_CACHE_LIMIT) {
18
+ const oldest = map.keys().next().value;
19
+ if (oldest === undefined)
20
+ break;
21
+ map.delete(oldest);
22
+ }
23
+ }
24
+ function getPiRoutingServices(storagePath, storage) {
25
+ let quotaManager = quotaManagers.get(storagePath);
26
+ let fallbackManager = fallbackManagers.get(storagePath);
27
+ if (!quotaManager || !fallbackManager) {
28
+ quotaManager = new QuotaManager({ storage });
29
+ fallbackManager = new FallbackAccountManager({
30
+ configPath: storagePath,
31
+ quotaManager,
32
+ });
33
+ setBoundedService(quotaManagers, storagePath, quotaManager);
34
+ setBoundedService(fallbackManagers, storagePath, fallbackManager);
35
+ }
36
+ else {
37
+ quotaManager.updateStorage(storage);
38
+ }
39
+ return { quotaManager, fallbackManager };
40
+ }
41
+ function getPiStickyRouter(storagePath) {
42
+ const path = process.env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE ||
43
+ getStickyRoutingStatePath(storagePath);
44
+ let router = stickyRouters.get(path);
45
+ if (!router) {
46
+ router = new StickySessionRouter({ path });
47
+ setBoundedService(stickyRouters, path, router);
48
+ }
49
+ return router;
50
+ }
51
+ export async function clearPiStickyRoutingSession(storagePath, sessionId) {
52
+ await getPiStickyRouter(storagePath).clear(sessionId);
53
+ }
54
+ function getPiCacheKeepRegistry() {
55
+ const directory = process.env.PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR ||
56
+ getDefaultCacheKeepRegistryDirectory('pi');
57
+ if (!cacheKeepRegistry || cacheKeepRegistryDirectory !== directory) {
58
+ cacheKeepRegistry = new CacheKeepSessionRegistry({ directory });
59
+ cacheKeepRegistryDirectory = directory;
60
+ }
61
+ return cacheKeepRegistry;
62
+ }
8
63
  const cacheKeepManager = new CacheKeepManager({
9
64
  loadStorage: () => loadAccounts(getPiAccountStoragePath()),
65
+ onTrackedSessionsChanged: (sessions) => getPiCacheKeepRegistry().publish(sessions),
10
66
  prepareHeaders: async (headers, target) => {
11
67
  const authorization = headers.get('authorization') ?? '';
12
68
  const match = /^Bearer\s+(.+)$/i.exec(authorization);
@@ -31,6 +87,9 @@ const cacheKeepManager = new CacheKeepManager({
31
87
  return headers;
32
88
  },
33
89
  });
90
+ export async function getPiTrackedCacheKeepSessions() {
91
+ return getPiCacheKeepRegistry().list(cacheKeepManager.trackedSessions());
92
+ }
34
93
  function mapStopReason(reason) {
35
94
  switch (reason) {
36
95
  case 'end_turn':
@@ -178,13 +237,15 @@ async function sendAnthropicRequest(options) {
178
237
  bodyText,
179
238
  storage,
180
239
  cacheMode: isCacheKeepHybridActive(storage) ? 'hybrid' : 'disabled',
240
+ oauthAccountId: options.oauthAccountId,
181
241
  });
182
242
  const directFetch = async () => {
183
243
  try {
184
244
  const response = await fetch(input, init);
185
245
  await dumpDirectRequest({
186
246
  affinity: relayAffinity,
187
- route: options.apiAccount ? `api:${options.apiAccount.id}` : 'oauth',
247
+ route: options.route ??
248
+ (options.apiAccount ? `api:${options.apiAccount.id}` : 'oauth'),
188
249
  status: response.status,
189
250
  bodyText,
190
251
  url: input.toString(),
@@ -196,7 +257,8 @@ async function sendAnthropicRequest(options) {
196
257
  catch (error) {
197
258
  await dumpDirectRequest({
198
259
  affinity: relayAffinity,
199
- route: options.apiAccount ? `api:${options.apiAccount.id}` : 'oauth',
260
+ route: options.route ??
261
+ (options.apiAccount ? `api:${options.apiAccount.id}` : 'oauth'),
200
262
  error: errorText(error),
201
263
  bodyText,
202
264
  url: input.toString(),
@@ -245,11 +307,93 @@ async function firstStreamingError(response) {
245
307
  return response;
246
308
  }
247
309
  async function executeWithFallback(options) {
248
- const manager = new FallbackAccountManager({
249
- configPath: options.storagePath,
250
- });
251
310
  const storage = await loadAccounts(options.storagePath);
252
- const quotaManager = new QuotaManager({ storage });
311
+ const { quotaManager, fallbackManager: manager } = getPiRoutingServices(options.storagePath, storage);
312
+ quotaManager.seedMainFromStorage(storage, options.primaryAccessToken);
313
+ quotaManager.seedFallbacksFromAccounts((storage?.accounts ?? []).filter(isOAuthAccount));
314
+ async function buildStickyRoutes(modelId) {
315
+ const mainEntry = quotaManager.getMain(options.primaryAccessToken);
316
+ let mainQuota = mainEntry?.quota;
317
+ if (!stickyQuotaSnapshotIsFresh(mainEntry?.quota, storage, Date.now(), modelId)) {
318
+ try {
319
+ mainQuota = await quotaManager.refreshMain(options.primaryAccessToken);
320
+ }
321
+ catch { }
322
+ }
323
+ const usableFallbacks = await manager.getUsableFallbackAccounts(storage, {
324
+ modelId,
325
+ });
326
+ const usableById = new Map(usableFallbacks.map((account) => [account.id, account]));
327
+ const allRoutes = [];
328
+ if (!isPermanentRefreshError(storage?.refresh?.mainLastRefreshError)) {
329
+ allRoutes.push({
330
+ id: STICKY_ROUTING_MAIN_ACCOUNT_ID,
331
+ access: options.primaryAccessToken,
332
+ quota: mainQuota,
333
+ order: 0,
334
+ });
335
+ }
336
+ for (const [index, configured] of (storage?.accounts ?? []).entries()) {
337
+ if (configured.enabled === false || !isOAuthAccount(configured))
338
+ continue;
339
+ const account = usableById.get(configured.id) ?? configured;
340
+ if (!account.access || isPermanentRefreshError(account.lastRefreshError))
341
+ continue;
342
+ let accountQuota = quotaManager.getFallback(account.id, account.access)?.quota ??
343
+ account.quota;
344
+ if (!stickyQuotaSnapshotIsFresh(accountQuota, storage, Date.now(), modelId)) {
345
+ try {
346
+ accountQuota = await quotaManager.refreshFallback(account.id, account.access);
347
+ }
348
+ catch { }
349
+ }
350
+ allRoutes.push({
351
+ id: account.id,
352
+ access: account.access,
353
+ quota: accountQuota,
354
+ order: index + 1,
355
+ account,
356
+ });
357
+ }
358
+ const retainAccountIds = new Set(allRoutes.flatMap((route) => {
359
+ const refreshError = route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID
360
+ ? storage?.refresh?.mainLastRefreshError
361
+ : route.account?.lastRefreshError;
362
+ if (isPermanentRefreshError(refreshError))
363
+ return [];
364
+ if (stickyQuotaSnapshotIsFresh(route.quota, storage, Date.now(), modelId) &&
365
+ decideStickyQuotaFailure({ quota: route.quota, modelId }).action ===
366
+ 'migrate') {
367
+ return [];
368
+ }
369
+ if (isKillswitchEnabled(storage) &&
370
+ !killswitchPassesPolicy(route.quota, storage, route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? undefined : route.id, modelId)) {
371
+ return [];
372
+ }
373
+ return [route.id];
374
+ }));
375
+ const usableIds = new Set(usableFallbacks.map((account) => account.id));
376
+ const candidates = allRoutes.flatMap((route) => {
377
+ if (!route.quota)
378
+ return [];
379
+ const accountId = route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? undefined : route.id;
380
+ const passes = quotaSnapshotPassesPolicy(route.quota, storage) &&
381
+ quotaSnapshotPassesModelScope(route.quota, modelId) &&
382
+ (!isKillswitchEnabled(storage) ||
383
+ killswitchPassesPolicy(route.quota, storage, accountId, modelId)) &&
384
+ (route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID || usableIds.has(route.id));
385
+ return passes
386
+ ? [
387
+ {
388
+ accountId: route.id,
389
+ quota: route.quota,
390
+ order: route.order,
391
+ },
392
+ ]
393
+ : [];
394
+ });
395
+ return { allRoutes, candidates, retainAccountIds };
396
+ }
253
397
  async function primaryQuotaRefreshConfirmsExhausted() {
254
398
  try {
255
399
  const quota = await quotaManager.refreshMain(options.primaryAccessToken);
@@ -282,7 +426,7 @@ async function executeWithFallback(options) {
282
426
  function primaryFreshModelScopeExhausted() {
283
427
  const entry = quotaManager.getMain(options.primaryAccessToken);
284
428
  return Boolean(entry &&
285
- entry.refreshAfter > Date.now() &&
429
+ !quotaManager.isMainStale(options.model.id) &&
286
430
  quotaSnapshotModelScopeIsExhausted(entry.quota, options.model.id));
287
431
  }
288
432
  async function tryFallbackAccounts(routeOptions = {}) {
@@ -298,7 +442,7 @@ async function executeWithFallback(options) {
298
442
  if (!account)
299
443
  continue;
300
444
  if (isOAuthAccount(account)) {
301
- if (!account.access)
445
+ if (routeOptions.apiOnly === true || !account.access)
302
446
  continue;
303
447
  response = await sendAnthropicRequest({
304
448
  ...options,
@@ -330,7 +474,191 @@ async function executeWithFallback(options) {
330
474
  }
331
475
  return null;
332
476
  }
333
- const fallbackFirst = getRoutingMode(storage) === 'fallback-first';
477
+ const routingMode = getRoutingMode(storage);
478
+ if (routingMode === 'sticky-balanced' && options.streamOptions?.sessionId) {
479
+ const sessionId = options.streamOptions.sessionId;
480
+ const router = getPiStickyRouter(options.storagePath);
481
+ const initialInputBytes = Math.max(1, Buffer.byteLength(JSON.stringify(options.context)));
482
+ let routes = await buildStickyRoutes(options.model.id);
483
+ const incompleteQuotaPool = routes.allRoutes.length === 0 ||
484
+ routes.allRoutes.some((candidate) => !candidate.quota ||
485
+ !stickyQuotaSnapshotIsFresh(candidate.quota, storage, Date.now(), options.model.id));
486
+ let resolution = await router.resolve({
487
+ sessionId,
488
+ family: stickyRouteFamilyForModel(options.model.id),
489
+ modelId: options.model.id,
490
+ candidates: routes.candidates,
491
+ retainAccountIds: routes.retainAccountIds,
492
+ storage,
493
+ inputBytes: initialInputBytes,
494
+ });
495
+ if (!resolution && incompleteQuotaPool) {
496
+ const error = new Error('Sticky-balanced routing is waiting for current OAuth quota snapshots');
497
+ Object.assign(error, {
498
+ code: 'ECONNRESET',
499
+ syscall: 'sticky-routing',
500
+ });
501
+ throw error;
502
+ }
503
+ let route = routes.allRoutes.find((candidate) => candidate.id === resolution?.accountId);
504
+ if (resolution && route) {
505
+ const sendRoute = (selected) => sendAnthropicRequest({
506
+ ...options,
507
+ accessToken: selected.access,
508
+ oauthAccountId: selected.id,
509
+ route: `sticky:${selected.id}`,
510
+ });
511
+ const completeRoute = async (selected, response, markUsed = true) => {
512
+ if (markUsed && selected.account)
513
+ await manager.markUsed(selected.account);
514
+ return response;
515
+ };
516
+ const proactiveQuotaDecision = stickyQuotaSnapshotIsFresh(route.quota, storage, Date.now(), options.model.id)
517
+ ? decideStickyQuotaFailure({
518
+ quota: route.quota,
519
+ modelId: options.model.id,
520
+ })
521
+ : undefined;
522
+ if (proactiveQuotaDecision?.action === 'hold') {
523
+ return completeRoute(route, new Response(JSON.stringify({
524
+ type: 'error',
525
+ error: {
526
+ type: 'rate_limit_error',
527
+ message: 'Sticky OAuth account five-hour quota resets shortly; retaining session affinity.',
528
+ },
529
+ }), {
530
+ status: 429,
531
+ headers: {
532
+ 'content-type': 'application/json',
533
+ 'retry-after': String(stickyRetryAfterWithJitter(sessionId, proactiveQuotaDecision.retryAfterSeconds)),
534
+ },
535
+ }), false);
536
+ }
537
+ let response = await sendRoute(route);
538
+ let preflight = await firstStreamingError(response);
539
+ if (preflight instanceof Response && preflight.ok) {
540
+ return completeRoute(route, preflight);
541
+ }
542
+ let permanentAuthFailure = preflight instanceof Response &&
543
+ preflight.status === 401 &&
544
+ route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID;
545
+ if (preflight instanceof Response &&
546
+ preflight.status === 401 &&
547
+ route.account &&
548
+ storage) {
549
+ const authRouteId = route.id;
550
+ try {
551
+ const refreshed = await manager.refreshAccount(route.account, storage, {
552
+ force: true,
553
+ });
554
+ if (refreshed.access) {
555
+ route = { ...route, access: refreshed.access, account: refreshed };
556
+ }
557
+ await preflight.body?.cancel().catch(() => { });
558
+ response = await sendRoute(route);
559
+ preflight = await firstStreamingError(response);
560
+ if (preflight instanceof Response && preflight.ok) {
561
+ return completeRoute(route, preflight);
562
+ }
563
+ permanentAuthFailure =
564
+ preflight instanceof Response && preflight.status === 401;
565
+ }
566
+ catch (error) {
567
+ const latest = await loadAccounts(options.storagePath);
568
+ const refreshError = latest?.accounts.find((account) => account.id === authRouteId && isOAuthAccount(account))?.lastRefreshError;
569
+ if (!isPermanentRefreshError(refreshError))
570
+ throw error;
571
+ permanentAuthFailure = true;
572
+ }
573
+ }
574
+ let migrate = (preflight instanceof Response && preflight.status === 403) ||
575
+ permanentAuthFailure;
576
+ if (primaryResponseAllowsApiFallback(preflight)) {
577
+ let quota;
578
+ try {
579
+ quota =
580
+ route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID
581
+ ? await quotaManager.refreshMain(route.access)
582
+ : await quotaManager.refreshFallback(route.id, route.access);
583
+ }
584
+ catch {
585
+ // Retain affinity when the quota probe itself is unavailable.
586
+ quota = undefined;
587
+ }
588
+ const decision = decideStickyQuotaFailure({
589
+ quota,
590
+ modelId: options.model.id,
591
+ });
592
+ if (decision.action === 'hold') {
593
+ const headers = new Headers(preflight instanceof Response
594
+ ? preflight.headers
595
+ : response.headers);
596
+ headers.set('retry-after', String(stickyRetryAfterWithJitter(sessionId, decision.retryAfterSeconds)));
597
+ if (preflight instanceof Response) {
598
+ return completeRoute(route, new Response(preflight.body, {
599
+ status: preflight.status,
600
+ statusText: preflight.statusText,
601
+ headers,
602
+ }));
603
+ }
604
+ await response.body?.cancel().catch(() => { });
605
+ headers.set('content-type', 'application/json');
606
+ return completeRoute(route, new Response(JSON.stringify({
607
+ type: 'error',
608
+ error: {
609
+ type: 'rate_limit_error',
610
+ message: 'Sticky OAuth account five-hour quota resets shortly; retaining session affinity.',
611
+ },
612
+ }), { status: 429, headers }));
613
+ }
614
+ migrate = decision.action === 'migrate';
615
+ }
616
+ if (migrate) {
617
+ const failedRouteId = route.id;
618
+ routes = await buildStickyRoutes(options.model.id);
619
+ if (routes.candidates.some((candidate) => candidate.accountId !== failedRouteId)) {
620
+ if (preflight instanceof Response) {
621
+ await preflight.body?.cancel().catch(() => { });
622
+ }
623
+ else {
624
+ await response.body?.cancel().catch(() => { });
625
+ }
626
+ resolution = await router.resolve({
627
+ sessionId,
628
+ family: stickyRouteFamilyForModel(options.model.id),
629
+ modelId: options.model.id,
630
+ candidates: routes.candidates,
631
+ retainAccountIds: routes.retainAccountIds,
632
+ storage,
633
+ inputBytes: initialInputBytes,
634
+ excludeAccountIds: new Set([failedRouteId]),
635
+ });
636
+ const migrated = routes.allRoutes.find((candidate) => candidate.id === resolution?.accountId);
637
+ if (resolution && migrated) {
638
+ route = migrated;
639
+ return completeRoute(route, await sendRoute(route));
640
+ }
641
+ }
642
+ if (primaryResponseAllowsApiFallback(preflight) &&
643
+ (await primaryQuotaRefreshConfirmsExhausted())) {
644
+ if (preflight instanceof Response) {
645
+ await preflight.body?.cancel().catch(() => { });
646
+ }
647
+ else {
648
+ await response.body?.cancel().catch(() => { });
649
+ }
650
+ const apiFallback = await tryFallbackAccounts({
651
+ includeApiRoutes: true,
652
+ apiOnly: true,
653
+ });
654
+ if (apiFallback)
655
+ return apiFallback;
656
+ }
657
+ }
658
+ return completeRoute(route, preflight instanceof Response ? preflight : response);
659
+ }
660
+ }
661
+ const fallbackFirst = routingMode === 'fallback-first';
334
662
  if (fallbackFirst) {
335
663
  const fallback = await tryFallbackAccounts();
336
664
  if (fallback)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/pi-anthropic-auth",
3
- "version": "1.15.1",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,7 +36,7 @@
36
36
  "prepublishOnly": "bun run build"
37
37
  },
38
38
  "dependencies": {
39
- "@cortexkit/anthropic-auth-core": "1.15.1"
39
+ "@cortexkit/anthropic-auth-core": "1.17.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@earendil-works/pi-ai": "*",