codex-multi-auth 2.21.0 → 2.23.0

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 (70) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +8 -5
  3. package/config/schema/config.schema.json +8 -0
  4. package/dist/lib/accounts.d.ts +5 -0
  5. package/dist/lib/accounts.js +33 -4
  6. package/dist/lib/api-route-store.d.ts +7 -0
  7. package/dist/lib/api-route-store.js +100 -31
  8. package/dist/lib/auth/account-access.js +2 -1
  9. package/dist/lib/auth/auth.js +3 -2
  10. package/dist/lib/auth/device-auth.js +2 -1
  11. package/dist/lib/auth/http.d.ts +1 -0
  12. package/dist/lib/auth/http.js +1 -0
  13. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  14. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  15. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  16. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  17. package/dist/lib/codex-manager/commands/locale.js +25 -0
  18. package/dist/lib/codex-manager/commands/status.d.ts +5 -1
  19. package/dist/lib/codex-manager/commands/status.js +501 -13
  20. package/dist/lib/codex-manager/help.js +3 -1
  21. package/dist/lib/codex-manager.js +98 -23
  22. package/dist/lib/config.js +3 -0
  23. package/dist/lib/constants.d.ts +1 -0
  24. package/dist/lib/constants.js +1 -0
  25. package/dist/lib/locale.d.ts +7 -0
  26. package/dist/lib/locale.js +35 -0
  27. package/dist/lib/model-route-policy.d.ts +1 -0
  28. package/dist/lib/model-route-policy.js +2 -1
  29. package/dist/lib/quota-probe.js +2 -1
  30. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  31. package/dist/lib/request/proxy-fetch.js +37 -0
  32. package/dist/lib/request/url-rewriting.js +17 -0
  33. package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
  34. package/dist/lib/runtime/account-model-catalog.js +7 -2
  35. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  36. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  37. package/dist/lib/runtime/app-bind.d.ts +7 -0
  38. package/dist/lib/runtime/app-bind.js +92 -1
  39. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  40. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  41. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  42. package/dist/lib/runtime/model-discovery-status.js +64 -49
  43. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  44. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  45. package/dist/lib/runtime/responses-websocket.d.ts +48 -5
  46. package/dist/lib/runtime/responses-websocket.js +275 -39
  47. package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
  48. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  50. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  51. package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
  52. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  53. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  54. package/dist/lib/runtime-rotation-proxy.js +601 -105
  55. package/dist/lib/schemas.d.ts +5 -0
  56. package/dist/lib/schemas.js +1 -0
  57. package/dist/lib/storage/credential-sidecars.js +34 -2
  58. package/dist/lib/storage/file-lock.d.ts +2 -0
  59. package/dist/lib/storage/file-lock.js +44 -30
  60. package/dist/lib/usage/ledger.js +1 -0
  61. package/dist/lib/usage/redaction.js +1 -0
  62. package/dist/lib/usage/types.d.ts +1 -1
  63. package/dist/lib/web-dashboard-html.d.ts +1 -0
  64. package/dist/lib/web-dashboard-html.js +66 -0
  65. package/dist/lib/web-dashboard.d.ts +36 -0
  66. package/dist/lib/web-dashboard.js +322 -0
  67. package/package.json +3 -1
  68. package/scripts/codex-app-router.js +86 -14
  69. package/scripts/codex-routing.js +1 -0
  70. package/scripts/codex.js +150 -13
@@ -1,17 +1,53 @@
1
1
  import type { Duplex } from "node:stream";
2
2
  import type { IncomingMessage, Server } from "node:http";
