@oh-my-pi/pi-ai 18.2.0 → 18.2.1

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 (59) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/auth-broker/remote-store.d.ts +17 -0
  3. package/dist/types/auth-gateway/index.d.ts +1 -0
  4. package/dist/types/auth-gateway/session-state.d.ts +65 -0
  5. package/dist/types/auth-storage.d.ts +16 -0
  6. package/dist/types/error/body-error.d.ts +15 -0
  7. package/dist/types/error/flags.d.ts +16 -0
  8. package/dist/types/error/index.d.ts +1 -0
  9. package/dist/types/oneshot-retry.d.ts +6 -0
  10. package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
  11. package/dist/types/providers/openai-shared.d.ts +20 -3
  12. package/dist/types/registry/oauth/perplexity.d.ts +1 -7
  13. package/dist/types/registry/oauth/types.d.ts +8 -0
  14. package/dist/types/stream.d.ts +2 -0
  15. package/dist/types/types.d.ts +3 -1
  16. package/dist/types/usage.d.ts +8 -0
  17. package/dist/types/utils/block-symbols.d.ts +36 -0
  18. package/dist/types/utils/openai-http.d.ts +2 -0
  19. package/dist/types/utils/retry-after.d.ts +2 -0
  20. package/dist/types/utils/schema/wire.d.ts +4 -5
  21. package/dist/types/utils.d.ts +9 -0
  22. package/package.json +6 -6
  23. package/src/auth-broker/remote-store.ts +73 -8
  24. package/src/auth-broker/wire-schemas.ts +1 -0
  25. package/src/auth-gateway/index.ts +1 -0
  26. package/src/auth-gateway/server.ts +48 -11
  27. package/src/auth-gateway/session-state.ts +114 -0
  28. package/src/auth-storage.ts +144 -13
  29. package/src/error/body-error.ts +310 -0
  30. package/src/error/flags.ts +63 -13
  31. package/src/error/index.ts +1 -0
  32. package/src/error/retryable.ts +2 -0
  33. package/src/oneshot-retry.ts +13 -3
  34. package/src/providers/anthropic-messages-server.ts +24 -3
  35. package/src/providers/anthropic.ts +101 -15
  36. package/src/providers/cursor.ts +7 -1
  37. package/src/providers/devin.ts +82 -28
  38. package/src/providers/openai-chat-server.ts +4 -0
  39. package/src/providers/openai-codex/request-transformer.ts +36 -0
  40. package/src/providers/openai-codex-responses.ts +35 -12
  41. package/src/providers/openai-completions.ts +43 -12
  42. package/src/providers/openai-reasoning-fallback.ts +6 -6
  43. package/src/providers/openai-responses-server.ts +2 -1
  44. package/src/providers/openai-responses.ts +25 -4
  45. package/src/providers/openai-shared.ts +199 -51
  46. package/src/registry/oauth/perplexity.ts +94 -28
  47. package/src/registry/oauth/types.ts +9 -0
  48. package/src/stream.ts +23 -2
  49. package/src/types.ts +3 -0
  50. package/src/usage/claude.ts +33 -0
  51. package/src/usage/google-antigravity.ts +8 -2
  52. package/src/usage.ts +3 -0
  53. package/src/utils/block-symbols.ts +57 -0
  54. package/src/utils/openai-http.ts +39 -3
  55. package/src/utils/retry-after.ts +12 -0
  56. package/src/utils/schema/normalize.ts +3 -3
  57. package/src/utils/schema/stamps.ts +33 -45
  58. package/src/utils/schema/wire.ts +9 -7
  59. package/src/utils.ts +67 -22
@@ -255,6 +255,18 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
255
255
  #snapshot: SnapshotResponse = emptySnapshot();
256
256
  #snapshotReceivedAt = Date.now();
257
257
  #generation = 0;
258
+ /**
259
+ * Content fingerprint of the credential set in {@link #snapshot} (id +
260
+ * provider + credential material), recomputed after every snapshot mutation.
261
+ * Drives {@link #credentialRevision} independently of the broker's numeric
262
+ * generation, which is an in-memory counter that resets when the broker
263
+ * process restarts and so cannot be trusted for change detection.
264
+ */
265
+ #credentialFingerprint = "";
266
+ /** Monotonic local counter bumped whenever {@link #credentialFingerprint} changes. */
267
+ #credentialRevision = 0;
268
+ /** Revision last reported as "seen" by {@link pollExternalChanges}; seeded from the initial snapshot. */
269
+ #acknowledgedRevision = 0;
258
270
  #usageOverlays: Map<string, UsageReport> = new Map();
