@oh-my-pi/pi-ai 18.2.0 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
  4. package/dist/types/auth-broker/remote-store.d.ts +17 -0
  5. package/dist/types/auth-gateway/index.d.ts +1 -0
  6. package/dist/types/auth-gateway/session-state.d.ts +118 -0
  7. package/dist/types/auth-storage.d.ts +17 -0
  8. package/dist/types/error/body-error.d.ts +15 -0
  9. package/dist/types/error/flags.d.ts +16 -0
  10. package/dist/types/error/index.d.ts +1 -0
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/oneshot-retry.d.ts +6 -0
  13. package/dist/types/provider-session-state.d.ts +46 -0
  14. package/dist/types/providers/amazon-bedrock.d.ts +3 -0
  15. package/dist/types/providers/aws-sigv4.d.ts +12 -0
  16. package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
  17. package/dist/types/providers/openai-responses.d.ts +15 -0
  18. package/dist/types/providers/openai-shared.d.ts +20 -3
  19. package/dist/types/registry/oauth/perplexity.d.ts +1 -7
  20. package/dist/types/registry/oauth/types.d.ts +8 -0
  21. package/dist/types/stream.d.ts +2 -0
  22. package/dist/types/types.d.ts +3 -1
  23. package/dist/types/usage/openai-codex.d.ts +3 -1
  24. package/dist/types/usage.d.ts +11 -1
  25. package/dist/types/utils/block-symbols.d.ts +36 -0
  26. package/dist/types/utils/openai-http.d.ts +2 -0
  27. package/dist/types/utils/retry-after.d.ts +2 -0
  28. package/dist/types/utils/schema/wire.d.ts +4 -5
  29. package/dist/types/utils.d.ts +9 -0
  30. package/package.json +6 -6
  31. package/src/auth/sqlite-credential-store.ts +8 -33
  32. package/src/auth-broker/remote-store.ts +73 -8
  33. package/src/auth-broker/wire-schemas.ts +1 -0
  34. package/src/auth-gateway/index.ts +1 -0
  35. package/src/auth-gateway/server.ts +186 -74
  36. package/src/auth-gateway/session-state.ts +312 -0
  37. package/src/auth-storage.ts +146 -15
  38. package/src/error/body-error.ts +310 -0
  39. package/src/error/flags.ts +63 -13
  40. package/src/error/index.ts +1 -0
  41. package/src/error/retryable.ts +2 -0
  42. package/src/index.ts +1 -0
  43. package/src/oneshot-retry.ts +13 -3
  44. package/src/provider-session-state.ts +56 -0
  45. package/src/providers/amazon-bedrock.ts +20 -3
  46. package/src/providers/anthropic-messages-server.ts +104 -23
  47. package/src/providers/anthropic-signature.ts +5 -2
  48. package/src/providers/anthropic.ts +101 -15
  49. package/src/providers/aws-sigv4.ts +16 -5
  50. package/src/providers/cursor.ts +60 -10
  51. package/src/providers/devin.ts +82 -28
  52. package/src/providers/openai-chat-server.ts +4 -0
  53. package/src/providers/openai-codex/request-transformer.ts +36 -0
  54. package/src/providers/openai-codex-responses.ts +35 -12
  55. package/src/providers/openai-completions.ts +49 -12
  56. package/src/providers/openai-reasoning-fallback.ts +6 -6
  57. package/src/providers/openai-responses-server.ts +2 -1
  58. package/src/providers/openai-responses.ts +52 -4
  59. package/src/providers/openai-shared.ts +199 -51
  60. package/src/registry/oauth/perplexity.ts +94 -28
  61. package/src/registry/oauth/types.ts +9 -0
  62. package/src/stream.ts +23 -2
  63. package/src/types.ts +3 -0
  64. package/src/usage/claude.ts +33 -0
  65. package/src/usage/google-antigravity.ts +8 -2
  66. package/src/usage/openai-codex.ts +94 -11
  67. package/src/usage.ts +8 -1
  68. package/src/utils/block-symbols.ts +57 -0
  69. package/src/utils/http-inspector.ts +20 -0
  70. package/src/utils/openai-http.ts +39 -3
  71. package/src/utils/retry-after.ts +12 -0
  72. package/src/utils/schema/normalize.ts +3 -3
  73. package/src/utils/schema/stamps.ts +33 -45
  74. package/src/utils/schema/wire.ts +9 -7
  75. package/src/utils.ts +67 -22
