@yansigit/opencodex 2.31.1 → 2.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/gui/dist/assets/{index-DJDp_XER.js → index-Cxt5fZMP.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/base.ts +6 -0
  5. package/src/adapters/command-code-project-context.ts +377 -0
  6. package/src/adapters/command-code.ts +5 -1
  7. package/src/adapters/cursor/cursor-errors.ts +12 -0
  8. package/src/adapters/cursor/live-transport.ts +21 -0
  9. package/src/adapters/cursor/native-exec-bridge.ts +141 -0
  10. package/src/adapters/cursor/thread-continuity.ts +71 -0
  11. package/src/adapters/cursor.ts +90 -28
  12. package/src/adapters/google-http.ts +12 -2
  13. package/src/adapters/google-wire-compiler.ts +91 -2
  14. package/src/adapters/google.ts +83 -15
  15. package/src/config.ts +2 -0
  16. package/src/generated/compatibility-version.json +57 -25
  17. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  18. package/src/oauth/anthropic-routing.ts +129 -219
  19. package/src/oauth/antigravity-routing.ts +165 -0
  20. package/src/oauth/cursor-routing.ts +252 -0
  21. package/src/oauth/index.ts +3 -0
  22. package/src/providers/cursor-pool.ts +3 -3
  23. package/src/routing/account-pool/affinity.ts +125 -0
  24. package/src/routing/account-pool/cooldown.ts +145 -0
  25. package/src/routing/account-pool/index.ts +45 -0
  26. package/src/routing/account-pool/resolve.ts +356 -0
  27. package/src/routing/account-pool/types.ts +32 -0
  28. package/src/routing/compatibility/behavior.ts +3 -0
  29. package/src/server/management/oauth-account-routes.ts +47 -12
  30. package/src/server/responses/core.ts +400 -72
  31. package/src/types/config.ts +8 -0
  32. package/src/types/provider.ts +7 -0
  33. package/src/types/request.ts +13 -3
  34. package/src/usage/log.ts +4 -0
  35. package/src/web-search/gemini-executor.ts +35 -13
  36. package/src/web-search/index.ts +85 -1
@@ -1,3 +1,14 @@
1
+ import { getAccountSet } from "./store";
2
+ import {
3
+ bindSessionAffinity,
4
+ buildSessionKeyFromParts,
5
+ clearAffinityState,
6
+ clearSessionAffinityForAccount,
7
+ getSessionAffinity,
8
+ normalizeAffinityComponent,
9
+ touchSessionAffinity,
10
+ } from "../routing/account-pool";
11
+
1
12
  /**
2
13
  * Process-local Antigravity account health (cooldowns). Stored in an in-memory
3
14
  * `Map<string, AntigravityAccountHealth>` for the lifetime of this process only —
@@ -5,6 +16,10 @@
5
16
  */
6
17
  export type AntigravityCooldownReason = "rate_limited" | "quota_exhausted" | "geo_blocked";
7
18
 
19
+ const POOL_KEY_ANTIGRAVITY = "google-antigravity";
20
+ /** Short rate-limit stick-wait: retry the same account instead of hopping. Never wait on quota/geo. */
21
+ export const ANTIGRAVITY_STICK_WAIT_MAX_MS = 5_000;
22
+
8
23
  const DEFAULT_RATE_LIMITED_COOLDOWN_MS = 5_000;
9
24
  const MAX_RATE_LIMITED_COOLDOWN_MS = 60_000;
10
25
  const DEFAULT_QUOTA_EXHAUSTED_COOLDOWN_MS = 24 * 60 * 60_000;
@@ -118,6 +133,156 @@ export function clearAntigravityAccountCooldown(accountId: string): void {
118
133
  accountHealth.delete(accountId);
119
134
  }
120
135
 
136
+ /** Test helper: reset process-local cooldown state between cases. */
137
+ export function clearAntigravityRoutingHealthForTests(): void {
138
+ accountHealth.clear();
139
+ }
140
+
141
+ export function isAntigravityRateLimitStickWait(accountId: string, now = Date.now()): boolean {
142
+ const health = getAntigravityAccountCooldown(accountId, now);
143
+ if (!health || health.reason !== "rate_limited") return false;
144
+ const remaining = health.cooldownUntil - now;
145
+ return remaining > 0 && remaining <= ANTIGRAVITY_STICK_WAIT_MAX_MS;
146
+ }
147
+
148
+ /** Milliseconds to wait before retrying the same account on 429, or null when failover should run. */
149
+ export function antigravity429StickWaitMs(accountId: string, now = Date.now()): number | null {
150
+ if (!isAntigravityRateLimitStickWait(accountId, now)) return null;
151
+ const health = getAntigravityAccountCooldown(accountId, now);
152
+ if (!health) return null;
153
+ const remaining = health.cooldownUntil - now;
154
+ return remaining > 0 ? remaining : null;
155
+ }
156
+
157
+ export function isAntigravityAccountEligible(accountId: string, now = Date.now()): boolean {
158
+ if (!isAntigravityAccountInCooldown(accountId, now)) return true;
159
+ return isAntigravityRateLimitStickWait(accountId, now);
160
+ }
161
+
162
+ export function getEligibleAntigravityAccounts(now = Date.now()): string[] {
163
+ const set = getAccountSet(POOL_KEY_ANTIGRAVITY);
164
+ if (!set) return [];
165
+ return set.accounts
166
+ .filter(account => isAntigravityAccountEligible(account.id, now))
167
+ .map(account => account.id);
168
+ }
169
+
170
+ export type AntigravityAccountSelectionReason =
171
+ | "affinity"
172
+ | "active"
173
+ | "failover"
174
+ | "none"
175
+ | "all-cooled";
176
+
177
+ export interface AntigravityAccountSelection {
178
+ accountId: string | null;
179
+ reason: AntigravityAccountSelectionReason;
180
+ }
181
+
182
+ function bindAntigravityAffinityIfPossible(
183
+ sessionKey: string | null | undefined,
184
+ accountId: string,
185
+ now: number,
186
+ ): void {
187
+ bindSessionAffinity(POOL_KEY_ANTIGRAVITY, sessionKey, accountId, now);
188
+ }
189
+
190
+ /**
191
+ * Failover-only account pick: stick bound sessions, default new sessions to the store active
192
+ * account, and hop only when the chosen account is cooled (except short rate-limit stick-wait).
193
+ * Does not call setActiveAccount.
194
+ */
195
+ export function resolveAntigravityAccountForSession(
196
+ sessionKey: string | null | undefined,
197
+ now = Date.now(),
198
+ ): AntigravityAccountSelection {
199
+ const set = getAccountSet(POOL_KEY_ANTIGRAVITY);
200
+ if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
201
+
202
+ const accountIds = set.accounts.map(account => account.id);
203
+ const activeId = set.activeAccountId;
204
+ const key = normalizeAffinityComponent(sessionKey);
205
+
206
+ if (key) {
207
+ const affined = getSessionAffinity(POOL_KEY_ANTIGRAVITY, key, now);
208
+ if (affined) {
209
+ if (isAntigravityAccountEligible(affined.accountId, now)) {
210
+ touchSessionAffinity(POOL_KEY_ANTIGRAVITY, key, now);
211
+ return { accountId: affined.accountId, reason: "affinity" };
212
+ }
213
+ const next = nextAntigravityAccount(accountIds, affined.accountId, now);
214
+ if (next) {
215
+ bindAntigravityAffinityIfPossible(sessionKey, next, now);
216
+ return { accountId: next, reason: "failover" };
217
+ }
218
+ clearSessionAffinityForAccount(POOL_KEY_ANTIGRAVITY, affined.accountId);
219
+ const anyCooled = accountIds.some(id => !isAntigravityAccountEligible(id, now));
220
+ return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
221
+ }
222
+ }
223
+
224
+ if (activeId && isAntigravityAccountEligible(activeId, now)) {
225
+ bindAntigravityAffinityIfPossible(sessionKey, activeId, now);
226
+ return { accountId: activeId, reason: "active" };
227
+ }
228
+
229
+ const next = nextAntigravityAccount(accountIds, activeId, now);
230
+ if (next) {
231
+ bindAntigravityAffinityIfPossible(sessionKey, next, now);
232
+ return { accountId: next, reason: "failover" };
233
+ }
234
+
235
+ const anyCooled = accountIds.some(id => !isAntigravityAccountEligible(id, now));
236
+ return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
237
+ }
238
+
239
+ export function bindAntigravitySessionAffinity(
240
+ sessionKey: string | null | undefined,
241
+ accountId: string,
242
+ now = Date.now(),
243
+ ): void {
244
+ bindAntigravityAffinityIfPossible(sessionKey, accountId, now);
245
+ }
246
+
247
+ /**
248
+ * Pick the next Antigravity account after a rate-limit 429. Cooldown is recorded upstream
249
+ * (google-http). Does not promote the global active account.
250
+ */
251
+ export function rotateAntigravityAccountOn429(
252
+ failedAccountId: string,
253
+ sessionKey: string | null | undefined,
254
+ now = Date.now(),
255
+ ): string | null {
256
+ if (antigravity429StickWaitMs(failedAccountId, now) !== null) return null;
257
+
258
+ const set = getAccountSet(POOL_KEY_ANTIGRAVITY);
259
+ if (!set) return null;
260
+ const accountIds = set.accounts.map(account => account.id);
261
+
262
+ clearSessionAffinityForAccount(POOL_KEY_ANTIGRAVITY, failedAccountId);
263
+
264
+ const next = nextAntigravityAccount(accountIds, failedAccountId, now);
265
+ if (!next) return null;
266
+
267
+ bindAntigravityAffinityIfPossible(sessionKey, next, now);
268
+ return next;
269
+ }
270
+
271
+ /** Test / logout helper. */
272
+ export function clearAntigravityAccountPoolState(): void {
273
+ clearAffinityState(POOL_KEY_ANTIGRAVITY);
274
+ }
275
+
276
+ export function antigravitySessionKeyFromParts(input: {
277
+ sessionIdHeader?: string | null;
278
+ threadIdHeader?: string | null;
279
+ promptCacheKey?: string | null;
280
+ clientThreadId?: string | null;
281
+ promptCacheKeyIsSharedCohort?: boolean;
282
+ }): string | null {
283
+ return buildSessionKeyFromParts(input);
284
+ }
285
+
121
286
  export const ANTIGRAVITY_MISSING_PROJECT_MESSAGE =
122
287
  "Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`).";
123
288
 
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Opt-in Cursor OAuth account pool (shared account-pool kernel).
3
+ *
4
+ * Default OFF. When enabled with ≥2 eligible OAuth accounts:
5
+ * - Sticky session affinity keyed by `_clientThreadId` (not prompt_cache_key)
6
+ * - Bounded 429/auth failover (cap 3; pre-commit only in core.ts)
7
+ * - No new-session spreading (failover-only, like Antigravity)
8
+ *
9
+ * Does not instantiate or wire `CursorCredentialRouter` weighted RR (#2334).
10
+ * Kernel must not call setActiveAccount — callers bind `_cursorIdentityScope` only.
11
+ */
12
+ import { fallbackCodexAccountLogLabel } from "../codex/account-label";
13
+ import {
14
+ ACCOUNT_POOL_MAX_FAILOVERS,
15
+ bindSessionAffinity,
16
+ clearAccountPoolState,
17
+ clearSessionAffinityForAccount,
18
+ getSessionAffinity,
19
+ isAccountPoolEligible,
20
+ isRateLimitStickWait,
21
+ normalizeAffinityComponent,
22
+ recordPoolAccountCooldown,
23
+ rotatePoolAccountOn429,
24
+ rotatePoolAccountOnAuth,
25
+ touchSessionAffinity,
26
+ type AccountPoolPlugin,
27
+ } from "../routing/account-pool";
28
+ import type { OcxConfig } from "../types";
29
+ import { getAccountSet } from "./store";
30
+
31
+ export const POOL_KEY_CURSOR = "cursor";
32
+ export const CURSOR_POOL_MAX_FAILOVERS_PER_REQUEST = ACCOUNT_POOL_MAX_FAILOVERS;
33
+
34
+ export interface CursorAccountPoolConfig {
35
+ enabled?: boolean;
36
+ }
37
+
38
+ const cursorPoolPlugin: AccountPoolPlugin = {
39
+ poolKey: POOL_KEY_CURSOR,
40
+ sessionKeyFromRequest: cursorSessionKeyFromParts,
41
+ listEligibleAccountIds(now) {
42
+ return getEligibleCursorAccountIds(now);
43
+ },
44
+ };
45
+
46
+ export function cursorAccountPoolConfig(config: OcxConfig): CursorAccountPoolConfig {
47
+ const raw = config.cursorAccountPool;
48
+ if (!raw || typeof raw !== "object") return {};
49
+ return raw;
50
+ }
51
+
52
+ export function isCursorAccountPoolEnabled(config: OcxConfig): boolean {
53
+ return cursorAccountPoolConfig(config).enabled === true;
54
+ }
55
+
56
+ /** Pool is active only when explicitly enabled and at least two OAuth accounts exist. */
57
+ export function isCursorAccountPoolActive(config: OcxConfig, _now = Date.now()): boolean {
58
+ if (!isCursorAccountPoolEnabled(config)) return false;
59
+ const set = getAccountSet(POOL_KEY_CURSOR);
60
+ if (!set) return false;
61
+ return set.accounts.filter(account => account.needsReauth !== true).length >= 2;
62
+ }
63
+
64
+ /**
65
+ * Sticky key for Cursor OAuth pooling — `_clientThreadId` only (not prompt_cache_key).
66
+ */
67
+ export function cursorSessionKeyFromParts(input: {
68
+ clientThreadId?: string | null;
69
+ }): string | null {
70
+ const key = normalizeAffinityComponent(input.clientThreadId);
71
+ return key || null;
72
+ }
73
+
74
+ function isCursorAccountEligible(accountId: string, now: number): boolean {
75
+ return isAccountPoolEligible(POOL_KEY_CURSOR, accountId, now, {
76
+ allowStickWait: isRateLimitStickWait(POOL_KEY_CURSOR, accountId, now),
77
+ });
78
+ }
79
+
80
+ function getEligibleCursorAccountIds(now: number): string[] {
81
+ const set = getAccountSet(POOL_KEY_CURSOR);
82
+ if (!set) return [];
83
+ return set.accounts
84
+ .filter(account => account.needsReauth !== true && isCursorAccountEligible(account.id, now))
85
+ .map(account => account.id);
86
+ }
87
+
88
+ function nextCursorAccount(
89
+ accountIds: readonly string[],
90
+ afterId: string | undefined,
91
+ now: number,
92
+ ): string | undefined {
93
+ if (accountIds.length === 0) return undefined;
94
+ const activeIndex = afterId === undefined ? -1 : accountIds.indexOf(afterId);
95
+ const startIndex = activeIndex < 0 ? 0 : activeIndex + 1;
96
+ for (let offset = 0; offset < accountIds.length; offset += 1) {
97
+ const accountId = accountIds[(startIndex + offset) % accountIds.length]!;
98
+ if (afterId !== undefined && accountId === afterId) continue;
99
+ if (isCursorAccountEligible(accountId, now)) return accountId;
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ function bindCursorAffinityIfPossible(
105
+ sessionKey: string | null | undefined,
106
+ accountId: string,
107
+ now: number,
108
+ ): void {
109
+ bindSessionAffinity(POOL_KEY_CURSOR, sessionKey, accountId, now);
110
+ }
111
+
112
+ export type CursorAccountSelectionReason =
113
+ | "affinity"
114
+ | "active"
115
+ | "failover"
116
+ | "none"
117
+ | "all-cooled"
118
+ | "pool-disabled";
119
+
120
+ export interface CursorAccountSelection {
121
+ accountId: string | null;
122
+ reason: CursorAccountSelectionReason;
123
+ }
124
+
125
+ /**
126
+ * Resolve which Cursor OAuth account should serve this session.
127
+ * When the pool is inactive, returns the store active account.
128
+ */
129
+ export function resolveCursorAccountForSession(
130
+ sessionKey: string | null | undefined,
131
+ config: OcxConfig,
132
+ now = Date.now(),
133
+ ): CursorAccountSelection {
134
+ const set = getAccountSet(POOL_KEY_CURSOR);
135
+ if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
136
+
137
+ if (!isCursorAccountPoolActive(config, now)) {
138
+ return { accountId: set.activeAccountId, reason: "pool-disabled" };
139
+ }
140
+
141
+ const accountIds = getEligibleCursorAccountIds(now);
142
+ const activeId = set.activeAccountId;
143
+ const key = normalizeAffinityComponent(sessionKey);
144
+
145
+ if (key) {
146
+ const affined = getSessionAffinity(POOL_KEY_CURSOR, key, now);
147
+ if (affined && isCursorAccountEligible(affined.accountId, now)) {
148
+ touchSessionAffinity(POOL_KEY_CURSOR, key, now);
149
+ return { accountId: affined.accountId, reason: "affinity" };
150
+ }
151
+ if (affined) {
152
+ const next = nextCursorAccount(accountIds, affined.accountId, now);
153
+ if (next) {
154
+ bindCursorAffinityIfPossible(sessionKey, next, now);
155
+ return { accountId: next, reason: "failover" };
156
+ }
157
+ clearSessionAffinityForAccount(POOL_KEY_CURSOR, affined.accountId);
158
+ }
159
+ }
160
+
161
+ if (activeId && accountIds.includes(activeId)) {
162
+ bindCursorAffinityIfPossible(sessionKey, activeId, now);
163
+ return { accountId: activeId, reason: "active" };
164
+ }
165
+
166
+ const next = nextCursorAccount(accountIds, activeId, now);
167
+ if (next) {
168
+ bindCursorAffinityIfPossible(sessionKey, next, now);
169
+ return { accountId: next, reason: "failover" };
170
+ }
171
+
172
+ const anyCooled = set.accounts.some(account => !isCursorAccountEligible(account.id, now));
173
+ return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
174
+ }
175
+
176
+ export function bindCursorSessionAffinity(
177
+ sessionKey: string | null | undefined,
178
+ accountId: string,
179
+ now = Date.now(),
180
+ ): void {
181
+ bindCursorAffinityIfPossible(sessionKey, accountId, now);
182
+ }
183
+
184
+ export function rotateCursorAccountOn429(
185
+ config: OcxConfig,
186
+ failedAccountId: string,
187
+ retryAfterHeader: string | null | undefined,
188
+ sessionKey?: string | null,
189
+ now = Date.now(),
190
+ ): string | null {
191
+ if (!isCursorAccountPoolActive(config, now)) return null;
192
+ const next = rotatePoolAccountOn429(
193
+ cursorPoolPlugin,
194
+ failedAccountId,
195
+ sessionKey ?? null,
196
+ retryAfterHeader ?? null,
197
+ now,
198
+ );
199
+ if (next) {
200
+ console.warn(
201
+ `[cursor-pool] 429 on ${formatCursorAccountOrdinal(failedAccountId)}; failing over to ${formatCursorAccountOrdinal(next)}`,
202
+ );
203
+ }
204
+ return next;
205
+ }
206
+
207
+ export function rotateCursorAccountOnAuth(
208
+ config: OcxConfig,
209
+ failedAccountId: string,
210
+ sessionKey: string | null | undefined,
211
+ now = Date.now(),
212
+ ): string | null {
213
+ if (!isCursorAccountPoolActive(config, now)) return null;
214
+ const next = rotatePoolAccountOnAuth(
215
+ cursorPoolPlugin,
216
+ failedAccountId,
217
+ sessionKey ?? null,
218
+ now,
219
+ );
220
+ if (next) {
221
+ console.warn(
222
+ `[cursor-pool] auth failure on ${formatCursorAccountOrdinal(failedAccountId)}; failing over to ${formatCursorAccountOrdinal(next)}`,
223
+ );
224
+ }
225
+ return next;
226
+ }
227
+
228
+ /** Billing exhaustion — long cooldown; must not enter the 429 hop carousel. */
229
+ export function recordCursorAccountBillingCooldown(
230
+ accountId: string,
231
+ retryAfterHeader: string | null | undefined,
232
+ now = Date.now(),
233
+ ): void {
234
+ recordPoolAccountCooldown(POOL_KEY_CURSOR, accountId, "billing", retryAfterHeader, now);
235
+ }
236
+
237
+ /** Test / logout helper. */
238
+ export function clearCursorAccountPoolState(): void {
239
+ clearAccountPoolState(POOL_KEY_CURSOR);
240
+ }
241
+
242
+ export function formatCursorAccountOrdinal(accountId: string): string {
243
+ return fallbackCodexAccountLogLabel(accountId);
244
+ }
245
+
246
+ export function formatCursorProviderForLog(
247
+ providerName: string,
248
+ accountId: string | null | undefined,
249
+ ): string {
250
+ if (!accountId) return providerName;
251
+ return `${providerName}-${formatCursorAccountOrdinal(accountId)}`;
252
+ }
@@ -1073,6 +1073,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1073
1073
  if (existing?.commandCodeVersion !== undefined) {
1074
1074
  next.commandCodeVersion = existing.commandCodeVersion;
1075
1075
  }
1076
+ if (existing?.projectContext !== undefined) {
1077
+ next.projectContext = existing.projectContext;
1078
+ }
1076
1079
  // User-configured price overlays are operator data, not preset state; a
1077
1080
  // re-login, add-account, or reauth must not silently drop them from the
1078
1081
  // Logs/Usage estimates.
@@ -5,9 +5,9 @@
5
5
  * weighted round-robin selection with per-credential auth-failure cooldown
6
6
  * and one-retry failover on a different account before surfacing the error.
7
7
  *
8
- * OpenCodex already has JWT-based multi-account identification (src/oauth/cursor.ts)
9
- * and Anthropic-specific 429 rotation; this module adds Cursor-aware weighted
10
- * routing on top of those primitives.
8
+ * **Not wired in opencodex.** Session-pinned OAuth pooling uses
9
+ * `src/oauth/cursor-routing.ts` on the shared account-pool kernel instead (#2334).
10
+ * This module remains for unit tests only until removed.
11
11
  */
12
12
 
13
13
  export interface CursorCredential {
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+ import { retainedUtf8Bytes } from "../../lib/admission";
3
+
4
+ export const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
5
+ export const MAX_AFFINITY_ENTRIES = 2_000;
6
+ export const MAX_AFFINITY_COMPONENT_BYTES = 512;
7
+
8
+ interface AffinityEntry {
9
+ accountId: string;
10
+ lastUsedAt: number;
11
+ }
12
+
13
+ const affinityByPool = new Map<string, Map<string, AffinityEntry>>();
14
+
15
+ export function normalizeAffinityComponent(value: string | null | undefined): string {
16
+ const normalized = value?.trim() ?? "";
17
+ return normalized && retainedUtf8Bytes(normalized) <= MAX_AFFINITY_COMPONENT_BYTES ? normalized : "";
18
+ }
19
+
20
+ /**
21
+ * Build a sticky session key from request headers.
22
+ * Prefer true session/thread ids; ignore Desktop shared cache-cohort prompt_cache_key.
23
+ */
24
+ export function buildSessionKeyFromParts(input: {
25
+ sessionIdHeader?: string | null;
26
+ threadIdHeader?: string | null;
27
+ clientThreadId?: string | null;
28
+ promptCacheKey?: string | null;
29
+ promptCacheKeyIsSharedCohort?: boolean;
30
+ }): string | null {
31
+ const preferred = (
32
+ input.clientThreadId
33
+ ?? input.sessionIdHeader
34
+ ?? input.threadIdHeader
35
+ ?? ""
36
+ ).trim();
37
+ if (preferred) {
38
+ return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
39
+ }
40
+ if (input.promptCacheKeyIsSharedCohort) return null;
41
+ const cacheKey = input.promptCacheKey?.trim() ?? "";
42
+ if (!cacheKey) return null;
43
+ return cacheKey.length <= 128 ? cacheKey : createHash("sha256").update(cacheKey).digest("hex");
44
+ }
45
+
46
+ function getPoolMap(poolKey: string): Map<string, AffinityEntry> {
47
+ let map = affinityByPool.get(poolKey);
48
+ if (!map) {
49
+ map = new Map();
50
+ affinityByPool.set(poolKey, map);
51
+ }
52
+ return map;
53
+ }
54
+
55
+ function pruneExpiredAffinity(poolKey: string, now: number): void {
56
+ const map = affinityByPool.get(poolKey);
57
+ if (!map) return;
58
+ for (const [key, entry] of map) {
59
+ if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) map.delete(key);
60
+ }
61
+ if (map.size <= MAX_AFFINITY_ENTRIES) return;
62
+ const sorted = [...map.entries()].sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
63
+ const drop = map.size - MAX_AFFINITY_ENTRIES;
64
+ for (let i = 0; i < drop; i++) map.delete(sorted[i]![0]);
65
+ }
66
+
67
+ export function getSessionAffinity(
68
+ poolKey: string,
69
+ sessionKey: string | null | undefined,
70
+ now: number,
71
+ ): AffinityEntry | null {
72
+ pruneExpiredAffinity(poolKey, now);
73
+ const key = normalizeAffinityComponent(sessionKey);
74
+ if (!key) return null;
75
+ const entry = getPoolMap(poolKey).get(key);
76
+ if (!entry) return null;
77
+ if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) {
78
+ getPoolMap(poolKey).delete(key);
79
+ return null;
80
+ }
81
+ return entry;
82
+ }
83
+
84
+ export function bindSessionAffinity(
85
+ poolKey: string,
86
+ sessionKey: string | null | undefined,
87
+ accountId: string,
88
+ now: number,
89
+ ): void {
90
+ const key = normalizeAffinityComponent(sessionKey);
91
+ if (!key || !normalizeAffinityComponent(accountId)) return;
92
+ getPoolMap(poolKey).set(key, { accountId, lastUsedAt: now });
93
+ pruneExpiredAffinity(poolKey, now);
94
+ }
95
+
96
+ export function touchSessionAffinity(
97
+ poolKey: string,
98
+ sessionKey: string | null | undefined,
99
+ now: number,
100
+ ): void {
101
+ const key = normalizeAffinityComponent(sessionKey);
102
+ if (!key) return;
103
+ const entry = getPoolMap(poolKey).get(key);
104
+ if (entry) entry.lastUsedAt = now;
105
+ }
106
+
107
+ export function clearSessionAffinityForAccount(poolKey: string, accountId: string): void {
108
+ const map = affinityByPool.get(poolKey);
109
+ if (!map) return;
110
+ for (const [key, entry] of map) {
111
+ if (entry.accountId === accountId) map.delete(key);
112
+ }
113
+ }
114
+
115
+ export function clearAffinityState(poolKey?: string): void {
116
+ if (poolKey === undefined) {
117
+ affinityByPool.clear();
118
+ return;
119
+ }
120
+ affinityByPool.delete(poolKey);
121
+ }
122
+
123
+ export function affinitySizeForTests(poolKey: string): number {
124
+ return affinityByPool.get(poolKey)?.size ?? 0;
125
+ }