259
271
  #backgroundAbort = new AbortController();
260
272
  readonly #backgroundIdleMs: number;
@@ -300,6 +312,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
300
312
  ? new Map([...opts.accountPool].map(([provider, identities]) => [provider, new Set(identities)]))
301
313
  : undefined;
302
314
  this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
315
+ this.#acknowledgedRevision = this.#credentialRevision;
303
316
  this.#onSnapshot = opts.onSnapshot;
304
317
  void this.#runBackground();
305
318
  }
@@ -325,6 +338,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
325
338
  this.#snapshot = { ...snapshot, credentials };
326
339
  this.#generation = generation;
327
340
  this.#snapshotReceivedAt = nowMs;
341
+ this.#refreshCredentialRevision();
328
342
  const onSnapshot = this.#onSnapshot;
329
343
  if (!onSnapshot) return;
330
344
  try {
@@ -333,6 +347,34 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
333
347
  logger.debug("auth-broker snapshot callback failed", { error: String(error) });
334
348
  }
335
349
  }
350
+
351
+ /**
352
+ * Recompute the credential-content fingerprint and bump
353
+ * {@link #credentialRevision} when it changes. Called after every snapshot
354
+ * mutation so {@link pollExternalChanges} detects add/remove/replace even
355
+ * when the broker's numeric generation repeats (e.g. after a broker
356
+ * restart resets its in-memory counter).
357
+ */
358
+ #refreshCredentialRevision(): void {
359
+ const fingerprint = this.#computeCredentialFingerprint();
360
+ if (fingerprint === this.#credentialFingerprint) return;
361
+ this.#credentialFingerprint = fingerprint;
362
+ this.#credentialRevision += 1;
363
+ }
364
+
365
+ /**
366
+ * Order-independent digest of the routable credential material — exactly the
367
+ * fields {@link listAuthCredentials} exposes (id, provider, credential). A
368
+ * token rotation or an add/remove changes it; credential blocks and usage
369
+ * overlays do not.
370
+ */
371
+ #computeCredentialFingerprint(): string {
372
+ const parts = this.#snapshot.credentials.map(
373
+ entry => `${entry.id}\u0000${entry.provider}\u0000${JSON.stringify(entry.credential)}`,
374
+ );
375
+ parts.sort();
376
+ return parts.join("\u0001");
377
+ }
336
378
  #protectNewSnapshotBlocks(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[], nowMs: number): void {
337
379
  const previousBlocksByKey = new Map<string, string>();
338
380
  for (const entry of previous) {
@@ -499,15 +541,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
499
541
  #applyStreamEvent(event: SnapshotStreamEvent): void {
500
542
  switch (event.kind) {
501
543
  case "snapshot": {
502
- // Strip the discriminator so we store the wire-shape SnapshotResponse.
544
+ // The first frame of every SSE connection is a full authoritative
545
+ // snapshot. Always adopt it as the new generation baseline: the
546
+ // broker's in-memory generation counter resets on restart and may
547
+ // therefore be lower than the previous stream's last value.
548
+ // Subsequent entry/removal frames remain guarded against reordering
549
+ // relative to this new baseline below.
503
550
  const { kind: _kind, ...snapshot } = event;
504
- if (snapshot.generation < this.#generation) {
505
- logger.debug("auth-broker stream snapshot older than local; ignoring", {
506
- local: this.#generation,
507
- incoming: snapshot.generation,
508
- });
509
- return;
510
- }
511
551
  this.#applySnapshot(snapshot, snapshot.generation);
512
552
  return;
513
553
  }
@@ -548,6 +588,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
548
588
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
549
589
  this.#generation = generation;
550
590
  this.#snapshotReceivedAt = Date.now();
591
+ this.#refreshCredentialRevision();
551
592
  }
552
593
 
553
594
  #removeStreamCredential(
@@ -564,6 +605,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
564
605
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
565
606
  this.#generation = generation;
566
607
  this.#snapshotReceivedAt = Date.now();
608
+ this.#refreshCredentialRevision();
567
609
  }
568
610
 
569
611
  /** Re-hydrate the in-memory snapshot from the broker. */
@@ -574,6 +616,29 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
574
616
  return this.#snapshot;
575
617
  }
576
618
 
