@yansigit/opencodex 2.31.1 → 2.31.2

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.
@@ -10,11 +10,10 @@
10
10
  * Intentionally narrower than the Codex pool: no mid-session quota rotation,
11
11
  * soft-avoid ladders, or probe leases. Anthropic OAuth is ToS-sensitive.
12
12
  *
13
- * Affinity is process-local (lost on restart). Cooldown uses Retry-After when present,
14
- * otherwise a default backoff. 401/403 credential failures should set needsReauth on the
15
- * store (existing OAuth path) so the account is excluded from eligibility.
13
+ * Affinity and cooldown delegate to `src/routing/account-pool/` (process-local).
14
+ * 401/403 credential failures should set needsReauth on the store (existing OAuth path)
15
+ * so the account is excluded from eligibility.
16
16
  */
17
- import { createHash } from "node:crypto";
18
17
  import { setActiveAccount, getAccountSet, getAccountCredential } from "./store";
19
18
  import { getCachedProviderAccountQuota } from "../providers/quota";
20
19
  import { fallbackCodexAccountLogLabel } from "../codex/account-label";
@@ -22,25 +21,36 @@ import {
22
21
  normalizeAccountPoolStickyLimit,
23
22
  normalizeAccountPoolStrategy,
24
23
  notePoolRotationFailure,
25
- notePoolRotationSuccess,
26
24
  pickRoundRobinAccount,
27
25
  POOL_KEY_ANTHROPIC,
28
26
  seedPoolRotationAccount,
29
27
  } from "../codex/pool-rotation";
30
28
  import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
31
- import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
32
- import { retainedUtf8Bytes } from "../lib/admission";
29
+ import {
30
+ ACCOUNT_POOL_MAX_FAILOVERS,
31
+ affinitySizeForTests,
32
+ bindSessionAffinity,
33
+ buildSessionKeyFromParts,
34
+ clearAccountPoolState,
35
+ clearAffinityState,
36
+ clearResolveState,
37
+ clearSessionAffinityForAccount,
38
+ getPoolCooldownRegistry,
39
+ getSessionAffinity,
40
+ isAccountPoolEligible,
41
+ isRateLimitStickWait,
42
+ normalizeAffinityComponent,
43
+ recordPoolAccountCooldown,
44
+ resolvePoolAccount,
45
+ touchSessionAffinity,
46
+ type AccountPoolPlugin,
47
+ } from "../routing/account-pool";
33
48
 
34
49
  const PROVIDER = "anthropic";
35
- const DEFAULT_COOLDOWN_MS = 60_000;
36
- const MAX_COOLDOWN_MS = 15 * 60_000;
37
- const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
38
- const MAX_AFFINITY_ENTRIES = 2_000;
39
- const MAX_AFFINITY_COMPONENT_BYTES = 512;
40
50
  const UNKNOWN_USAGE_SCORE = 100;
41
51
  const DEFAULT_AUTO_SWITCH_THRESHOLD = 80;
42
52
  /** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */
43
- export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3;
53
+ export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = ACCOUNT_POOL_MAX_FAILOVERS;
44
54
 