3
+ /** Safe operational evidence only: never persist upstream reason text or payloads. */
4
+ export interface WebSocketDisconnectDiagnostic {
5
+ at: number;
6
+ cause: "close" | "transport_error" | "invalid_event" | "buffer_limit" | "send_error";
7
+ stage: "awaiting_first_event" | "streaming";
8
+ closeCode?: number;
9
+ routeKind?: "oauth" | "api" | "zdr";
10
+ connectionAgeMs?: number;
11
+ eventCount?: number;
12
+ requestBytes?: number;
13
+ inputItemCount?: number;
14
+ isPrewarm?: boolean;
15
+ hasPreviousResponse?: boolean;
16
+ reusedConnection?: boolean;
17
+ /** Local send callback success, not evidence of upstream receipt. */
18
+ sendCompleted?: boolean;
19
+ compressionNegotiated?: boolean;
20
+ transportErrorCode?: "ECONNRESET" | "EPIPE" | "ETIMEDOUT" | "OTHER";
21
+ tcpEndReceived?: boolean;
22
+ tcpCloseHadError?: boolean;
23
+ }
24
+ /** Local admission evidence only; no headers, credentials, or conversation IDs. */
25
+ export interface WebSocketUpgradeRejectionDiagnostic {
26
+ at: number;
27
+ reason: "origin" | "capacity" | "repeated_disconnect";
28
+ status: number;
29
+ activeConnections: number;
30
+ }
31
+ interface WebSocketStats {
32
+ connections: number;
33
+ activeConnections: number;
34
+ capacityReclaims: number;
35
+ upstreamConnections: number;
36
+ upstreamRequests: number;
37
+ lastDisconnect?: WebSocketDisconnectDiagnostic;
38
+ lastUpgradeRejection?: WebSocketUpgradeRejectionDiagnostic;
39
+ }
3
40
  /** Adapts only the transport; authenticated HTTP routing remains the policy authority. */
4
41
  export declare class ResponsesWebSocketGateway {
5
42
  private readonly upstreamFetch;
6
43
  private readonly options;
7
- readonly stats: {
8
- connections: number;
9
- upstreamConnections: number;
10
- upstreamRequests: number;
11
- };
44
+ readonly stats: WebSocketStats;
12
45
  private readonly contexts;
13
46
  private readonly scope;
14
47
  private readonly sessions;
48
+ /** Credential-hashed, bounded API handshake backoff survives local reconnects. */
49
+ private readonly apiHandshakeBackoff;
50
+ private readonly websocketFailures;
15
51
  private readonly wss;
16
52
  private readonly maxBytes;
17
53
  /** Loopback hops must never go through an environment HTTP proxy (NODE_USE_ENV_PROXY). */
@@ -23,8 +59,15 @@ export declare class ResponsesWebSocketGateway {
23
59
  routeModel?: (model: string, headers: Headers) => string;
24
60
  });
25
61
  readonly fetch: typeof fetch;
62
+ private rememberDisconnect;
63
+ private needsHttpFallback;
64
+ /** One writer for the upgrade-rejection diagnostic so the two reject paths cannot drift. */
65
+ private recordUpgradeRejection;
66
+ /** Release idle setup sockets only; active work and generated history stay pinned. */
67
+ private reclaimIdleSession;
26
68
  handle(req: IncomingMessage, handler: () => Promise<void>): void;
27
69
  attach(server: Server, baseUrl: string): void;
28
70
  private serve;
29
71
  close(): void;
30
72
  }
73
+ export {};
@@ -1,3 +1,5 @@
1
+ import { MAX_API_ROUTES } from "../api-route-store.js";
2
+ import { OPENAI_HEADER_VALUES } from "../constants.js";
1
3
  import { ClientCancellationError } from "../request/client-cancellation.js";
2
4
  import { AsyncLocalStorage } from "node:async_hooks";
3
5
  import { createHash, randomUUID } from "node:crypto";
@@ -7,6 +9,13 @@ import { parseModelRoute } from "../model-route-policy.js";
7
9
  import { isRecord } from "../utils.js";
8
10
  import { ResponseOutputHistory, hasOrphanToolResult } from "./response-output-history.js";
9
11
  const CONTEXT_HEADER = "x-local-responses-transport";
