@oh-my-pi/pi-ai 18.2.1 → 18.2.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.
@@ -131,15 +131,37 @@ function deriveSessionId(modelId: string, context: Context): string {
131
131
  }
132
132
 
133
133
  /**
134
- * Resolve the logical session identity for one request. A client-supplied key
135
- * wins so external session ids line up with the gateway's, but a blank one
136
- * counts as absent: honouring it would collapse every caller that sends an
137
- * empty key into one shared credential-sticky, prefix-cache and
138
- * provider-session bucket.
134
+ * The client's own session key, or `undefined` when it sent none. A blank key
135
+ * counts as none: honouring it would collapse every caller that sends an empty
136
+ * key into one shared credential-sticky, prefix-cache and provider-session
137
+ * bucket.
139
138
  */
140
- function resolveSessionId(clientKey: string | undefined, modelId: string, context: Context): string {
141
- if (clientKey !== undefined && clientKey.trim().length > 0) return clientKey;
142
- return deriveSessionId(modelId, context);
139
+ function normalizeClientSessionKey(clientKey: string | undefined): string | undefined {
140
+ return clientKey !== undefined && clientKey.trim().length > 0 ? clientKey : undefined;
141
+ }
142
+
143
+ /**
144
+ * Stable identity of the account a request's credential belongs to.
145
+ *
146
+ * `markUsageLimitReached` and the auth-retry resolver switch a session to a
147
+ * sibling credential, so the provider state retained for that session can
148
+ * outlive the account that taught it. OAuth rows expose an account id / email
149
+ * that survives token refresh — fingerprinting the bearer instead would look
150
+ * like a rotation every time a token refreshes and discard the retained
151
+ * lessons for nothing. Key-based rows fall back to a hash of the key, never
152
+ * the key itself: this value is held for the lifetime of the entry.
153
+ */
154
+ function resolveGatewayAccount(storage: AuthStorage, provider: string, sessionId: string, apiKey: string): string {
155
+ const identity = storage.getOAuthAccountIdentity(provider, sessionId);
156
+ if (identity) {
157
+ return `oauth:${JSON.stringify([
158
+ identity.accountId ?? "",
159
+ identity.email ?? "",
160
+ identity.projectId ?? "",
161
+ identity.orgId ?? "",
162
+ ])}`;
163
+ }
164
+ return `key:${Bun.hash(apiKey).toString(36)}`;
143
165
  }
144
166
 
145
167
  function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
@@ -182,7 +204,8 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
182
204
  // Client-supplied `prompt_cache_key` wins; otherwise derive a stable
183
205
  // key from the model + system + tools so prefix caching engages on
184
206
  // Codex-class backends across turns of the same logical conversation.
185
- const promptCacheKey = resolveSessionId(options.promptCacheKey, parsed.modelId, parsed.context);
207
+ const promptCacheKey =
208
+ normalizeClientSessionKey(options.promptCacheKey) ?? deriveSessionId(parsed.modelId, parsed.context);
186
209
  opts.promptCacheKey = promptCacheKey;
187
210
  opts.sessionId = promptCacheKey;
188
211
  if (options.thinkingBudgets) {
@@ -314,6 +337,7 @@ function buildGatewayApiKeyResolver(
314
337
  requestSignal: AbortSignal,
315
338
  format: string,
316
339
  peer: string,
340
+ onResolvedKey: (apiKey: string) => void,
317
341
  ): ApiKeyResolver {
318
342
  let lastKey = initialKey;
319
343
  return async ({ lastChance, error, signal }) => {
@@ -329,6 +353,7 @@ function buildGatewayApiKeyResolver(
329
353
  forceRefresh: true,
330
354
  });
331
355
  lastKey = refreshed ?? lastKey;
356
+ if (refreshed) onResolvedKey(refreshed);
332
357
  return refreshed;
333
358
  }
334
359
  const next = await refreshGatewayApiKeyAfterAuthError(
@@ -343,6 +368,7 @@ function buildGatewayApiKeyResolver(
343
368
  peer,
344
369
  );
345
370
  lastKey = next ?? lastKey;
371
+ if (next) onResolvedKey(next);
346
372
  return next;
347
373
  };
348
374
  }
@@ -476,7 +502,8 @@ async function handleFormatEndpoint(
476
502
  // supplied (so external session ids align), otherwise derive from
477
503
  // modelId + system + tools + first message. Mirrored into
478
504
  // streamOpts.sessionId / promptCacheKey by `buildStreamOptions`.
479
- const sessionId = resolveSessionId(parsed.options.promptCacheKey, parsed.modelId, parsed.context);
505
+ const clientKey = normalizeClientSessionKey(parsed.options.promptCacheKey);
506
+ const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
480
507
  parsed.options.promptCacheKey = sessionId;
481
508
 
482
509
  // pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
@@ -505,6 +532,19 @@ async function handleFormatEndpoint(
505
532
  }
506
533
 
507
534
  const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
535
+ // Per-session provider learning (sticky strict-tools / fast-mode / thinking
536
+ // fallbacks, Codex transport sessions). Owned by this gateway instance: the
537
+ // map is non-serializable, so no client can supply it and every turn would
538
+ // otherwise re-learn each lesson from a fresh upstream rejection. The lease
539
+ // keeps the entry out of reach of eviction until this request is done with
540
+ // it, so it MUST be released on every exit path.
541
+ const lease = sessionStates.acquire({
542
+ clientKey,
543
+ model,
544
+ context: parsed.context,
545
+ account: resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, apiKey),
546
+ });
547
+ streamOpts.providerSessionState = lease.states;
508
548
  streamOpts.apiKey = buildGatewayApiKeyResolver(
509
549
  bootOpts.storage,
510
550
  model,
@@ -513,12 +553,9 @@ async function handleFormatEndpoint(
513
553
  controller.signal,
514
554
  route.label,
515
555
  peer,
556
+ resolvedKey =>
557
+ lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
516
558
  );
517
- // Per-session provider learning (sticky strict-tools / fast-mode / thinking
518
- // fallbacks, Codex transport sessions). Owned by this gateway instance: the
519
- // map is non-serializable, so no client can supply it and every turn would
520
- // otherwise re-learn each lesson from a fresh upstream rejection.
521
- streamOpts.providerSessionState = sessionStates.acquire(sessionId, model);
522
559
 
523
560
  logger.info("auth-gateway request", {
524
561
  requestId,
@@ -565,45 +602,59 @@ async function handleFormatEndpoint(
565
602
  peer,
566
603
  });
567
604
  return route.module.formatError(classified.status, classified.type, classified.message);
605
+ } finally {
606
+ // Every non-streaming outcome — answered, upstream error, thrown,
607
+ // client gone — is done with the provider state here.
608
+ lease.release();
568
609
  }
569
610
  }
570
611
 
571
- let events: AssistantMessageEventStream;
612
+ // A streamed turn outlives this function, so the lease travels with the
613
+ // event stream and is released when the turn settles. Until that handoff
614
+ // happens, the `finally` below owns it.
615
+ let streamOwnsLease = false;
572
616
  try {
617
+ let events: AssistantMessageEventStream;
618
+ try {
619
+ if (controller.signal.aborted) return clientClosedResponse(route);
620
+ events = streamSimple(model, parsed.context, streamOpts);
621
+ } catch (error) {
622
+ const classified = classifyGatewayError(error);
623
+ logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
624
+ return route.module.formatError(classified.status, classified.type, classified.message);
625
+ }
573
626
  if (controller.signal.aborted) return clientClosedResponse(route);
574
- events = streamSimple(model, parsed.context, streamOpts);
575
- } catch (error) {
576
- const classified = classifyGatewayError(error);
577
- logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
578
- return route.module.formatError(classified.status, classified.type, classified.message);
579
- }
580
- if (controller.signal.aborted) return clientClosedResponse(route);
581
- void events
582
- .result()
583
- .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
584
- .catch(() => {});
627
+ void events
628
+ .result()
629
+ .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
630
+ .catch(() => {})
631
+ .finally(() => lease.release());
632
+ streamOwnsLease = true;
585
633
 
586
- const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options, {
587
- signal: controller.signal,
588
- onCancel: reason => {
589
- if (!controller.signal.aborted) {
590
- controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
591
- }
592
- },
593
- });
594
- return new Response(sseStream, {
595
- status: 200,
596
- headers: {
597
- ...gatewayResponseHeaders(model, { requestId }),
598
- "Content-Type": "text/event-stream; charset=utf-8",
599
- "Cache-Control": "no-cache",
600
- Connection: "keep-alive",
601
- // Disable proxy buffering (nginx and ingress controllers honor this).
602
- // Without it the SSE stream gets held until the buffer flushes, which
603
- // stalls the long-thinking-budget calls we exist to support.
604
- "X-Accel-Buffering": "no",
605
- },
606
- });
634
+ const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options, {
635
+ signal: controller.signal,
636
+ onCancel: reason => {
637
+ if (!controller.signal.aborted) {
638
+ controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
639
+ }
640
+ },
641
+ });
642
+ return new Response(sseStream, {
643
+ status: 200,
644
+ headers: {
645
+ ...gatewayResponseHeaders(model, { requestId }),
646
+ "Content-Type": "text/event-stream; charset=utf-8",
647
+ "Cache-Control": "no-cache",
648
+ Connection: "keep-alive",
649
+ // Disable proxy buffering (nginx and ingress controllers honor this).
650
+ // Without it the SSE stream gets held until the buffer flushes, which
651
+ // stalls the long-thinking-budget calls we exist to support.
652
+ "X-Accel-Buffering": "no",
653
+ },
654
+ });
655
+ } finally {
656
+ if (!streamOwnsLease) lease.release();
657
+ }
607
658
  }