619
+ /**
620
+ * Stateful probe for broker-side credential changes, mirroring
621
+ * {@link SqliteAuthCredentialStore.pollExternalChanges} so long-lived broker
622
+ * clients (notably `auth-gateway serve`) pick up logins/logouts made by
623
+ * another process without a restart.
624
+ *
625
+ * Compares a local content revision, not the broker's numeric generation:
626
+ * generation is an in-memory counter that resets when the broker process
627
+ * restarts, so a reconnecting stream can deliver a different credential set
628
+ * under a repeated (or lower) generation. {@link #refreshCredentialRevision}
629
+ * bumps the revision whenever the applied credential material actually
630
+ * changes, catching those cases too. Records foreground activity first: a
631
+ * low-traffic client's background sync parks after `#backgroundIdleMs`, and
632
+ * without this wakeup it would never fetch the new snapshot to report in the
633
+ * first place.
634
+ */
635
+ pollExternalChanges(): boolean {
636
+ this.#noteActivity();
637
+ if (this.#credentialRevision === this.#acknowledgedRevision) return false;
638
+ this.#acknowledgedRevision = this.#credentialRevision;
639
+ return true;
640
+ }
641
+
577
642
  listAuthCredentials(provider?: string): StoredAuthCredential[] {
578
643
  this.#noteActivity();
579
644
  const out: StoredAuthCredential[] = [];
@@ -220,6 +220,7 @@ const usageScopeSchema = type({
220
220
  "tier?": "string",
221
221
  "windowId?": "string",
222
222
  "shared?": "boolean",
223
+ "sharedGroup?": "string",
223
224
  });
224
225
 
225
226
  const usageLimitSchema = type({
@@ -1,3 +1,4 @@
1
1
  export * from "./http";
2
+ export * from "./session-state";
2
3
  export * from "./server";
3
4
  export * from "./types";
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { Effort } from "@oh-my-pi/pi-catalog/effort";
22
- import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
22
+ import { extractHttpStatusFromError, logger } from "@oh-my-pi/pi-utils";
23
23
  import type { ApiKeyResolver } from "../auth-retry";
24
24
  import type { AuthStorage } from "../auth-storage";
25
25
  import * as AIError from "../error";
@@ -34,6 +34,7 @@ import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model
34
34
  import type { ClientUsageIdentity } from "../usage";
35
35
  import { deterministicUuid } from "../utils/deterministic-id";
36
36
  import { parseBind } from "../utils/parse-bind";
37
+ import { extractProviderRetryHint } from "../utils/retry-after";
37
38
  import {
38
39
  captureRequestHeaders,
39
40
  corsHeaders,
@@ -44,6 +45,7 @@ import {
44
45
  resolvePeer,
45
46
  withCors,
46
47
  } from "./http";
48
+ import { AuthGatewaySessionStateStore } from "./session-state";
47
49
  import type {
48
50
  AuthGatewayServerHandle,
49
51
  AuthGatewayServerOptions,
@@ -128,6 +130,18 @@ function deriveSessionId(modelId: string, context: Context): string {
128
130
  return deterministicUuid(seed);
129
131
  }
130
132
 
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.
139
+ */
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);
143
+ }
144
+
131
145
  function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
132
146
  const opts: SimpleStreamOptions = { signal, cursorExternalToolExecutor: true };
133
147
  const { options } = parsed;
@@ -168,7 +182,7 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
168
182
  // Client-supplied `prompt_cache_key` wins; otherwise derive a stable
169
183
  // key from the model + system + tools so prefix caching engages on
170
184
  // Codex-class backends across turns of the same logical conversation.
171
- const promptCacheKey = options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
185
+ const promptCacheKey = resolveSessionId(options.promptCacheKey, parsed.modelId, parsed.context);
172
186
  opts.promptCacheKey = promptCacheKey;
173
187
  opts.sessionId = promptCacheKey;
174
188
  if (options.thinkingBudgets) {
@@ -248,7 +262,7 @@ async function refreshGatewayApiKeyAfterAuthError(
248
262
  const message = error instanceof Error ? error.message : String(error);
249
263
  const status = extractHttpStatusFromError(error);
250
264
  if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
251
- const retryAfterMs = extractRetryHint(undefined, message);
265
+ const retryAfterMs = extractProviderRetryHint(provider, message);
252
266
  const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
253
267
  retryAfterMs,
254
268
  providerTimed: retryAfterMs !== undefined,
@@ -379,6 +393,7 @@ async function handleFormatEndpoint(
379
393
  bootOpts: AuthGatewayBootOptions,
380
394
  req: Request,
381
395
  peer: string,
396
+ sessionStates: AuthGatewaySessionStateStore,
382
397
  ): Promise<Response> {
383
398
  const startedAt = performance.now();
384
399
  const requestId = crypto.randomUUID();
@@ -461,8 +476,8 @@ async function handleFormatEndpoint(
461
476
  // supplied (so external session ids align), otherwise derive from
462
477
  // modelId + system + tools + first message. Mirrored into
463
478
  // streamOpts.sessionId / promptCacheKey by `buildStreamOptions`.
464
- const sessionId = parsed.options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
465
- parsed.options.promptCacheKey ??= sessionId;
479
+ const sessionId = resolveSessionId(parsed.options.promptCacheKey, parsed.modelId, parsed.context);
480
+ parsed.options.promptCacheKey = sessionId;
466
481
 
467
482
  // pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
468
483
  // expected to resolve the credential and pass it as `options.apiKey`.
@@ -499,6 +514,11 @@ async function handleFormatEndpoint(
499
514
  route.label,
500
515
  peer,
501
516
  );
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);
502
522
 
503
523
  logger.info("auth-gateway request", {
504
524
  requestId,
@@ -600,7 +620,12 @@ async function handleFormatEndpoint(
600
620
  * `parseRequest`/`encodeResponse`/`encodeStream` differ from the format-endpoint
601
621
  * path.
602
622
  */
603
- async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
623
+ async function handlePiNative(
624
+ bootOpts: AuthGatewayBootOptions,
625
+ req: Request,
626
+ peer: string,
627
+ sessionStates: AuthGatewaySessionStateStore,
628
+ ): Promise<Response> {
604
629
  const startedAt = performance.now();
605
630
  const requestId = crypto.randomUUID();
606
631
  const controller = mirrorRequestAbort(req);
@@ -635,8 +660,8 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
635
660
  // up with cache-prefix stickiness — same identity used for both means
636
661
  // the next turn of this conversation reuses the same credential until
637
662
  // it hits a usage cap, then markUsageLimitReached can hand off.
638
- const sessionId = parsed.options.sessionId ?? deriveSessionId(parsed.modelId, parsed.context);
639
- parsed.options.sessionId ??= sessionId;
663
+ const sessionId = resolveSessionId(parsed.options.sessionId, parsed.modelId, parsed.context);
664
+ parsed.options.sessionId = sessionId;
640
665
 
641
666
  let apiKey: string | undefined;
642
667
  try {
@@ -668,6 +693,11 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
668
693
  apiKey,
669
694
  signal: controller.signal,
670
695
  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),
671
701
  };
672
702
  streamOpts.apiKey = buildGatewayApiKeyResolver(
673
703
  bootOpts.storage,
@@ -692,7 +722,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
692
722
  // headers — the client's values win when they collide.
693
723
  const captured = captureRequestHeaders(req.headers);
694
724
  streamOpts.headers = { ...captured, ...streamOpts.headers };
695
- streamOpts.sessionId ??= sessionId;
725
+ streamOpts.sessionId = sessionId;
696
726
 
697
727
  logger.info("auth-gateway request", {
698
728
  requestId,
@@ -847,6 +877,9 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
847
877
  const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
848
878
  const tokens = new Set<string>(opts.bearerTokens);
849
879
  const version = opts.version;
880
+ // Owned by this server instance so two gateways in one process never share
881
+ // (or tear down) each other's provider state, and so `close()` can drain it.
882
+ const sessionStates = new AuthGatewaySessionStateStore();
850
883
 
851
884
  const server = Bun.serve({
852
885
  hostname: bind.hostname,
@@ -887,13 +920,13 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
887
920
  // Provider-format dispatch.
888
921
  const formatRoute = FORMAT_ROUTES[pathname];
889
922
  if (formatRoute && req.method === "POST") {
890
- return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
923
+ return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer, sessionStates), req);
891
924
  }
892
925
 
893
926
  // Pi-native fast path. Same auth + provider plumbing as the
894
927
  // foreign-wire routes, just without the wire-format translation.
895
928
  if (req.method === "POST" && pathname === "/v1/pi/stream") {
896
- return withCors(await handlePiNative(opts, req, peer), req);
929
+ return withCors(await handlePiNative(opts, req, peer, sessionStates), req);
897
930
  }
898
931
 
899
932
  // Model catalog.
@@ -927,6 +960,10 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
927
960
  hostname: boundHost,
928
961
  close: async () => {
929
962
  server.stop(true);
963
+ // Drain after the listener is down: the retained provider states own
964
+ // sockets and timers (Codex WebSockets, GitLab Duo workflows), so the
965
+ // process can't settle until each one is closed.
966
+ sessionStates.close();
930
967
  },
931
968
  };
932
969
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Server-owned provider session state for the auth-gateway.
3
+ *
4
+ * `SimpleStreamOptions.providerSessionState` is how a provider keeps what it
5
+ * learned about an endpoint across turns of one conversation: Anthropic's
6
+ * sticky `strictToolsDisabled` / `fastModeDisabled` /
7
+ * `replayUnsignedThinkingDisabled` flags and dropped-thinking-prefix set,
8
+ * OpenAI's strict-tools and reasoning-effort fallbacks, Codex's WebSocket and
9
+ * turn-state sessions. An in-process omp session owns that `Map` for its whole
10
+ * lifetime, so a grammar-too-large 400 or a fast-mode rejection costs one
11
+ * wasted round-trip per session rather than one per turn.
12
+ *
13
+ * The map is deliberately non-serializable — `Set`/`Map` fields, live sockets,
14
+ * a `close()` method — so `pi-native-client` strips it from the wire and
15
+ * `pi-native-server` never accepts it. Gateway clients therefore cannot bring
16
+ * their own, and without a server-side owner every containerized / robomp turn
17
+ * re-learns every lesson from a fresh upstream rejection.
18
+ *
19
+ * A plain `Map<sessionId, …>` in a long-lived server process is a leak: nothing
20
+ * ever reclaims an entry, and the entries own timers and sockets. This store is
21
+ * an LRU with a hard entry ceiling that calls `close()` on everything it drops
22
+ * and on everything it still holds at shutdown.
23
+ */
24
+
25
+ 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
+
29
+ /**
30
+ * Retained logical sessions. Each entry is a handful of small provider records
31
+ * plus, for Codex, a WebSocket session — cheap to keep, but not free, so the
32
+ * ceiling is what turns "one entry per session id forever" into a bounded cost.
33
+ * Eviction is least-recently-used, so the ceiling only ever drops sessions that
34
+ * have been quiet longer than the 256 most recent ones.
35
+ */
36
+ export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
37
+
38
+ /**
39
+ * Close every provider record an evicted (or drained) session held.
40
+ *
41
+ * Anthropic's `close()` resets its sticky flags, Codex's tears down WebSockets
42
+ * and GitLab Duo's stops the server-side workflow — so dropping an entry
43
+ * without closing it leaks exactly the resources the bound exists to cap.
44
+ */
45
+ function closeSessionState(states: Map<string, ProviderSessionState>, sessionKey: string, reason: DisposeReason): void {
46
+ for (const [providerKey, state] of states) {
47
+ try {
48
+ state.close();
49
+ } catch (error) {
50
+ // 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.
54
+ logger.warn("auth-gateway provider session state close failed", {
55
+ sessionKey,
56
+ providerKey,
57
+ reason,
58
+ error: String(error),
59
+ });
60
+ }
61
+ }
62
+ states.clear();
63
+ }
64
+
65
+ /**
66
+ * Bounded per-session provider state, owned by one gateway server instance.
67
+ *
68
+ * Two gateways in the same process get separate stores, so neither can hand a
69
+ * request another gateway's learned state or close it out from under one.
70
+ */
71
+ export class AuthGatewaySessionStateStore {
72
+ readonly #sessions: LRUCache<string, Map<string, ProviderSessionState>>;
73
+
74
+ constructor(max: number = AUTH_GATEWAY_MAX_SESSION_STATES) {
75
+ this.#sessions = new LRUCache({ max, dispose: closeSessionState });
76
+ }
77
+
78
+ /** Retained logical sessions. */
79
+ get size(): number {
80
+ return this.#sessions.size;
81
+ }
82
+
83
+ /**
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.
87
+ *
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
96
+ * (`anthropic-messages:${baseUrl}\0${modelId}`,
97
+ * `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.
100
+ */
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;
108
+ }
109
+
110
+ /** Close and drop every retained state. Called when the gateway shuts down. */
111
+ close(): void {
112
+ this.#sessions.clear();
113
+ }
114
+ }