45
55
  export interface AnthropicAccountPoolConfig {
46
56
  enabled?: boolean;
@@ -52,23 +62,24 @@ export interface AnthropicAccountPoolConfig {
52
62
  stickyLimit?: number;
53
63
  }
54
64
 
55
- interface AccountHealth {
56
- cooldownUntil: number;
57
- cooldownSource: "retry-after" | "default";
58
- }
59
-
60
- interface AffinityEntry {
61
- accountId: string;
62
- lastUsedAt: number;
63
- }
64
-
65
- const upstreamHealth = new Map<string, AccountHealth>();
66
- const sessionAffinity = new Map<string, AffinityEntry>();
65
+ const anthropicPoolPlugin: AccountPoolPlugin = {
66
+ poolKey: POOL_KEY_ANTHROPIC,
67
+ sessionKeyFromRequest: buildSessionKeyFromParts,
68
+ listEligibleAccountIds(now) {
69
+ const set = getAccountSet(PROVIDER);
70
+ if (!set) return [];
71
+ return set.accounts
72
+ .filter(account =>
73
+ account.needsReauth !== true
74
+ && isPoolCredentialUsable(account.id, now))
75
+ .map(account => account.id);
76
+ },
77
+ usageScore(accountId) {
78
+ return anthropicUsageScore(accountId);
79
+ },
80
+ };
67
81
 
68
- function normalizeAffinityComponent(value: string | null | undefined): string {
69
- const normalized = value?.trim() ?? "";
70
- return normalized && retainedUtf8Bytes(normalized) <= MAX_AFFINITY_COMPONENT_BYTES ? normalized : "";
71
- }
82
+ const TOKEN_SKEW_MS = 60_000;
72
83
 
73
84
  export function anthropicAccountPoolConfig(config: OcxConfig): AnthropicAccountPoolConfig {
74
85
  const raw = config.anthropicAccountPool;
@@ -86,60 +97,34 @@ export function anthropicAutoSwitchThreshold(config: OcxConfig): number {
86
97
  return DEFAULT_AUTO_SWITCH_THRESHOLD;
87
98
  }
88
99
 
89
- function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined {
90
- const text = value?.trim();
91
- if (!text) return undefined;
92
- if (/^\d+(?:\.\d+)?$/.test(text)) {
93
- const seconds = Number(text);
94
- if (Number.isFinite(seconds) && seconds > 0) {
95
- return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
96
- }
97
- }
98
- const timestamp = Date.parse(text);
99
- if (!Number.isFinite(timestamp)) return undefined;
100
- const delay = timestamp - now;
101
- return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
102
- }
103
-
104
100
  export function getAnthropicAccountHealthSnapshot(
105
101
  accountId: string,
106
102
  now = Date.now(),
107
- ): { cooldownUntil?: number; cooldownSource?: AccountHealth["cooldownSource"] } | null {
108
- const entry = upstreamHealth.get(accountId);
103
+ ): { cooldownUntil?: number; cooldownSource?: "retry-after" | "default" } | null {
104
+ const entry = getPoolCooldownRegistry(POOL_KEY_ANTHROPIC).get(accountId, now);
109
105
  if (!entry) return null;
110
- if (entry.cooldownUntil <= now) {
111
- upstreamHealth.delete(accountId);
112
- return null;
113
- }
114
- return { cooldownUntil: entry.cooldownUntil, cooldownSource: entry.cooldownSource };
106
+ const source = entry.source === "retry-after" ? "retry-after" : "default";
107
+ return { cooldownUntil: entry.until, cooldownSource: source };
115
108
  }
116
109
 
117
110
  export function clearAnthropicAccountCooldown(accountId: string): boolean {
118
- return upstreamHealth.delete(accountId);
111
+ const registry = getPoolCooldownRegistry(POOL_KEY_ANTHROPIC);
112
+ const had = registry.get(accountId) !== null;
113
+ registry.clear(accountId);
114
+ return had;
119
115
  }
120
116
 
121
117
  export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
122
- let removed = 0;
123
- for (const [accountId, health] of upstreamHealth) {
124
- if (health.cooldownUntil > now) continue;
125
- upstreamHealth.delete(accountId);
126
- removed += 1;
127
- }
128
- return removed;
118
+ return getPoolCooldownRegistry(POOL_KEY_ANTHROPIC).sweep(now);
129
119
  }
130
120
 
131
121
  /** Test / logout helper. */
132
122
  export function clearAnthropicAccountPoolState(): void {
133
- upstreamHealth.clear();
134
- sessionAffinity.clear();
123
+ clearAccountPoolState(POOL_KEY_ANTHROPIC);
135
124
  }
136
125
 
137
126
  export function anthropicSessionAffinitySizeForTests(): number {
138
- return sessionAffinity.size;
139
- }
140
-
141
- function isCooled(accountId: string, now: number): boolean {
142
- return getAnthropicAccountHealthSnapshot(accountId, now) !== null;
127
+ return affinitySizeForTests(POOL_KEY_ANTHROPIC);
143
128
  }
144
129
 
145
130
  function hasKnownUsage(accountId: string): boolean {
@@ -147,7 +132,7 @@ function hasKnownUsage(accountId: string): boolean {
147
132
  return typeof quota?.fiveHourPercent === "number" && Number.isFinite(quota.fiveHourPercent);
148
133
  }
149
134
 
150
- function usageScore(accountId: string): number {
135
+ function anthropicUsageScore(accountId: string): number {
151
136
  const quota = getCachedProviderAccountQuota(PROVIDER, accountId);
152
137
  if (!quota || typeof quota.fiveHourPercent !== "number" || !Number.isFinite(quota.fiveHourPercent)) {
153
138
  return UNKNOWN_USAGE_SCORE;
@@ -155,8 +140,6 @@ function usageScore(accountId: string): number {
155
140
  return Math.max(0, Math.min(100, quota.fiveHourPercent));
156
141
  }
157
142
 
158
- const TOKEN_SKEW_MS = 60_000;
159
-
160
143
  /** Background `local-cli` slots with expired access are not pool-eligible (identity adoption risk). */
161
144
  function isPoolCredentialUsable(accountId: string, now: number): boolean {
162
145
  const cred = getAccountCredential(PROVIDER, accountId);
@@ -166,13 +149,19 @@ function isPoolCredentialUsable(accountId: string, now: number): boolean {
166
149
  return cred.expires > now + TOKEN_SKEW_MS;
167
150
  }
168
151
 
152
+ function isAnthropicAccountEligible(accountId: string, now: number): boolean {
153
+ return isAccountPoolEligible(POOL_KEY_ANTHROPIC, accountId, now, {
154
+ allowStickWait: isRateLimitStickWait(POOL_KEY_ANTHROPIC, accountId, now),
155
+ });
156
+ }
157
+
169
158
  export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
170
159
  const set = getAccountSet(PROVIDER);
171
160
  if (!set) return [];
172
161
  return set.accounts
173
162
  .filter(account =>
174
163
  account.needsReauth !== true
175
- && !isCooled(account.id, now)
164
+ && isAnthropicAccountEligible(account.id, now)
176
165
  && isPoolCredentialUsable(account.id, now))
177
166
  .map(account => account.id);
178
167
  }
@@ -195,10 +184,10 @@ function pickLowestUsage(excludeId: string | undefined, now: number): string | n
195
184
  const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
196
185
  if (eligible.length === 0) return null;
197
186
  let best = eligible[0]!;
198
- let bestScore = usageScore(best);
187
+ let bestScore = anthropicUsageScore(best);
199
188
  for (let i = 1; i < eligible.length; i++) {
200
189
  const id = eligible[i]!;
201
- const score = usageScore(id);
190
+ const score = anthropicUsageScore(id);
202
191
  if (score < bestScore) {
203
192
  best = id;
204
193
  bestScore = score;
@@ -226,7 +215,6 @@ function pickNextFillFirstAnthropicAccount(
226
215
  }
227
216
  return ordered[0] ?? null;
228
217
  }
229
- // Skip successors that are also at/above threshold (known drained usage).
230
218
  let fallback: string | null = null;
231
219
  for (let step = 1; step <= stableAll.length; step++) {
232
220
  const candidate = stableAll[(startIdx + step) % stableAll.length]!;
@@ -253,16 +241,6 @@ function pickAlternateAnthropicAccount(
253
241
  return pickLowestUsage(excludeId, now);
254
242
  }
255
243
 
256
- function pruneExpiredAffinity(now: number): void {
257
- for (const [key, entry] of sessionAffinity) {
258
- if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) sessionAffinity.delete(key);
259
- }
260
- if (sessionAffinity.size <= MAX_AFFINITY_ENTRIES) return;
261
- const sorted = [...sessionAffinity.entries()].sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
262
- const drop = sessionAffinity.size - MAX_AFFINITY_ENTRIES;
263
- for (let i = 0; i < drop; i++) sessionAffinity.delete(sorted[i]![0]);
264
- }
265
-
266
244
  export type AnthropicAccountSelectionReason =
267
245
  | "pool-disabled"
268
246
  | "affinity"
@@ -290,15 +268,10 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg
290
268
  function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
291
269
  const threshold = anthropicAutoSwitchThreshold(config);
292
270
  if (threshold <= 0) return true;
293
- // Unknown usage must not force fill-first to abandon the active account.
294
271
  if (!hasKnownUsage(accountId)) return true;
295
- return usageScore(accountId) < threshold;
272
+ return anthropicUsageScore(accountId) < threshold;
296
273
  }
297
274
 
298
- /**
299
- * Fill-first: keep eligible active under threshold; otherwise advance to the next
300
- * eligible id in stable sorted order after the current active (wrapping).
301
- */
302
275
  function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string | null {
303
276
  const eligible = getEligibleAnthropicAccounts(now);
304
277
  if (eligible.length === 0) return null;
@@ -320,33 +293,48 @@ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string |
320
293
  return pickNextFillFirstAnthropicAccount(config, active, eligible);
321
294
  }
322
295
 
323
- /**
324
- * Unbound new-session pick for round-robin / fill-first. Returns null to fall through
325
- * to the legacy quota path (or when the strategy is quota).
326
- */
327
- function pickUnboundStrategyAccount(
296
+ function resolveAnthropicFillFirst(
297
+ sessionKey: string | null | undefined,
328
298
  config: OcxConfig,
299
+ set: NonNullable<ReturnType<typeof getAccountSet>>,
329
300
  now: number,
330
- ): { accountId: string; reason: "round-robin" | "fill-first" } | null {
331
- const strategy = anthropicPoolStrategy(config);
332
- if (strategy === "quota") return null;
301
+ ): AnthropicAccountSelection {
302
+ const key = normalizeAffinityComponent(sessionKey);
303
+ if (key) {
304
+ const affined = getSessionAffinity(POOL_KEY_ANTHROPIC, key, now);
305
+ if (affined) {
306
+ const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
307
+ if (
308
+ stillThere
309
+ && isAnthropicAccountEligible(affined.accountId, now)
310
+ && isPoolCredentialUsable(affined.accountId, now)
311
+ ) {
312
+ touchSessionAffinity(POOL_KEY_ANTHROPIC, key, now);
313
+ return { accountId: affined.accountId, reason: "affinity" };
314
+ }
315
+ clearSessionAffinityForAccount(POOL_KEY_ANTHROPIC, affined.accountId);
316
+ }
317
+ }
333
318
 
334
- if (strategy === "round-robin") {
335
- const eligible = getEligibleAnthropicAccounts(now);
336
- const limit = stickyLimitForPool(config);
337
- const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit);
338
- if (!picked) return null;
339
- notePoolRotationSuccess(POOL_KEY_ANTHROPIC, picked, limit);
340
- return { accountId: picked, reason: "round-robin" };
319
+ if (!key) {
320
+ const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
321
+ && isAnthropicAccountEligible(set.activeAccountId, now)
322
+ && isPoolCredentialUsable(set.activeAccountId, now);
323
+ if (activeOk) {
324
+ return { accountId: set.activeAccountId, reason: "active" };
325
+ }
341
326
  }
342
327
 
343
- if (strategy === "fill-first") {
344
- const picked = pickFillFirstAnthropicAccount(config, now);
345
- if (!picked) return null;
346
- return { accountId: picked, reason: "fill-first" };
328
+ const picked = pickFillFirstAnthropicAccount(config, now);
329
+ if (!picked) {
330
+ const anyCooled = set.accounts.some(a => !isAnthropicAccountEligible(a.id, now));
331
+ return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
347
332
  }
348
333
 
349
- return null;
334
+ if (key && normalizeAffinityComponent(picked)) {
335
+ bindSessionAffinity(POOL_KEY_ANTHROPIC, key, picked, now);
336
+ }
337
+ return { accountId: picked, reason: "fill-first" };
350
338
  }
351
339
 
352
340
  /**
@@ -358,7 +346,6 @@ export function resolveAnthropicAccountForSession(
358
346
  config: OcxConfig,
359
347
  now = Date.now(),
360
348
  ): AnthropicAccountSelection {
361
- pruneExpiredAffinity(now);
362
349
  const set = getAccountSet(PROVIDER);
363
350
  if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
364
351
 
@@ -366,87 +353,27 @@ export function resolveAnthropicAccountForSession(
366
353
  return { accountId: set.activeAccountId, reason: "pool-disabled" };
367
354
  }
368
355
 
369
- const key = normalizeAffinityComponent(sessionKey);
370
- if (key) {
371
- const affined = sessionAffinity.get(key);
372
- if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
373
- const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
374
- if (stillThere && !isCooled(affined.accountId, now) && isPoolCredentialUsable(affined.accountId, now)) {
375
- affined.lastUsedAt = now;
376
- return { accountId: affined.accountId, reason: "affinity" };
377
- }
378
- sessionAffinity.delete(key);
379
- }
356
+ if (anthropicPoolStrategy(config) === "fill-first") {
357
+ return resolveAnthropicFillFirst(sessionKey, config, set, now);
380
358
  }
381
359
 
382
- const strategy = anthropicPoolStrategy(config);
383
- // No session identity (Desktop turns without a sticky key): hold the current
384
- // active under RR/fill-first instead of treating every turn as a new session.
385
- // Round-robin only when there is a real new-session key (or active is unusable).
386
- if (!key && (strategy === "round-robin" || strategy === "fill-first")) {
387
- const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
388
- && !isCooled(set.activeAccountId, now)
389
- && isPoolCredentialUsable(set.activeAccountId, now);
390
- if (activeOk) {
391
- return { accountId: set.activeAccountId, reason: "active" };
392
- }
393
- }
394
-
395
- const strategyPick = pickUnboundStrategyAccount(config, now);
396
- if (strategyPick) {
397
- // Do not promote active here — token validation may still fail. Callers
398
- // (responses/core) promote after getAnthropicPoolAccessToken succeeds.
399
- if (key && normalizeAffinityComponent(strategyPick.accountId)) {
400
- sessionAffinity.set(key, { accountId: strategyPick.accountId, lastUsedAt: now });
401
- pruneExpiredAffinity(now);
402
- }
403
- return { accountId: strategyPick.accountId, reason: strategyPick.reason };
404
- }
405
-
406
- const threshold = anthropicAutoSwitchThreshold(config);
407
- const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
408
- && !isCooled(set.activeAccountId, now)
409
- && isPoolCredentialUsable(set.activeAccountId, now);
410
-
411
- let accountId: string | null = null;
412
- let reason: AnthropicAccountSelectionReason = "none";
413
-
414
- if (threshold > 0) {
415
- // Unknown usage must NOT force a switch away from the healthy active account.
416
- if (activeOk && (!hasKnownUsage(set.activeAccountId) || usageScore(set.activeAccountId) < threshold)) {
417
- accountId = set.activeAccountId;
418
- reason = "active";
419
- } else {
420
- const picked = pickLowestUsage(undefined, now);
421
- if (picked) {
422
- accountId = picked;
423
- reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage";
424
- } else if (activeOk) {
425
- accountId = set.activeAccountId;
426
- reason = "active";
427
- }
428
- }
429
- } else if (activeOk) {
430
- accountId = set.activeAccountId;
431
- reason = "active";
432
- } else {
433
- const picked = pickLowestUsage(set.activeAccountId, now);
434
- if (picked) {
435
- accountId = picked;
436
- reason = "only-eligible";
437
- }
438
- }
439
-
440
- if (!accountId) {
441
- const anyCooled = set.accounts.some(a => isCooled(a.id, now));
442
- return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
443
- }
360
+ const kernelResult = resolvePoolAccount(
361
+ anthropicPoolPlugin,
362
+ sessionKey ?? null,
363
+ {
364
+ strategy: anthropicPoolStrategy(config) === "round-robin" ? "round-robin" : "quota",
365
+ enabled: true,
366
+ activeAccountId: set.activeAccountId,
367
+ stickyLimit: stickyLimitForPool(config),
368
+ autoSwitchThreshold: anthropicAutoSwitchThreshold(config),
369
+ },
370
+ now,
371
+ );
444
372
 
445
- if (key && normalizeAffinityComponent(accountId)) {
446
- sessionAffinity.set(key, { accountId, lastUsedAt: now });
447
- pruneExpiredAffinity(now);
448
- }
449
- return { accountId, reason };
373
+ return {
374
+ accountId: kernelResult.accountId,
375
+ reason: kernelResult.reason as AnthropicAccountSelectionReason,
376
+ };
450
377
  }
451
378
 
452
379
  export function bindAnthropicSessionAffinity(
@@ -454,16 +381,11 @@ export function bindAnthropicSessionAffinity(
454
381
  accountId: string,
455
382
  now = Date.now(),
456
383
  ): void {
457
- const key = normalizeAffinityComponent(sessionKey);
458
- if (!key || !normalizeAffinityComponent(accountId)) return;
459
- sessionAffinity.set(key, { accountId, lastUsedAt: now });
460
- pruneExpiredAffinity(now);
384
+ bindSessionAffinity(POOL_KEY_ANTHROPIC, sessionKey, accountId, now);
461
385
  }
462
386
 
463
387
  export function clearAnthropicSessionAffinityForAccount(accountId: string): void {
464
- for (const [key, entry] of sessionAffinity) {
465
- if (entry.accountId === accountId) sessionAffinity.delete(key);
466
- }
388
+ clearSessionAffinityForAccount(POOL_KEY_ANTHROPIC, accountId);
467
389
  }
468
390
 
469
391
  /**
@@ -480,14 +402,14 @@ export function rotateAnthropicAccountOn429(
480
402
  ): string | null {
481
403
  if (!isAnthropicAccountPoolEnabled(config)) return null;
482
404
 
483
- const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
484
- const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS;
485
- upstreamHealth.set(failedAccountId, {
486
- cooldownUntil: now + cooldownMs,
487
- cooldownSource: parsedRetry ? "retry-after" : "default",
488
- });
489
- sweepExpiredOnWrite(now);
490
- clearAnthropicSessionAffinityForAccount(failedAccountId);
405
+ recordPoolAccountCooldown(
406
+ POOL_KEY_ANTHROPIC,
407
+ failedAccountId,
408
+ "rate_limit",
409
+ retryAfterHeader,
410
+ now,
411
+ );
412
+ clearSessionAffinityForAccount(POOL_KEY_ANTHROPIC, failedAccountId);
491
413
  notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
492
414
 
493
415
  const next = pickAlternateAnthropicAccount(config, failedAccountId, now);
@@ -498,8 +420,7 @@ export function rotateAnthropicAccountOn429(
498
420
 
499
421
  const affinityKey = normalizeAffinityComponent(sessionKey);
500
422
  if (affinityKey && normalizeAffinityComponent(next)) {
501
- sessionAffinity.set(affinityKey, { accountId: next, lastUsedAt: now });
502
- pruneExpiredAffinity(now);
423
+ bindSessionAffinity(POOL_KEY_ANTHROPIC, affinityKey, next, now);
503
424
  }
504
425
  console.warn(
505
426
  `[anthropic-pool] 429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
@@ -517,7 +438,8 @@ export function promoteAnthropicActiveAccount(accountId: string): void {
517
438
  * unbound new session honors the operator-chosen account (Codex parity).
518
439
  */
519
440
  export function resetAnthropicRoutingForManualSelection(accountId: string): void {
520
- sessionAffinity.clear();
441
+ clearAffinityState(POOL_KEY_ANTHROPIC);
442
+ clearResolveState(POOL_KEY_ANTHROPIC);
521
443
  seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
522
444
  }
523
445
 
@@ -578,17 +500,5 @@ export function anthropicSessionKeyFromParts(input: {
578
500
  /** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
579
501
  promptCacheKeyIsSharedCohort?: boolean;
580
502
  }): string | null {
581
- const preferred = (
582
- input.clientThreadId
583
- ?? input.sessionIdHeader
584
- ?? input.threadIdHeader
585
- ?? ""
586
- ).trim();
587
- if (preferred) {
588
- return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
589
- }
590
- if (input.promptCacheKeyIsSharedCohort) return null;
591
- const cacheKey = input.promptCacheKey?.trim() ?? "";
592
- if (!cacheKey) return null;
593
- return cacheKey.length <= 128 ? cacheKey : createHash("sha256").update(cacheKey).digest("hex");
503
+ return buildSessionKeyFromParts(input);
594
504
  }