608
659
 
609
660
  /**
@@ -660,7 +711,8 @@ async function handlePiNative(
660
711
  // up with cache-prefix stickiness — same identity used for both means
661
712
  // the next turn of this conversation reuses the same credential until
662
713
  // it hits a usage cap, then markUsageLimitReached can hand off.
663
- const sessionId = resolveSessionId(parsed.options.sessionId, parsed.modelId, parsed.context);
714
+ const clientKey = normalizeClientSessionKey(parsed.options.sessionId);
715
+ const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
664
716
  parsed.options.sessionId = sessionId;
665
717
 
666
718
  let apiKey: string | undefined;
@@ -684,6 +736,17 @@ async function handlePiNative(
684
736
  );
685
737
  }
686
738
 
739
+ // Per-session provider learning, owned by this gateway instance. The map is
740
+ // non-serializable, so `parseRequest` cannot accept one from the wire and
741
+ // every turn would otherwise re-learn each lesson from a fresh upstream
742
+ // rejection. The lease keeps the entry out of reach of eviction until this
743
+ // request is done with it, so it MUST be released on every exit path.
744
+ const lease = sessionStates.acquire({
745
+ clientKey,
746
+ model,
747
+ context: parsed.context,
748
+ account: resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, apiKey),
749
+ });
687
750
  // Build the SimpleStreamOptions actually handed to `streamSimple`. We
688
751
  // trust the client's options (already allow-listed by `parseRequest`) and
689
752
  // only inject server-controlled fields. The codex sampling strip mirrors
@@ -693,11 +756,7 @@ async function handlePiNative(
693
756
  apiKey,
694
757
  signal: controller.signal,
695
758
  cursorExternalToolExecutor: true,
696
- // Per-session provider learning, owned by this gateway instance. The map
697
- // is non-serializable, so `parseRequest` cannot accept one from the wire
698
- // and every turn would otherwise re-learn each lesson from a fresh
699
- // upstream rejection.
700
- providerSessionState: sessionStates.acquire(sessionId, model),
759
+ providerSessionState: lease.states,
701
760
  };
702
761
  streamOpts.apiKey = buildGatewayApiKeyResolver(
703
762
  bootOpts.storage,
@@ -707,6 +766,8 @@ async function handlePiNative(
707
766
  controller.signal,
708
767
  "pi-native",
709
768
  peer,
769
+ resolvedKey =>
770
+ lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
710
771
  );
711
772
  if (model.api === "openai-codex-responses") {
712
773
  delete streamOpts.temperature;
@@ -761,42 +822,56 @@ async function handlePiNative(
761
822
  const classified = classifyGatewayError(error);
762
823
  logger.warn("auth-gateway non-streaming aborted", { format: "pi-native", error: classified.message, peer });
763
824
  return piNative.formatError(classified.status, classified.type, classified.message);
825
+ } finally {
826
+ // Every non-streaming outcome — answered, upstream error, thrown,
827
+ // client gone — is done with the provider state here.
828
+ lease.release();
764
829
  }
765
830
  }
766
831
 
767
- let events: AssistantMessageEventStream;
832
+ // A streamed turn outlives this function, so the lease travels with the
833
+ // event stream and is released when the turn settles. Until that handoff
834
+ // happens, the `finally` below owns it.
835
+ let streamOwnsLease = false;
768
836
  try {
837
+ let events: AssistantMessageEventStream;
838
+ try {
839
+ if (controller.signal.aborted) return aborted();
840
+ events = streamSimple(model, parsed.context, streamOpts);
841
+ } catch (error) {
842
+ const classified = classifyGatewayError(error);
843
+ logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
844
+ return piNative.formatError(classified.status, classified.type, classified.message);
845
+ }
769
846
  if (controller.signal.aborted) return aborted();
770
- events = streamSimple(model, parsed.context, streamOpts);
771
- } catch (error) {
772
- const classified = classifyGatewayError(error);
773
- logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
774
- return piNative.formatError(classified.status, classified.type, classified.message);
775
- }
776
- if (controller.signal.aborted) return aborted();
777
- void events
778
- .result()
779
- .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
780
- .catch(() => {});
847
+ void events
848
+ .result()
849
+ .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
850
+ .catch(() => {})
851
+ .finally(() => lease.release());
852
+ streamOwnsLease = true;
781
853
 
782
- const sseStream = piNative.encodeStream(events, parsed.modelId, parsed.options, {
783
- signal: controller.signal,
784
- onCancel: reason => {
785
- if (!controller.signal.aborted) {
786
- controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
787
- }
788
- },
789
- });
790
- return new Response(sseStream, {
791
- status: 200,
792
- headers: {
793
- ...gatewayResponseHeaders(model, { requestId }),
794
- "Content-Type": "text/event-stream; charset=utf-8",
795
- "Cache-Control": "no-cache",
796
- Connection: "keep-alive",
797
- "X-Accel-Buffering": "no",
798
- },
799
- });
854
+ const sseStream = piNative.encodeStream(events, parsed.modelId, parsed.options, {
855
+ signal: controller.signal,
856
+ onCancel: reason => {
857
+ if (!controller.signal.aborted) {
858
+ controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
859
+ }
860
+ },
861
+ });
862
+ return new Response(sseStream, {
863
+ status: 200,
864
+ headers: {
865
+ ...gatewayResponseHeaders(model, { requestId }),
866
+ "Content-Type": "text/event-stream; charset=utf-8",
867
+ "Cache-Control": "no-cache",
868
+ Connection: "keep-alive",
869
+ "X-Accel-Buffering": "no",
870
+ },
871
+ });
872
+ } finally {
873
+ if (!streamOwnsLease) lease.release();
874
+ }
800
875
  }
801
876
 
802
877
  /**
@@ -19,12 +19,14 @@
19
19
  * A plain `Map<sessionId, …>` in a long-lived server process is a leak: nothing
20
20
  * ever reclaims an entry, and the entries own timers and sockets. This store is
21
21
  * an LRU with a hard entry ceiling that calls `close()` on everything it drops
22
- * and on everything it still holds at shutdown.
22
+ * and on everything it still holds at shutdown — but it only ever drops an
23
+ * entry no request is holding, because `close()` on a live entry tears down
24
+ * state an in-flight stream is still streaming through.
23
25
  */