12
+ const HTTP_FALLBACK_COOLDOWN_MS = 60_000;
13
+ const MAX_HTTP_FALLBACK_SCOPES = 256;
14
+ const HTTP_FALLBACK_FAILURE_THRESHOLD = 2;
15
+ const MAX_CLIENT_SESSIONS = 64;
16
+ // Conservative request ceiling below observed upstream 1009 rejections. This
17
+ // is a local routing guard, not a claim about the upstream's undocumented cap.
18
+ const MAX_UPSTREAM_WEBSOCKET_REQUEST_BYTES = 8 * 1024 * 1024;
10
19
  const terminal = new Set([
11
20
  "response.completed",
12
21
  "response.failed",
@@ -14,6 +23,28 @@ const terminal = new Set([
14
23
  "response.cancelled",
15
24
  "error",
16
25
  ]);
26
+ /** A thread-scoped hint only; authentication is checked separately before use. */
27
+ function fallbackScope(req) {
28
+ // Native clients can share a session across threads. Prefer the conversation
29
+ // identity so a failed thread does not downgrade a healthy sibling.
30
+ // Current native clients send hyphenated names; retain older underscore
31
+ // aliases, with thread identity taking priority over a shared session.
32
+ const identity = ["thread-id", "conversation_id", "session-id", "session_id"]
33
+ .map(name => req.headers[name])
34
+ .find((value) => typeof value === "string" && !!value.trim())
35
+ ?.trim();
36
+ if (!identity || identity.length > 1024)
37
+ return undefined;
38
+ const bearer = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
39
+ const apiKey = req.headers["x-api-key"];
40
+ const credentials = [...new Set([
41
+ bearer,
42
+ typeof apiKey === "string" ? apiKey.trim() : undefined,
43
+ ].filter((value) => !!value))].sort();
44
+ if (!credentials.length)
45
+ return undefined;
46
+ return createHash("sha256").update(JSON.stringify([credentials, identity])).digest("hex");
47
+ }
17
48
  /** Headers that describe the synthetic body or the upgrade itself, never the upstream decision. */
18
49
  const LOCAL_ONLY_HEADERS = /^(connection|upgrade|keep-alive|sec-websocket-.*|content-length|content-encoding|content-type|transfer-encoding|set-cookie)$/i;
19
50
  function upstreamHeaders(source) {
@@ -32,12 +63,12 @@ function upstreamHeaders(source) {
32
63
  }
33
64
  return headers;
34
65
  }
35
- /** Union of comma-separated OpenAI-Beta features; the client's own flags are kept. */
66
+ /** Remove the router's HTTP-only flag; preserve every explicit client beta. */
36
67
  function mergeBeta(current, client) {
37
- if (!client)
38
- return current;
39
- const features = [...(current ?? "").split(","), ...client.split(",")].map(item => item.trim()).filter(Boolean);
40
- return [...new Set(features)].join(", ") || null;
68
+ const routingFlags = (current ?? "").split(",").map(item => item.trim())
69
+ .filter(item => item && item !== OPENAI_HEADER_VALUES.BETA_RESPONSES);
70
+ const clientFlags = (client ?? "").split(",").map(item => item.trim()).filter(Boolean);
71
+ return [...new Set([...routingFlags, ...clientFlags])].join(", ") || null;
41
72
  }
42
73
  function wireError(code, status = 400) {
43
74
  return {
@@ -60,23 +91,35 @@ function bodyJson(body) {
60
91
  throw Error("Invalid response body");
61
92
  return value;
62
93
  }
94
+ function transportErrorCode(error) {
95
+ const code = isRecord(error) ? error.code : undefined;
96
+ return code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" ? code : "OTHER";
97
+ }
63
98
  /** Per-client memory only; credentials never leave the existing routing boundary. */
64
99
  class SocketSession {
65
100
  maxBytes;
66
101
  stats;
67
102
  httpFetch;
103
+ onDisconnect;
104
+ onComplete;
105
+ needsHttpRecovery;
106
+ httpOnly;
68
107
  channels = new Map();
108
+ openedAt = new WeakMap();
109
+ transportEvidence = new WeakMap();
69
110
  owners = new Map();
70
111
  chains = new Map();
71
- /** Credentials whose WebSocket handshake was refused; they use HTTP for the session. */
72
- httpOnly = new Set();
73
112
  bytes = 0;
74
113
  controller;
75
114
  closed = false;
76
- constructor(maxBytes, stats, httpFetch = fetch) {
115
+ constructor(maxBytes, stats, httpFetch = fetch, onDisconnect, onComplete, needsHttpRecovery = () => false, httpOnly = new Map()) {
77
116
  this.maxBytes = maxBytes;
78
117
  this.stats = stats;
79
118
  this.httpFetch = httpFetch;
119
+ this.onDisconnect = onDisconnect;
120
+ this.onComplete = onComplete;
121
+ this.needsHttpRecovery = needsHttpRecovery;
122
+ this.httpOnly = httpOnly;
80
123
  }
81
124
  close() {
82
125
  this.closed = true;
@@ -91,7 +134,7 @@ class SocketSession {
91
134
  remember(id, body, output, pool) {
92
135
  const bytes = Buffer.byteLength(JSON.stringify({ body, output }));
93
136
  if (bytes > this.maxBytes)
94
- return;
137
+ return false;
95
138
  const old = this.chains.get(id);
96
139
  if (old)
97
140
  this.bytes -= old.bytes;
@@ -107,6 +150,21 @@ class SocketSession {
107
150
  this.chains.delete(first);
108
151
  this.owners.delete(first);
109
152
  }
153
+ return true;
154
+ }
155
+ httpRecovery(input, init, body) {
156
+ // generate=false is a WebSocket-only prewarm control. Never turn a
157
+ // transport warmup into a generating HTTP request. The local chain is
158
+ // remembered by serve(), so the next create can reconstruct its input.
159
+ if (body.generate === false) {
160
+ return Promise.resolve(new Response(`data: ${JSON.stringify({
161
+ type: "response.completed",
162
+ response: { id: `resp_local_prewarm_${randomUUID()}`, object: "response", status: "completed", output: [] },
163
+ })}\n\n`, { headers: { "content-type": "text/event-stream" } }));
164
+ }
165
+ // This is a newly submitted create. Never retry a create already sent
166
+ // over a WebSocket; its generation outcome can be ambiguous.
167
+ return this.httpFetch(input, init);
110
168
  }
111
169
  async fetch(input, init, context) {
112
170
  if (context.signal.aborted || this.closed)
@@ -114,11 +172,14 @@ class SocketSession {
114
172
  const url = new URL(String(input));
115
173
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
116
174
  const headers = new Headers(init.headers);
117
- // The routing pipeline stamps its HTTP beta flag; the native client's
118
- // WebSocket flag (e.g. responses_websockets=<date>) must survive the hop.
175
+ // The OAuth HTTP beta selects an incompatible upstream protocol when
176
+ // combined with the native WebSocket beta (metadata then close 1011).
177
+ // Keep the original client flags, without the router's HTTP injection.
119
178
  const beta = mergeBeta(headers.get("openai-beta"), context.clientBeta);
120
179
  if (beta)
121
180
  headers.set("openai-beta", beta);
181
+ else
182
+ headers.delete("openai-beta");
122
183
  const key = createHash("sha256")
123
184
  .update(url.toString())
124
185
  .update("\0")
@@ -126,12 +187,15 @@ class SocketSession {
126
187
  .update("\0")
127
188
  .update(headers.get("chatgpt-account-id") ?? "")
128
189
  .digest("hex");
129
- // API/ZDR credentials are not ChatGPT workspaces: when their WebSocket
130
- // handshake is refused, the turn (and the session) uses plain HTTP.
131
190
  const apiCredential = !headers.has("chatgpt-account-id");
132
- if (apiCredential && this.httpOnly.has(key))
133
- return this.httpFetch(input, init);
134
191
  const body = bodyJson(init.body);
192
+ for (const [credential, until] of this.httpOnly) {
193
+ if (until <= Date.now())
194
+ this.httpOnly.delete(credential);
195
+ }
196
+ if (this.needsHttpRecovery() || (apiCredential && this.httpOnly.has(key))) {
197
+ return this.httpRecovery(input, init, body);
198
+ }
135
199
  delete body.stream;
136
200
  delete body.background;
137
201
  body.store = false;
@@ -151,6 +215,13 @@ class SocketSession {
151
215
  else if (context.previousId && hasOrphanToolResult(body.input)) {
152
216
  return Response.json(wireError("previous_response_not_found"), { status: 400 });
153
217
  }
218
+ const serializedBody = JSON.stringify(body);
219
+ // Measure the actual UTF-8 create after connection-local delta reduction.
220
+ // HTTP receives the original reconstructed request, never a WS-only delta.
221
+ if (Buffer.byteLength(serializedBody) > MAX_UPSTREAM_WEBSOCKET_REQUEST_BYTES) {
222
+ return this.httpRecovery(input, init, body);
223
+ }
224
+ const reusedConnection = !!socket;
154
225
  let handshakeHeaders;
155
226
  if (!socket) {
156
227
  if (context.signal.aborted || this.closed)
@@ -177,7 +248,16 @@ class SocketSession {
177
248
  followRedirects: false,
178
249
  handshakeTimeout: 15000,
179
250
  maxPayload: this.maxBytes,
180
- perMessageDeflate: false,
251
+ // Offer native compression with modest zlib work and request dictionary
252
+ // resets. Message limits also apply after inflation; peers can decline
253
+ // the extension and continue using WebSockets.
254
+ perMessageDeflate: {
255
+ clientNoContextTakeover: true,
256
+ serverNoContextTakeover: true,
257
+ concurrencyLimit: 4,
258
+ threshold: 1024,
259
+ zlibDeflateOptions: { level: 3 },
260
+ },
181
261
  });
182
262
  const opening = socket;
183
263
  const rejected = await new Promise((resolve, reject) => {
@@ -194,6 +274,20 @@ class SocketSession {
194
274
  init.signal?.addEventListener("abort", abort, { once: true });
195
275
  opening.once("upgrade", (response) => {
196
276
  handshakeHeaders = upstreamHeaders(response.headers);
277
+ // ws consumes TCP/TLS errors without re-emitting them. Observe
278
+ // the public upgrade socket before its close handler runs.
279
+ const tcp = response.socket;
280
+ const evidence = { tcpEndReceived: false };
281
+ this.transportEvidence.set(opening, evidence);
282
+ const onEnd = () => { evidence.tcpEndReceived = true; };
283
+ const onError = (error) => { evidence.transportErrorCode = transportErrorCode(error); };
284
+ tcp.once("end", onEnd);
285
+ tcp.once("error", onError);
286
+ tcp.once("close", (hadError) => {
287
+ evidence.tcpCloseHadError = hadError;
288
+ tcp.off("end", onEnd);
289
+ tcp.off("error", onError);
290
+ });
197
291
  });
198
292
  opening.once("open", () => {
199
293
  cleanup();
@@ -221,37 +315,70 @@ class SocketSession {
221
315
  if (rejected) {
222
316
  if (!apiCredential)
223
317
  return rejected;
224
- this.httpOnly.add(key);
225
- return this.httpFetch(input, init);
318
+ if (!this.httpOnly.has(key) && this.httpOnly.size >= MAX_API_ROUTES) {
319
+ const oldest = this.httpOnly.keys().next().value;
320
+ if (oldest)
321
+ this.httpOnly.delete(oldest);
322
+ }
323
+ this.httpOnly.set(key, Date.now() + HTTP_FALLBACK_COOLDOWN_MS);
324
+ return this.httpRecovery(input, init, body);
226
325
  }
326
+ this.openedAt.set(socket, Date.now());
227
327
  this.channels.set(key, socket);
228
328
  }
229
329
  const active = socket;
230
330
  return new Promise((resolve, reject) => {
231
331
  let accepted = false, finished = false;
332
+ let bodyCancelled = false;
333
+ let eventCount = 0;
334
+ let sendCompleted = false;
335
+ let requestTransportErrorCode;
232
336
  let streamController;
233
337
  const stream = new ReadableStream({
234
338
  start(controller) {
235
339
  streamController = controller;
236
340
  },
237
341
  cancel() {
342
+ bodyCancelled = true;
238
343
  active.terminate();
239
344
  },
240
345
  }, { highWaterMark: this.maxBytes, size: (chunk) => chunk.byteLength });
241
346
  const cleanup = () => {
242
347
  active.off("message", onMessage);
243
- active.off("close", onClose);
244
- active.off("error", onClose);
348
+ active.off("close", onSocketClose);
349
+ active.off("error", onError);
245
350
  init.signal?.removeEventListener("abort", abort);
246
351
  context.signal.removeEventListener("abort", abort);
247
352
  };
248
- const onClose = () => {
353
+ const onClose = (closeCode, cause = "close") => {
249
354
  if (finished)
250
355
  return;
251
356
  finished = true;
252
357
  cleanup();
253
358
  this.channels.delete(key);
254
- const cancelled = context.signal.aborted || this.closed;
359
+ const cancelled = bodyCancelled || init.signal?.aborted || context.signal.aborted || this.closed;
360
+ if (!cancelled) {
361
+ this.onDisconnect?.();
362
+ const at = Date.now();
363
+ const transport = this.transportEvidence.get(active);
364
+ const errorCode = transport?.transportErrorCode ?? requestTransportErrorCode;
365
+ this.stats.lastDisconnect = {
366
+ at, cause, routeKind: context.routeKind,
367
+ connectionAgeMs: Math.max(0, at - (this.openedAt.get(active) ?? at)), eventCount,
368
+ stage: accepted ? "streaming" : "awaiting_first_event",
369
+ ...(typeof closeCode === "number" ? { closeCode } : {}),
370
+ requestBytes: Buffer.byteLength(serializedBody),
371
+ ...(Array.isArray(body.input) ? { inputItemCount: body.input.length } : {}),
372
+ isPrewarm: body.generate === false,
373
+ hasPreviousResponse: typeof body.previous_response_id === "string" && body.previous_response_id.length > 0,
374
+ reusedConnection, sendCompleted,
375
+ compressionNegotiated: active.extensions.split(",").some(extension => extension.trim() === "permessage-deflate"),
376
+ ...(transport ? { tcpEndReceived: transport.tcpEndReceived,
377
+ ...(transport.tcpCloseHadError !== undefined ? { tcpCloseHadError: transport.tcpCloseHadError } : {}),
378
+ } : {}),
379
+ ...(errorCode ? { transportErrorCode: errorCode } : {}),
380
+ };
381
+ }
255
382
  // Once create has been sent, a disconnect cannot prove generation
256
383
  // did not start. Never replay it, even before the first event.
257
384
  const error = cancelled
@@ -269,8 +396,14 @@ class SocketSession {
269
396
  }));
270
397
  }
271
398
  };
399
+ const onSocketClose = (code) => onClose(code);
400
+ const onError = (error) => {
401
+ requestTransportErrorCode = transportErrorCode(error);
402
+ onClose(undefined, "transport_error");
403
+ };
272
404
  const abort = () => active.terminate();
273
405
  const onMessage = (raw, isBinary) => {
406
+ let failureCause = "invalid_event";
274
407
  try {
275
408
  if (isBinary)
276
409
  throw Error("Unexpected binary response");
@@ -278,6 +411,7 @@ class SocketSession {
278
411
  const event = JSON.parse(text);
279
412
  if (!isRecord(event) || typeof event.type !== "string")
280
413
  throw Error("Invalid response event");
414
+ eventCount++;
281
415
  if (event.type === "error" && !accepted) {
282
416
  finished = true;
283
417
  cleanup();
@@ -297,8 +431,10 @@ class SocketSession {
297
431
  headers: { ...handshakeHeaders, "content-type": "text/event-stream" },
298
432
  }));
299
433
  }
300
- if ((streamController.desiredSize ?? 0) < -this.maxBytes)
434
+ if ((streamController.desiredSize ?? 0) < -this.maxBytes) {
435
+ failureCause = "buffer_limit";
301
436
  throw Error("Upstream buffer exceeded");
437
+ }
302
438
  streamController.enqueue(Buffer.from(`data: ${text}\n\n`));
303
439
  if (isRecord(event.response) &&
304
440
  typeof event.response.id === "string") {
@@ -310,19 +446,23 @@ class SocketSession {
310
446
  }
311
447
  }
312
448
  if (terminal.has(event.type)) {
449
+ // A prewarm completion proves only connection setup, not that
450
+ // generation works. It must not erase a real disconnect history.
451
+ if (event.type === "response.completed" && body.generate !== false)
452
+ this.onComplete?.();
313
453
  finished = true;
314
454
  cleanup();
315
455
  streamController.close();
316
456
  }
317
457
  }
318
458
  catch {
459
+ onClose(undefined, failureCause);
319
460
  active.terminate();
320
- onClose();
321
461
  }
322
462
  };
323
463
  active.on("message", onMessage);
324
- active.once("close", onClose);
325
- active.once("error", onClose);
464
+ active.once("close", onSocketClose);
465
+ active.once("error", onError);
326
466
  init.signal?.addEventListener("abort", abort, { once: true });
327
467
  context.signal.addEventListener("abort", abort, { once: true });
328
468
  if (init.signal?.aborted || context.signal.aborted) {
@@ -330,9 +470,14 @@ class SocketSession {
330
470
  return;
331
471
  }
332
472
  this.stats.upstreamRequests++;
333
- active.send(JSON.stringify(body), (error) => {
334
- if (error)
335
- onClose();
473
+ active.send(serializedBody, (error) => {
474
+ if (error) {
475
+ requestTransportErrorCode = transportErrorCode(error);
476
+ onClose(undefined, "send_error");
477
+ active.terminate();
478
+ }
479
+ else
480
+ sendCompleted = true;
336
481
  });
337
482
  });
338
483
  }
@@ -343,12 +488,17 @@ export class ResponsesWebSocketGateway {
343
488
  options;
344
489
  stats = {
345
490
  connections: 0,
491
+ activeConnections: 0,
492
+ capacityReclaims: 0,
346
493
  upstreamConnections: 0,
347
494
  upstreamRequests: 0,
348
495
  };
349
496
  contexts = new Map();
350
497
  scope = new AsyncLocalStorage();
351
- sessions = new Set();
498
+ sessions = new Map();
499
+ /** Credential-hashed, bounded API handshake backoff survives local reconnects. */
500
+ apiHandshakeBackoff = new Map();
501
+ websocketFailures = new Map();
352
502
  wss;
353
503
  maxBytes;
354
504
  /** Loopback hops must never go through an environment HTTP proxy (NODE_USE_ENV_PROXY). */
@@ -362,6 +512,22 @@ export class ResponsesWebSocketGateway {
362
512
  noServer: true,
363
513
  maxPayload: this.maxBytes,
364
514
  perMessageDeflate: false,
515
+ // ws validates method/key/version/subprotocol before this synchronous gate.
516
+ // An invalid newcomer must never evict an existing usable connection.
517
+ verifyClient: ({ req }, done) => {
518
+ if (req.socket.destroyed || !req.socket.readable || !req.socket.writable) {
519
+ done(false, 503);
520
+ return;
521
+ }
522
+ if (this.sessions.size >= MAX_CLIENT_SESSIONS && !this.reclaimIdleSession()) {
523
+ // Capacity is not an authentication failure. Native Codex handles
524
+ // 426 immediately instead of retrying five rejected handshakes.
525
+ this.recordUpgradeRejection("capacity", 426);
526
+ done(false, 426);
527
+ return;
528
+ }
529
+ done(true);
530
+ },
365
531
  });
366
532
  }
367
533
  fetch = async (input, init) => {
@@ -372,6 +538,54 @@ export class ResponsesWebSocketGateway {
372
538
  return context.session.fetch(input, init, context);
373
539
  return this.upstreamFetch(input, init);
374
540
  };
541
+ rememberDisconnect(scope) {
542
+ if (!scope)
543
+ return;
544
+ const now = Date.now();
545
+ for (const [key, failure] of this.websocketFailures) {
546
+ if (failure.until <= now)
547
+ this.websocketFailures.delete(key);
548
+ }
549
+ const count = Math.min((this.websocketFailures.get(scope)?.count ?? 0) + 1, HTTP_FALLBACK_FAILURE_THRESHOLD);
550
+ this.websocketFailures.delete(scope);
551
+ this.websocketFailures.set(scope, { until: now + HTTP_FALLBACK_COOLDOWN_MS, count });
552
+ while (this.websocketFailures.size > MAX_HTTP_FALLBACK_SCOPES) {
553
+ const oldest = this.websocketFailures.keys().next().value;
554
+ if (oldest === undefined)
555
+ break;
556
+ this.websocketFailures.delete(oldest);
557
+ }
558
+ }
559
+ needsHttpFallback(req) {
560
+ const scope = fallbackScope(req);
561
+ if (!scope)
562
+ return false;
563
+ const failure = this.websocketFailures.get(scope);
564
+ if (!failure)
565
+ return false;
566
+ if (failure.until > Date.now())
567
+ return failure.count >= HTTP_FALLBACK_FAILURE_THRESHOLD;
568
+ this.websocketFailures.delete(scope);
569
+ return false;
570
+ }
571
+ /** One writer for the upgrade-rejection diagnostic so the two reject paths cannot drift. */
572
+ recordUpgradeRejection(reason, status) {
573
+ this.stats.lastUpgradeRejection = { at: Date.now(), reason, status, activeConnections: this.sessions.size };
574
+ }
575
+ /** Release idle setup sockets only; active work and generated history stay pinned. */
576
+ reclaimIdleSession() {
577
+ for (const [session, client] of this.sessions) {
578
+ if (!client.canReclaim())
579
+ continue;
580
+ this.sessions.delete(session);
581
+ this.stats.activeConnections = this.sessions.size;
582
+ this.stats.capacityReclaims++;
583
+ session.close();
584
+ client.socket.terminate();
585
+ return true;
586
+ }
587
+ return false;
588
+ }
375
589
  handle(req, handler) {
376
590
  const id = req.headers[CONTEXT_HEADER];
377
591
  delete req.headers[CONTEXT_HEADER];
@@ -394,6 +608,12 @@ export class ResponsesWebSocketGateway {
394
608
  return;
395
609
  socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
396
610
  };
611
+ const rejectAdmission = (reason, status) => {
612
+ if (socket.destroyed)
613
+ return;
614
+ this.recordUpgradeRejection(reason, status);
615
+ reject(status);
616
+ };
397
617
  void (async () => {
398
618
  const credentials = {};
399
619
  for (const name of ["authorization", "x-api-key"]) {
@@ -414,7 +634,7 @@ export class ResponsesWebSocketGateway {
414
634
  return;
415
635
  }
416
636
  if (req.headers.origin) {
417
- reject(403);
637
+ rejectAdmission("origin", 403);
418
638
  return;
419
639
  }
420
640
  if (await this.options.upgrade?.(req, socket, head))
@@ -428,8 +648,11 @@ export class ResponsesWebSocketGateway {
428
648
  reject(404);
429
649
  return;
430
650
  }
431
- if (req.headers.origin || this.sessions.size >= 64) {
432
- reject(403);
651
+ // Keep native transport on WebSocket during temporary upstream
652
+ // HTTP recovery. At capacity, preserve admission behavior rather
653
+ // than evicting another session just to serve a recovery scope.
654
+ if (this.needsHttpFallback(req) && this.sessions.size >= MAX_CLIENT_SESSIONS) {
655
+ rejectAdmission("repeated_disconnect", 426);
433
656
  return;
434
657
  }
435
658
  if (socket.destroyed)
@@ -440,10 +663,18 @@ export class ResponsesWebSocketGateway {
440
663
  }
441
664
  serve(ws, req, baseUrl) {
442
665
  this.stats.connections++;
443
- const session = new SocketSession(this.maxBytes, this.stats, this.upstreamFetch);
444
- this.sessions.add(session);
666
+ const scope = fallbackScope(req);
667
+ const session = new SocketSession(this.maxBytes, this.stats, this.upstreamFetch, () => this.rememberDisconnect(scope), () => { if (scope)
668
+ this.websocketFailures.delete(scope); }, () => this.needsHttpFallback(req), this.apiHandshakeBackoff);
445
669
  let queue = Promise.resolve(), queued = 0, queuedBytes = 0;
446
670
  let generation = 0;
671
+ // Pending work is protected by queued/controller; rejected creates retain no history.
672
+ let hasGeneratedHistory = false;
673
+ this.sessions.set(session, {
674
+ socket: ws,
675
+ canReclaim: () => !hasGeneratedHistory && queued === 0 && !session.controller && !session.closed && ws.readyState === WebSocket.OPEN,
676
+ });
677
+ this.stats.activeConnections = this.sessions.size;
447
678
  const send = (event) => {
448
679
  if (ws.readyState !== WebSocket.OPEN)
449
680
  return;
@@ -457,6 +688,7 @@ export class ResponsesWebSocketGateway {
457
688
  ws.once("close", () => {
458
689
  session.close();
459
690
  this.sessions.delete(session);
691
+ this.stats.activeConnections = this.sessions.size;
460
692
  });
461
693
  ws.on("message", (raw, isBinary) => {
462
694
  let event;
@@ -548,7 +780,7 @@ export class ResponsesWebSocketGateway {
548
780
  const controller = new AbortController();
549
781
  session.controller = controller;
550
782
  const clientBeta = req.headers["openai-beta"];
551
- this.contexts.set(id, { session, previousId, delta: event.input, signal: controller.signal, clientBeta: typeof clientBeta === "string" ? clientBeta : undefined });
783
+ this.contexts.set(id, { routeKind: pool, session, previousId, delta: event.input, signal: controller.signal, clientBeta: typeof clientBeta === "string" ? clientBeta : undefined });
552
784
  const headers = new Headers();
553
785
  for (const [key, value] of Object.entries(req.headers)) {
554
786
  if (typeof value === "string" &&
@@ -600,8 +832,8 @@ export class ResponsesWebSocketGateway {
600
832
  isRecord(parsed.response) &&
601
833
  typeof parsed.response.id === "string") {
602
834
  const output = outputHistory.finish(parsed.response.output);
603
- if (output)
604
- session.remember(parsed.response.id, body, output, pool);
835
+ if (output && session.remember(parsed.response.id, body, output, pool) && body.generate !== false)
836
+ hasGeneratedHistory = true;
605
837
  }
606
838
  send(parsed);
607
839
  }
@@ -627,9 +859,13 @@ export class ResponsesWebSocketGateway {
627
859
  close() {
628
860
  for (const ws of this.wss.clients)
629
861
  ws.terminate();
630
- for (const session of this.sessions)
862
+ for (const session of this.sessions.keys())
631
863
  session.close();
864
+ this.sessions.clear();
865
+ this.stats.activeConnections = 0;
632
866
  this.contexts.clear();
867
+ this.websocketFailures.clear();
868
+ this.apiHandshakeBackoff.clear();
633
869
  this.wss.close();
634
870
  void this.loopback.close().catch(() => undefined);
635
871
  }