@@ -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,40 @@ function deriveSessionId(modelId: string, context: Context): string {
128
130
  return deterministicUuid(seed);
129
131
  }
130
132
 
133
+ /**
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.
138
+ */
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)}`;
165
+ }
166
+
131
167
  function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
132
168
  const opts: SimpleStreamOptions = { signal, cursorExternalToolExecutor: true };
133
169
  const { options } = parsed;
@@ -168,7 +204,8 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
168
204
  // Client-supplied `prompt_cache_key` wins; otherwise derive a stable
169
205
  // key from the model + system + tools so prefix caching engages on
170
206
  // Codex-class backends across turns of the same logical conversation.
171
- const promptCacheKey = options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
207
+ const promptCacheKey =
208
+ normalizeClientSessionKey(options.promptCacheKey) ?? deriveSessionId(parsed.modelId, parsed.context);
172
209
  opts.promptCacheKey = promptCacheKey;
173
210
  opts.sessionId = promptCacheKey;
174
211
  if (options.thinkingBudgets) {
@@ -248,7 +285,7 @@ async function refreshGatewayApiKeyAfterAuthError(
248
285
  const message = error instanceof Error ? error.message : String(error);
249
286
  const status = extractHttpStatusFromError(error);
250
287
  if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
251
- const retryAfterMs = extractRetryHint(undefined, message);
288
+ const retryAfterMs = extractProviderRetryHint(provider, message);
252
289
  const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
253
290
  retryAfterMs,
254
291
  providerTimed: retryAfterMs !== undefined,
@@ -300,6 +337,7 @@ function buildGatewayApiKeyResolver(
300
337
  requestSignal: AbortSignal,
301
338
  format: string,
302
339
  peer: string,
340
+ onResolvedKey: (apiKey: string) => void,
303
341
  ): ApiKeyResolver {
304
342
  let lastKey = initialKey;
305
343
  return async ({ lastChance, error, signal }) => {
@@ -315,6 +353,7 @@ function buildGatewayApiKeyResolver(
315
353
  forceRefresh: true,
316
354
  });
317
355
  lastKey = refreshed ?? lastKey;
356
+ if (refreshed) onResolvedKey(refreshed);
318
357
  return refreshed;
319
358
  }
320
359
  const next = await refreshGatewayApiKeyAfterAuthError(
@@ -329,6 +368,7 @@ function buildGatewayApiKeyResolver(
329
368
  peer,
330
369
  );
331
370
  lastKey = next ?? lastKey;
371
+ if (next) onResolvedKey(next);
332
372
  return next;
333
373
  };
334
374
  }
@@ -379,6 +419,7 @@ async function handleFormatEndpoint(
379
419
  bootOpts: AuthGatewayBootOptions,
380
420
  req: Request,
381
421
  peer: string,
422
+ sessionStates: AuthGatewaySessionStateStore,
382
423
  ): Promise<Response> {
383
424
  const startedAt = performance.now();
384
425
  const requestId = crypto.randomUUID();
@@ -461,8 +502,9 @@ async function handleFormatEndpoint(
461
502
  // supplied (so external session ids align), otherwise derive from
462
503
  // modelId + system + tools + first message. Mirrored into
463
504
  // streamOpts.sessionId / promptCacheKey by `buildStreamOptions`.
464
- const sessionId = parsed.options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
465
- parsed.options.promptCacheKey ??= sessionId;
505
+ const clientKey = normalizeClientSessionKey(parsed.options.promptCacheKey);
506
+ const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
507
+ parsed.options.promptCacheKey = sessionId;
466
508
 
467
509
  // pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
468
510
  // expected to resolve the credential and pass it as `options.apiKey`.
@@ -490,6 +532,19 @@ async function handleFormatEndpoint(
490
532
  }
491
533
 
492
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;
493
548
  streamOpts.apiKey = buildGatewayApiKeyResolver(
494
549
  bootOpts.storage,
495
550
  model,
@@ -498,6 +553,8 @@ async function handleFormatEndpoint(
498
553
  controller.signal,
499
554
  route.label,
500
555
  peer,
556
+ resolvedKey =>
557
+ lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
501
558
  );
502
559
 
503
560
  logger.info("auth-gateway request", {
@@ -545,45 +602,59 @@ async function handleFormatEndpoint(
545
602
  peer,
546
603
  });
547
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();
548
609
  }
549
610
  }
550
611
 
551
- 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;
552
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
+ }
553
626
  if (controller.signal.aborted) return clientClosedResponse(route);
554
- events = streamSimple(model, parsed.context, streamOpts);
555
- } catch (error) {
556
- const classified = classifyGatewayError(error);
557
- logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
558
- return route.module.formatError(classified.status, classified.type, classified.message);
559
- }
560
- if (controller.signal.aborted) return clientClosedResponse(route);
561
- void events
562
- .result()
563
- .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
564
- .catch(() => {});
627
+ void events
628
+ .result()
629
+ .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
630
+ .catch(() => {})
631
+ .finally(() => lease.release());
632
+ streamOwnsLease = true;
565
633
 
566
- const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options, {
567
- signal: controller.signal,
568
- onCancel: reason => {
569
- if (!controller.signal.aborted) {
570
- controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
571
- }
572
- },
573
- });
574
- return new Response(sseStream, {
575
- status: 200,
576
- headers: {
577
- ...gatewayResponseHeaders(model, { requestId }),
578
- "Content-Type": "text/event-stream; charset=utf-8",
579
- "Cache-Control": "no-cache",
580
- Connection: "keep-alive",
581
- // Disable proxy buffering (nginx and ingress controllers honor this).
582
- // Without it the SSE stream gets held until the buffer flushes, which
583
- // stalls the long-thinking-budget calls we exist to support.
584
- "X-Accel-Buffering": "no",
585
- },
586
- });
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
+ }
587
658
  }
588
659
 
589
660
  /**
@@ -600,7 +671,12 @@ async function handleFormatEndpoint(
600
671
  * `parseRequest`/`encodeResponse`/`encodeStream` differ from the format-endpoint
601
672
  * path.
602
673
  */
603
- async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
674
+ async function handlePiNative(
675
+ bootOpts: AuthGatewayBootOptions,
676
+ req: Request,
677
+ peer: string,
678
+ sessionStates: AuthGatewaySessionStateStore,
679
+ ): Promise<Response> {
604
680
  const startedAt = performance.now();
605
681
  const requestId = crypto.randomUUID();
606
682
  const controller = mirrorRequestAbort(req);
@@ -635,8 +711,9 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
635
711
  // up with cache-prefix stickiness — same identity used for both means
636
712
  // the next turn of this conversation reuses the same credential until
637
713
  // 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;
714
+ const clientKey = normalizeClientSessionKey(parsed.options.sessionId);
715
+ const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
716
+ parsed.options.sessionId = sessionId;
640
717
 
641
718
  let apiKey: string | undefined;
642
719
  try {
@@ -659,6 +736,17 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
659
736
  );
660
737
  }
661
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
+ });
662
750
  // Build the SimpleStreamOptions actually handed to `streamSimple`. We
663
751
  // trust the client's options (already allow-listed by `parseRequest`) and
664
752
  // only inject server-controlled fields. The codex sampling strip mirrors
@@ -668,6 +756,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
668
756
  apiKey,
669
757
  signal: controller.signal,
670
758
  cursorExternalToolExecutor: true,
759
+ providerSessionState: lease.states,
671
760
  };
672
761
  streamOpts.apiKey = buildGatewayApiKeyResolver(
673
762
  bootOpts.storage,
@@ -677,6 +766,8 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
677
766
  controller.signal,
678
767
  "pi-native",
679
768
  peer,
769
+ resolvedKey =>
770
+ lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
680
771
  );
681
772
  if (model.api === "openai-codex-responses") {
682
773
  delete streamOpts.temperature;
@@ -692,7 +783,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
692
783
  // headers — the client's values win when they collide.
693
784
  const captured = captureRequestHeaders(req.headers);
694
785
  streamOpts.headers = { ...captured, ...streamOpts.headers };
695
- streamOpts.sessionId ??= sessionId;
786
+ streamOpts.sessionId = sessionId;
696
787
 
697
788
  logger.info("auth-gateway request", {
698
789
  requestId,
@@ -731,42 +822,56 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
731
822
  const classified = classifyGatewayError(error);
732
823
  logger.warn("auth-gateway non-streaming aborted", { format: "pi-native", error: classified.message, peer });
733
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();
734
829
  }
735
830
  }
736
831
 
737
- 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;
738
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
+ }
739
846
  if (controller.signal.aborted) return aborted();
740
- events = streamSimple(model, parsed.context, streamOpts);
741
- } catch (error) {
742
- const classified = classifyGatewayError(error);
743
- logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
744
- return piNative.formatError(classified.status, classified.type, classified.message);
745
- }
746
- if (controller.signal.aborted) return aborted();
747
- void events
748
- .result()
749
- .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
750
- .catch(() => {});
847
+ void events
848
+ .result()
849
+ .then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
850
+ .catch(() => {})
851
+ .finally(() => lease.release());
852
+ streamOwnsLease = true;
751
853
 
752
- const sseStream = piNative.encodeStream(events, parsed.modelId, parsed.options, {
753
- signal: controller.signal,
754
- onCancel: reason => {
755
- if (!controller.signal.aborted) {
756
- controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
757
- }
758
- },
759
- });
760
- return new Response(sseStream, {
761
- status: 200,
762
- headers: {
763
- ...gatewayResponseHeaders(model, { requestId }),
764
- "Content-Type": "text/event-stream; charset=utf-8",
765
- "Cache-Control": "no-cache",
766
- Connection: "keep-alive",
767
- "X-Accel-Buffering": "no",
768
- },
769
- });
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
+ }
770
875
  }
771
876
 
772
877
  /**
@@ -847,6 +952,9 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
847
952
  const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
848
953
  const tokens = new Set<string>(opts.bearerTokens);
849
954
  const version = opts.version;
955
+ // Owned by this server instance so two gateways in one process never share
956
+ // (or tear down) each other's provider state, and so `close()` can drain it.
957
+ const sessionStates = new AuthGatewaySessionStateStore();
850
958
 
851
959
  const server = Bun.serve({
852
960
  hostname: bind.hostname,
@@ -887,13 +995,13 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
887
995
  // Provider-format dispatch.
888
996
  const formatRoute = FORMAT_ROUTES[pathname];
889
997
  if (formatRoute && req.method === "POST") {
890
- return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
998
+ return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer, sessionStates), req);
891
999
  }
892
1000
 
893
1001
  // Pi-native fast path. Same auth + provider plumbing as the
894
1002
  // foreign-wire routes, just without the wire-format translation.
895
1003
  if (req.method === "POST" && pathname === "/v1/pi/stream") {
896
- return withCors(await handlePiNative(opts, req, peer), req);
1004
+ return withCors(await handlePiNative(opts, req, peer, sessionStates), req);
897
1005
  }
898
1006
 
899
1007
  // Model catalog.
@@ -927,6 +1035,10 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
927
1035
  hostname: boundHost,
928
1036
  close: async () => {
929
1037
  server.stop(true);
1038
+ // Drain after the listener is down: the retained provider states own
1039
+ // sockets and timers (Codex WebSockets, GitLab Duo workflows), so the
1040
+ // process can't settle until each one is closed.
1041
+ sessionStates.close();
930
1042
  },
931
1043
  };
932
1044
  }