24
26
 
25
27
  import { logger } from "@oh-my-pi/pi-utils";
26
- import { type DisposeReason, LRUCache } from "@oh-my-pi/pi-utils/lru";
27
- import type { Api, Model, ProviderSessionState } from "../types";
28
+ import { resetAccountScopedProviderSessionState } from "../provider-session-state";
29
+ import type { Api, Context, Model, ProviderSessionState } from "../types";
28
30
 
29
31
  /**
30
32
  * Retained logical sessions. Each entry is a handful of small provider records
@@ -35,6 +37,61 @@ import type { Api, Model, ProviderSessionState } from "../types";
35
37
  */
36
38
  export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
37
39
 
40
+ /** Why an entry's provider records were closed. Logged on teardown failure. */
41
+ type SessionDisposeReason = "evict" | "shutdown";
42
+
43
+ /**
44
+ * One request's claim on a retained session.
45
+ *
46
+ * `release()` is what makes the entry evictable again, so it MUST run for every
47
+ * outcome of the request — a `finally` at the call site for the synchronous
48
+ * paths, stream completion for the streaming ones. It is idempotent, so the
49
+ * two can overlap.
50
+ */
51
+ export interface AuthGatewaySessionStateLease {
52
+ /** The map to hand to `streamSimple` as `providerSessionState`. */
53
+ readonly states: Map<string, ProviderSessionState>;
54
+ /** Reset account-scoped records if an in-request auth retry switches accounts. */
55
+ updateAccount(account: string): void;
56
+ /** Give up this request's claim. Idempotent. */
57
+ release(): void;
58
+ }
59
+
60
+ /** Everything the store needs to place one request on a retained session. */
61
+ export interface AuthGatewaySessionStateRequest {
62
+ /**
63
+ * The client's own session key (`prompt_cache_key` / `sessionId`), or
64
+ * `undefined` when it sent none — blank counts as none. A supplied key is
65
+ * authoritative: the client is telling us which conversation this is.
66
+ */
67
+ clientKey: string | undefined;
68
+ model: Model<Api>;
69
+ /**
70
+ * System prompt, tools and message history of this request. Used only when
71
+ * `clientKey` is absent, to place the request on the conversation it
72
+ * continues.
73
+ */
74
+ context: Context;
75
+ /**
76
+ * Stable identity of the account this request's credential resolved to.
77
+ * A change means the gateway switched the session to a sibling credential,
78
+ * so the account-dependent lessons in the retained map are re-probed. The
79
+ * comparison happens on acquire and whenever an in-request auth retry
80
+ * resolves a sibling credential.
81
+ */
82
+ account: string;
83
+ }
84
+
85
+ interface RetainedSession {
86
+ /** Current index key. Advances as a keyless conversation grows. */
87
+ key: string;
88
+ states: Map<string, ProviderSessionState>;
89
+ /** Account identity of the most recent request placed on this entry. */
90
+ account: string;
91
+ /** Requests currently holding this entry. Eviction never takes one of these. */
92
+ leases: number;
93
+ }
94
+
38
95
  /**
39
96
  * Close every provider record an evicted (or drained) session held.
40
97
  *
@@ -42,15 +99,18 @@ export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
42
99
  * and GitLab Duo's stops the server-side workflow — so dropping an entry
43
100
  * without closing it leaks exactly the resources the bound exists to cap.
44
101
  */
45
- function closeSessionState(states: Map<string, ProviderSessionState>, sessionKey: string, reason: DisposeReason): void {
102
+ function closeSessionState(
103
+ states: Map<string, ProviderSessionState>,
104
+ sessionKey: string,
105
+ reason: SessionDisposeReason,
106
+ ): void {
46
107
  for (const [providerKey, state] of states) {
47
108
  try {
48
109
  state.close();
49
110
  } catch (error) {
50
111
  // One provider's teardown must not abort the rest: a throw here
51
- // propagates out of `LRUCache.set` into whichever request happened to
52
- // trigger the eviction, or abandons the remainder of the shutdown
53
- // drain.
112
+ // propagates out of the eviction into whichever request happened to
113
+ // trigger it, or abandons the remainder of the shutdown drain.
54
114
  logger.warn("auth-gateway provider session state close failed", {
55
115
  sessionKey,
56
116
  providerKey,
@@ -62,17 +122,82 @@ function closeSessionState(states: Map<string, ProviderSessionState>, sessionKey
62
122
  states.clear();
63
123
  }
64
124
 
125
+ /**
126
+ * Index keys this request may be placed on, most specific first.
127
+ *
128
+ * With a client key there is exactly one: the client named its conversation, so
129
+ * provider + model + that key is the identity.
130
+ *
131
+ * Without one the gateway has to infer the conversation, and the request's
132
+ * message history is the only thing that can distinguish two of them. The
133
+ * derived `sessionId` used for prefix caching and credential stickiness hashes
134
+ * the model, system prompt, tools and *first* message, which is deliberately
135
+ * prefix-shaped — two chats that open the same way share a cache bucket, which
136
+ * is a cache hit rather than a leak, and share a sticky account, which is a
137
+ * load-balancing hint. Retained provider state is neither: sharing it means one
138
+ * chat's rejection silences another chat's request, and one chat's Codex
139
+ * transport session answers another chat's turn. So provider state gets its own
140
+ * key, and only provider state: `deriveSessionId` keeps its two other jobs.
141
+ *
142
+ * The key is therefore a running hash over (provider, model, system, tools) and
143
+ * then every message, one key per message — the last of which identifies the
144
+ * exact history this request presented. Turn N+1 of a conversation extends turn
145
+ * N's history, so turn N's key is one of the earlier entries in turn N+1's
146
+ * chain: the store finds the nearest ancestor and moves that entry forward onto
147
+ * the new key. Two conversations that share an opening therefore share an entry
148
+ * only until they diverge; after that the first branch to arrive keeps the
149
+ * ancestor and the other starts clean. That is the most a stateless wire can
150
+ * tell us — before divergence the two requests are byte-identical.
151
+ *
152
+ * System prompt and tools sit in the root rather than per-message because they
153
+ * are not history: a client that re-stamps its system prompt every turn (a
154
+ * date, a cwd) starts a new lineage, exactly as it already starts a new derived
155
+ * `sessionId` today.
156
+ */
157
+ function sessionKeys(request: AuthGatewaySessionStateRequest): string[] {
158
+ const { model } = request;
159
+ const scope = `${model.provider}\u0000${model.id}`;
160
+ if (request.clientKey !== undefined) return [`c\u0000${scope}\u0000${request.clientKey}`];
161
+ const { context } = request;
162
+ // NUL separates the components so none of them can forge the boundary.
163
+ let hash = Bun.hash(
164
+ `${scope}\u0000${context.systemPrompt?.join("\n\n") ?? ""}\u0000${context.tools ? JSON.stringify(context.tools) : ""}`,
165
+ );
166
+ const keys: string[] = [];
167
+ for (const message of context.messages) {
168
+ // Role + content only: omp re-stamps `timestamp` and provider metadata on
169
+ // every parsed message, so hashing those would break the chain on turn
170
+ // two of every conversation.
171
+ hash = Bun.hash(JSON.stringify({ role: message.role, content: message.content }), hash);
172
+ keys.push(`h\u0000${scope}\u0000${hash.toString(36)}`);
173
+ }
174
+ // A request with no messages has no history to place; its root is the key.
175
+ if (keys.length === 0) return [`h\u0000${scope}\u0000${hash.toString(36)}`];
176
+ keys.reverse();
177
+ return keys;
178
+ }
179
+
65
180
  /**
66
181
  * Bounded per-session provider state, owned by one gateway server instance.
67
182
  *
68
183
  * Two gateways in the same process get separate stores, so neither can hand a
69
184
  * request another gateway's learned state or close it out from under one.
185
+ *
186
+ * The recency order is this class's own (a `Map` iterates in insertion order,
187
+ * and every acquire re-inserts) rather than `LRUCache`'s, because the policy
188
+ * needs two things a general cache cannot express: an entry that a request is
189
+ * still holding must be skipped when picking a victim, and an entry must be
190
+ * able to change key — `LRUCache` disposes on every removal, which is precisely
191
+ * the `close()` we must not run here.
70
192
  */
71
193
  export class AuthGatewaySessionStateStore {
72
- readonly #sessions: LRUCache<string, Map<string, ProviderSessionState>>;
194
+ /** Least recently acquired first — insertion order is the LRU order. */
195
+ readonly #sessions = new Map<string, RetainedSession>();
196
+ readonly #max: number;
73
197
 
74
198
  constructor(max: number = AUTH_GATEWAY_MAX_SESSION_STATES) {
75
- this.#sessions = new LRUCache({ max, dispose: closeSessionState });
199
+ if (!Number.isInteger(max) || max < 1) throw new TypeError("max must be a positive integer");
200
+ this.#max = max;
76
201
  }
77
202
 
78
203
  /** Retained logical sessions. */
@@ -81,34 +206,107 @@ export class AuthGatewaySessionStateStore {
81
206
  }
82
207
 
83
208
  /**
84
- * The provider-session map for one logical session on one model, created on
85
- * first use and returned by reference so provider mutations persist into the
86
- * next request.
209
+ * Claim the provider-session map for one request, created on first use and
210
+ * returned by reference so provider mutations persist into the next request.
87
211
  *
88
- * Keyed by session + provider + model id. The session is the identity that
89
- * matters — it is the same identity used for credential stickiness and
90
- * prefix-cache keying — but a client is free to reuse one session id across
91
- * models, and the coarsest provider entries do not separate models
92
- * themselves (`openai-responses` keys its strict-tools / history-replay
93
- * record by provider alone, Antigravity by a single constant), so the model
94
- * belongs in the key here. Endpoint is deliberately absent: every provider
95
- * whose learning is endpoint-specific already sub-keys it internally
212
+ * Keyed by provider + model + conversation (see {@link sessionKeys}). A
213
+ * client is free to reuse one session id across models, and the coarsest
214
+ * provider entries do not separate models themselves (`openai-responses`
215
+ * keys its strict-tools / history-replay record by provider alone,
216
+ * Antigravity by a single constant), so the model belongs in the key here.
217
+ * Endpoint is deliberately absent: every provider whose learning is
218
+ * endpoint-specific already sub-keys it internally
96
219
  * (`anthropic-messages:${baseUrl}\0${modelId}`,
97
220
  * `openai-completions:${provider}:${baseUrl}:${modelId}`), and repeating it
98
- * would only fragment the map. NUL separates the components so none of them
99
- * can forge the boundary.
221
+ * would only fragment the map. The credential is absent for the same reason
222
+ * — most of what is retained is true of the endpoint whoever calls it, and
223
+ * Codex already sub-keys its transport by account and bearer — so a
224
+ * credential switch resets the account-dependent subset instead of
225
+ * splitting the entry (see `resetAccountScopedProviderSessionState`).
226
+ *
227
+ * The returned lease MUST be released; until then the entry cannot be
228
+ * evicted.
100
229
  */
101
- acquire(sessionId: string, model: Model<Api>): Map<string, ProviderSessionState> {
102
- const key = `${sessionId}\u0000${model.provider}\u0000${model.id}`;
103
- const existing = this.#sessions.get(key);
104
- if (existing) return existing;
105
- const created = new Map<string, ProviderSessionState>();
106
- this.#sessions.set(key, created);
107
- return created;
230
+ acquire(request: AuthGatewaySessionStateRequest): AuthGatewaySessionStateLease {
231
+ const session = this.#claim(sessionKeys(request), request.account);
232
+ let released = false;
233
+ return {
234
+ states: session.states,
235
+ updateAccount: (account: string): void => {
236
+ if (session.account === account) return;
237
+ resetAccountScopedProviderSessionState(session.states);
238
+ session.account = account;
239
+ },
240
+ release: (): void => {
241
+ if (released) return;
242
+ released = true;
243
+ session.leases--;
244
+ // This entry may be the victim the bound has been waiting for.
245
+ if (session.leases === 0) this.#evict();
246
+ },
247
+ };
108
248
  }
109
249
 
110
250
  /** Close and drop every retained state. Called when the gateway shuts down. */
111
251
  close(): void {
252
+ // The only place a leased entry is torn down: the listener is already
253
+ // down, every in-flight stream is being cancelled with it, and the
254
+ // process cannot settle while a Codex WebSocket or Duo workflow is open.
255
+ for (const session of this.#sessions.values()) closeSessionState(session.states, session.key, "shutdown");
112
256
  this.#sessions.clear();
113
257
  }
258
+
259
+ /**
260
+ * Resolve `keys` to an entry — reusing the nearest ancestor when a keyless
261
+ * conversation has grown — mark it most recently used, and hand the caller
262
+ * the claim. The claim is taken before the ceiling is enforced: a brand-new
263
+ * entry belongs to the request that just created it, and is not a candidate
264
+ * for making room for itself.
265
+ */
266
+ #claim(keys: readonly string[], account: string): RetainedSession {
267
+ const key = keys[0] ?? "";
268
+ for (const candidate of keys) {
269
+ const session = this.#sessions.get(candidate);
270
+ if (session === undefined) continue;
271
+ // Re-insert at the tail for recency, under this request's own key so
272
+ // the next turn of this conversation finds it as its ancestor. A
273
+ // sibling branch of the same ancestor no longer matches, which is the
274
+ // point: it gets an entry of its own.
275
+ this.#sessions.delete(candidate);
276
+ session.key = key;
277
+ this.#sessions.set(key, session);
278
+ session.leases++;
279
+ if (session.account !== account) {
280
+ resetAccountScopedProviderSessionState(session.states);
281
+ session.account = account;
282
+ }
283
+ return session;
284
+ }
285
+ const created: RetainedSession = { key, states: new Map(), account, leases: 1 };
286
+ this.#sessions.set(key, created);
287
+ this.#evict();
288
+ return created;
289
+ }
290
+
291
+ /**
292
+ * Enforce the ceiling against entries no request is holding.
293
+ *
294
+ * A long-running stream is exactly the entry LRU order would pick — it was
295
+ * acquired when the stream opened and not touched since — so blind eviction
296
+ * would `close()` the sockets and flags that stream is still using. Live
297
+ * entries are skipped instead, and the store sits above its bound until
298
+ * their requests release; the excess is therefore capped by the number of
299
+ * concurrent requests, each of which holds a client connection.
300
+ */
301
+ #evict(): void {
302
+ if (this.#sessions.size <= this.#max) return;
303
+ // Deleting during Map iteration is well-defined: the current and later
304
+ // keys stay consistent, so this walks least-recently-acquired first.
305
+ for (const session of this.#sessions.values()) {
306
+ if (this.#sessions.size <= this.#max) return;
307
+ if (session.leases > 0) continue;
308
+ this.#sessions.delete(session.key);
309
+ closeSessionState(session.states, session.key, "evict");
310
+ }
311
+ }
114
312
  }