codex-multi-auth 2.22.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 (43) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +4 -2
  3. package/config/schema/config.schema.json +8 -0
  4. package/dist/lib/api-route-store.d.ts +1 -0
  5. package/dist/lib/api-route-store.js +2 -1
  6. package/dist/lib/auth/account-access.js +2 -1
  7. package/dist/lib/auth/auth.js +3 -2
  8. package/dist/lib/auth/device-auth.js +2 -1
  9. package/dist/lib/auth/http.d.ts +1 -0
  10. package/dist/lib/auth/http.js +1 -0
  11. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  12. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  13. package/dist/lib/codex-manager/commands/locale.js +25 -0
  14. package/dist/lib/codex-manager/commands/status.d.ts +4 -0
  15. package/dist/lib/codex-manager/commands/status.js +476 -5
  16. package/dist/lib/codex-manager/help.js +3 -1
  17. package/dist/lib/codex-manager.js +98 -29
  18. package/dist/lib/config.js +3 -0
  19. package/dist/lib/locale.d.ts +7 -0
  20. package/dist/lib/locale.js +35 -0
  21. package/dist/lib/quota-probe.js +2 -1
  22. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  23. package/dist/lib/request/proxy-fetch.js +37 -0
  24. package/dist/lib/request/url-rewriting.js +17 -0
  25. package/dist/lib/runtime/account-model-catalog.d.ts +2 -0
  26. package/dist/lib/runtime/account-model-catalog.js +7 -2
  27. package/dist/lib/runtime/app-bind.d.ts +4 -1
  28. package/dist/lib/runtime/app-bind.js +63 -3
  29. package/dist/lib/runtime/responses-websocket.d.ts +30 -0
  30. package/dist/lib/runtime/responses-websocket.js +243 -29
  31. package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
  32. package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
  33. package/dist/lib/runtime-rotation-proxy.js +30 -9
  34. package/dist/lib/schemas.d.ts +5 -0
  35. package/dist/lib/schemas.js +1 -0
  36. package/dist/lib/web-dashboard-html.d.ts +1 -0
  37. package/dist/lib/web-dashboard-html.js +66 -0
  38. package/dist/lib/web-dashboard.d.ts +36 -0
  39. package/dist/lib/web-dashboard.js +322 -0
  40. package/package.json +3 -1
  41. package/scripts/codex-app-router.js +45 -1
  42. package/scripts/codex-routing.js +1 -0
  43. package/scripts/codex.js +150 -13
@@ -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,24 +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();
69
108
  openedAt = new WeakMap();
109
+ transportEvidence = new WeakMap();
70
110
  owners = new Map();
71
111
  chains = new Map();
72
- /** Credentials whose WebSocket handshake was refused; they use HTTP for the session. */
73
- httpOnly = new Set();
74
112
  bytes = 0;
75
113
  controller;
76
114
  closed = false;
77
- constructor(maxBytes, stats, httpFetch = fetch) {
115
+ constructor(maxBytes, stats, httpFetch = fetch, onDisconnect, onComplete, needsHttpRecovery = () => false, httpOnly = new Map()) {
78
116
  this.maxBytes = maxBytes;
79
117
  this.stats = stats;
80
118
  this.httpFetch = httpFetch;
119
+ this.onDisconnect = onDisconnect;
120
+ this.onComplete = onComplete;
121
+ this.needsHttpRecovery = needsHttpRecovery;
122
+ this.httpOnly = httpOnly;
81
123
  }
82
124
  close() {
83
125
  this.closed = true;
@@ -92,7 +134,7 @@ class SocketSession {
92
134
  remember(id, body, output, pool) {
93
135
  const bytes = Buffer.byteLength(JSON.stringify({ body, output }));
94
136
  if (bytes > this.maxBytes)
95
- return;
137
+ return false;
96
138
  const old = this.chains.get(id);
97
139
  if (old)
98
140
  this.bytes -= old.bytes;
@@ -108,6 +150,21 @@ class SocketSession {
108
150
  this.chains.delete(first);
109
151
  this.owners.delete(first);
110
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);
111
168
  }
112
169
  async fetch(input, init, context) {
113
170
  if (context.signal.aborted || this.closed)
@@ -115,11 +172,14 @@ class SocketSession {
115
172
  const url = new URL(String(input));
116
173
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
117
174
  const headers = new Headers(init.headers);
118
- // The routing pipeline stamps its HTTP beta flag; the native client's
119
- // 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.
120
178
  const beta = mergeBeta(headers.get("openai-beta"), context.clientBeta);
121
179
  if (beta)
122
180
  headers.set("openai-beta", beta);
181
+ else
182
+ headers.delete("openai-beta");
123
183
  const key = createHash("sha256")
124
184
  .update(url.toString())
125
185
  .update("\0")
@@ -127,12 +187,15 @@ class SocketSession {
127
187
  .update("\0")
128
188
  .update(headers.get("chatgpt-account-id") ?? "")
129
189
  .digest("hex");
130
- // API/ZDR credentials are not ChatGPT workspaces: when their WebSocket
131
- // handshake is refused, the turn (and the session) uses plain HTTP.
132
190
  const apiCredential = !headers.has("chatgpt-account-id");
133
- if (apiCredential && this.httpOnly.has(key))
134
- return this.httpFetch(input, init);
135
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
+ }
136
199
  delete body.stream;
137
200
  delete body.background;
138
201
  body.store = false;
@@ -152,6 +215,13 @@ class SocketSession {
152
215
  else if (context.previousId && hasOrphanToolResult(body.input)) {
153
216
  return Response.json(wireError("previous_response_not_found"), { status: 400 });
154
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;
155
225
  let handshakeHeaders;
156
226
  if (!socket) {
157
227
  if (context.signal.aborted || this.closed)
@@ -178,7 +248,16 @@ class SocketSession {
178
248
  followRedirects: false,
179
249
  handshakeTimeout: 15000,
180
250
  maxPayload: this.maxBytes,
181
- 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
+ },
182
261
  });
183
262
  const opening = socket;
184
263
  const rejected = await new Promise((resolve, reject) => {
@@ -195,6 +274,20 @@ class SocketSession {
195
274
  init.signal?.addEventListener("abort", abort, { once: true });
196
275
  opening.once("upgrade", (response) => {
197
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
+ });
198
291
  });
199
292
  opening.once("open", () => {
200
293
  cleanup();
@@ -222,8 +315,13 @@ class SocketSession {
222
315
  if (rejected) {
223
316
  if (!apiCredential)
224
317
  return rejected;
225
- this.httpOnly.add(key);
226
- 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);
227
325
  }
228
326
  this.openedAt.set(socket, Date.now());
229
327
  this.channels.set(key, socket);
@@ -233,6 +331,8 @@ class SocketSession {
233
331
  let accepted = false, finished = false;
234
332
  let bodyCancelled = false;
235
333
  let eventCount = 0;
334
+ let sendCompleted = false;
335
+ let requestTransportErrorCode;
236
336
  let streamController;
237
337
  const stream = new ReadableStream({
238
338
  start(controller) {
@@ -258,12 +358,25 @@ class SocketSession {
258
358
  this.channels.delete(key);
259
359
  const cancelled = bodyCancelled || init.signal?.aborted || context.signal.aborted || this.closed;
260
360
  if (!cancelled) {
361
+ this.onDisconnect?.();
261
362
  const at = Date.now();
363
+ const transport = this.transportEvidence.get(active);
364
+ const errorCode = transport?.transportErrorCode ?? requestTransportErrorCode;
262
365
  this.stats.lastDisconnect = {
263
366
  at, cause, routeKind: context.routeKind,
264
367
  connectionAgeMs: Math.max(0, at - (this.openedAt.get(active) ?? at)), eventCount,
265
368
  stage: accepted ? "streaming" : "awaiting_first_event",
266
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 } : {}),
267
380
  };
268
381
  }
269
382
  // Once create has been sent, a disconnect cannot prove generation
@@ -284,7 +397,10 @@ class SocketSession {
284
397
  }
285
398
  };
286
399
  const onSocketClose = (code) => onClose(code);
287
- const onError = () => onClose(undefined, "transport_error");
400
+ const onError = (error) => {
401
+ requestTransportErrorCode = transportErrorCode(error);
402
+ onClose(undefined, "transport_error");
403
+ };
288
404
  const abort = () => active.terminate();
289
405
  const onMessage = (raw, isBinary) => {
290
406
  let failureCause = "invalid_event";
@@ -330,6 +446,10 @@ class SocketSession {
330
446
  }
331
447
  }
332
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?.();
333
453
  finished = true;
334
454
  cleanup();
335
455
  streamController.close();
@@ -350,11 +470,14 @@ class SocketSession {
350
470
  return;
351
471
  }
352
472
  this.stats.upstreamRequests++;
353
- active.send(JSON.stringify(body), (error) => {
473
+ active.send(serializedBody, (error) => {
354
474
  if (error) {
475
+ requestTransportErrorCode = transportErrorCode(error);
355
476
  onClose(undefined, "send_error");
356
477
  active.terminate();
357
478
  }
479
+ else
480
+ sendCompleted = true;
358
481
  });
359
482
  });
360
483
  }
@@ -365,12 +488,17 @@ export class ResponsesWebSocketGateway {
365
488
  options;
366
489
  stats = {
367
490
  connections: 0,
491
+ activeConnections: 0,
492
+ capacityReclaims: 0,
368
493
  upstreamConnections: 0,
369
494
  upstreamRequests: 0,
370
495
  };
371
496
  contexts = new Map();
372
497
  scope = new AsyncLocalStorage();
373
- 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();
374
502
  wss;
375
503
  maxBytes;
376
504
  /** Loopback hops must never go through an environment HTTP proxy (NODE_USE_ENV_PROXY). */
@@ -384,6 +512,22 @@ export class ResponsesWebSocketGateway {
384
512
  noServer: true,
385
513
  maxPayload: this.maxBytes,
386
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
+ },
387
531
  });
388
532
  }
389
533
  fetch = async (input, init) => {
@@ -394,6 +538,54 @@ export class ResponsesWebSocketGateway {
394
538
  return context.session.fetch(input, init, context);
395
539
  return this.upstreamFetch(input, init);
396
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
+ }
397
589
  handle(req, handler) {
398
590
  const id = req.headers[CONTEXT_HEADER];
399
591
  delete req.headers[CONTEXT_HEADER];
@@ -416,6 +608,12 @@ export class ResponsesWebSocketGateway {
416
608
  return;
417
609
  socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
418
610
  };
611
+ const rejectAdmission = (reason, status) => {
612
+ if (socket.destroyed)
613
+ return;
614
+ this.recordUpgradeRejection(reason, status);
615
+ reject(status);
616
+ };
419
617
  void (async () => {
420
618
  const credentials = {};
421
619
  for (const name of ["authorization", "x-api-key"]) {
@@ -436,7 +634,7 @@ export class ResponsesWebSocketGateway {
436
634
  return;
437
635
  }
438
636
  if (req.headers.origin) {
439
- reject(403);
637
+ rejectAdmission("origin", 403);
440
638
  return;
441
639
  }
442
640
  if (await this.options.upgrade?.(req, socket, head))
@@ -450,8 +648,11 @@ export class ResponsesWebSocketGateway {
450
648
  reject(404);
451
649
  return;
452
650
  }
453
- if (req.headers.origin || this.sessions.size >= 64) {
454
- 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);
455
656
  return;
456
657
  }
457
658
  if (socket.destroyed)
@@ -462,10 +663,18 @@ export class ResponsesWebSocketGateway {
462
663
  }
463
664
  serve(ws, req, baseUrl) {
464
665
  this.stats.connections++;
465
- const session = new SocketSession(this.maxBytes, this.stats, this.upstreamFetch);
466
- 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);
467
669
  let queue = Promise.resolve(), queued = 0, queuedBytes = 0;
468
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;
469
678
  const send = (event) => {
470
679
  if (ws.readyState !== WebSocket.OPEN)
471
680
  return;
@@ -479,6 +688,7 @@ export class ResponsesWebSocketGateway {
479
688
  ws.once("close", () => {
480
689
  session.close();
481
690
  this.sessions.delete(session);
691
+ this.stats.activeConnections = this.sessions.size;
482
692
  });
483
693
  ws.on("message", (raw, isBinary) => {
484
694
  let event;
@@ -622,8 +832,8 @@ export class ResponsesWebSocketGateway {
622
832
  isRecord(parsed.response) &&
623
833
  typeof parsed.response.id === "string") {
624
834
  const output = outputHistory.finish(parsed.response.output);
625
- if (output)
626
- 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;
627
837
  }
628
838
  send(parsed);
629
839
  }
@@ -649,9 +859,13 @@ export class ResponsesWebSocketGateway {
649
859
  close() {
650
860
  for (const ws of this.wss.clients)
651
861
  ws.terminate();
652
- for (const session of this.sessions)
862
+ for (const session of this.sessions.keys())
653
863
  session.close();
864
+ this.sessions.clear();
865
+ this.stats.activeConnections = 0;
654
866
  this.contexts.clear();
867
+ this.websocketFailures.clear();
868
+ this.apiHandshakeBackoff.clear();
655
869
  this.wss.close();
656
870
  void this.loopback.close().catch(() => undefined);
657
871
  }
@@ -62,16 +62,17 @@ export declare function chooseAccount(params: {
62
62
  /**
63
63
  * Admit a pinned account that is only blocked by its own cooldown.
64
64
  *
65
- * Set by the proxy for the RETRY passes of one pinned request, never for
66
- * the first pass. A cooldown exists to keep OTHER requests off a flaky
67
- * account; a retry budget exists to give THIS request another attempt.
65
+ * Set by the proxy for RETRY passes, or the first pass when a pin only
66
+ * has a shared network-error cooldown. Independent concurrent work must
67
+ * not fail solely because another request encountered a transport error.
68
+ * Other cooldown reasons still protect the pin from new requests.
68
69
  * Conflating them meant every transient branch cooled the pin down before
69
70
  * the next selection pass and the budget could never be spent — a pinned
70
71
  * request got exactly one upstream attempt under shipped defaults.
71
72
  *
72
73
  * Only the cooldown is waived. Rate limits, an open circuit, a disabled
73
74
  * account, workspace and policy blocks all still reject the pin, and the
74
- * cooldown itself stays on the account for everyone else and for the 503's
75
+ * cooldown itself stays on the account for unpinned selection and the 503's
75
76
  * `retry_after_ms`.
76
77
  *
77
78
  * One ordering note: `getAccountRuntimeSkipReason` reports the FIRST
@@ -1,4 +1,4 @@
1
- import type { WebSocketDisconnectDiagnostic } from "./responses-websocket.js";
1
+ import type { WebSocketDisconnectDiagnostic, WebSocketUpgradeRejectionDiagnostic } from "./responses-websocket.js";
2
2
  import type { QuotaCacheData } from "../quota-cache.js";
3
3
  import type { ApiRouteCredential } from "../api-route-store.js";
4
4
  import type { AccountStorageV3 } from "../storage.js";
@@ -24,6 +24,9 @@ export interface RuntimeRotationProxyStatus {
24
24
  lastStreamQuotaUpdateAt?: number;
25
25
  websocketConnections?: number;
26
26
  websocketUpstreamRequests?: number;
27
+ websocketActiveConnections?: number;
28
+ websocketCapacityReclaims?: number;
29
+ lastWebsocketUpgradeRejection?: WebSocketUpgradeRejectionDiagnostic;
27
30
  lastWebsocketDisconnect?: WebSocketDisconnectDiagnostic;
28
31
  startedAt: number;
29
32
  totalRequests: number;
@@ -2,6 +2,7 @@ import { createAutomaticApiCheck, startAutomaticApiChecks } from "./runtime/auto
2
2
  import { startAutomaticAccountChecks } from "./runtime/automatic-account-checks.js";
3
3
  import { createAutomaticSubscriptionCheck } from "./runtime/automatic-subscription-checks.js";
4
4
  import { ClientCancellationError } from "./request/client-cancellation.js";
5
+ import { fetchWithProxy } from "./request/proxy-fetch.js";
5
6
  import { createNativeAccountStorageReader } from "./runtime/native-account-storage.js";
6
7
  import { resetSnapshotQuota } from "./runtime/reset-credits.js";
7
8
  import { createResetCreditService, loadResetCreditState } from "./runtime/account-reset-credits.js";
@@ -879,7 +880,7 @@ export async function startRuntimeRotationProxy(options) {
879
880
  const routingMutexMode = getRoutingMutexMode(pluginConfig);
880
881
  activeAccountManager.setRoutingMutexMode(routingMutexMode);
881
882
  const schedulingStrategy = getSchedulingStrategy(pluginConfig);
882
- const fetchImpl = options.fetchImpl ?? fetch;
883
+ const fetchImpl = options.fetchImpl ?? fetchWithProxy;
883
884
  const host = options.host ?? DEFAULT_HOST;
884
885
  // Defense in depth (runtime-proxy-01): the proxy presents managed OAuth tokens
885
886
  // and must never be reachable off-box. It is loopback-only with NO opt-out —
@@ -1057,6 +1058,9 @@ export async function startRuntimeRotationProxy(options) {
1057
1058
  schedulingStrategy: state.schedulingStrategy,
1058
1059
  websocketConnections: websocketGateway.stats.connections,
1059
1060
  websocketUpstreamRequests: websocketGateway.stats.upstreamRequests,
1061
+ websocketActiveConnections: websocketGateway.stats.activeConnections,
1062
+ websocketCapacityReclaims: websocketGateway.stats.capacityReclaims,
1063
+ lastWebsocketUpgradeRejection: websocketGateway.stats.lastUpgradeRejection,
1060
1064
  lastWebsocketDisconnect: websocketGateway.stats.lastDisconnect,
1061
1065
  // Redact any email/token material that leaked into a raw upstream or
1062
1066
  // refresh error string before exposing it to status/report consumers
@@ -1875,13 +1879,23 @@ async function handleRequestInner(state, req, res, traceId) {
1875
1879
  models = await refresh;
1876
1880
  else {
1877
1881
  const cached = pickerSnapshot();
1878
- if (cached.length)
1882
+ // An API cache can be warm while this client-version's OAuth
1883
+ // catalog is still cold. Give subscription discovery the same
1884
+ // bounded opportunity as an entirely empty picker.
1885
+ const oauthReady = modelCatalog.cachedList(routableKeys).length > 0 ||
1886
+ routableKeys.every(key => modelCatalog.hasUsablePickerSnapshot(key));
1887
+ if (cached.length && oauthReady)
1879
1888
  models = cached;
1880
1889
  else {
1881
1890
  let timer;
1882
1891
  try {
1883
1892
  models = await Promise.race([refresh, new Promise(resolve => {
1884
- timer = setTimeout(() => resolve(pickerSnapshot()), 2000);
1893
+ timer = setTimeout(() => {
1894
+ // Do not let an incomplete API-only success replace the
1895
+ // native client's complete cached catalog for minutes.
1896
+ const missingSubscription = routableKeys.length > 0 && !modelCatalog.cachedList(routableKeys).length;
1897
+ resolve(missingSubscription && routableKeys.some(key => !modelCatalog.hasUsablePickerSnapshot(key)) ? [] : pickerSnapshot());
1898
+ }, 2000);
1885
1899
  })]);
1886
1900
  }
1887
1901
  finally {
@@ -1890,6 +1904,12 @@ async function handleRequestInner(state, req, res, traceId) {
1890
1904
  }
1891
1905
  }
1892
1906
  }
1907
+ // A completed failed fetch is still unknown, not a successful
1908
+ // empty catalog. Never replace native subscription entries with
1909
+ // an API-only success while a routable catalog is unknown.
1910
+ if (!forceCatalogRefresh && !models.some(model => !/^(api|zdr)\//.test(model.slug)) &&
1911
+ routableKeys.some(key => !modelCatalog.hasUsablePickerSnapshot(key)))
1912
+ models = [];
1893
1913
  res.setHeader("etag", setCatalogEtag(state, models));
1894
1914
  writeJson(res, models.length ? 200 : 503, models.length
1895
1915
  ? { models }
@@ -2194,12 +2214,13 @@ async function handleRequestInner(state, req, res, traceId) {
2194
2214
  stickyBoostByAccount: rotationStickyBoost,
2195
2215
  pidOffsetEnabled: state.pidOffsetEnabled,
2196
2216
  schedulingStrategy: state.schedulingStrategy,
2197
- // Only on a RETRY pass. The first pass still honors a cooldown
2198
- // another request left on the pin, so an already-cooling pinned
2199
- // account 503s immediately as before; from the second pass on,
2200
- // this request's own retry budget outranks the cooldown it just
2201
- // created for itself.
2202
- allowPinnedCooldown: isPinned && transientAttempts > 0,
2217
+ // A shared network cooldown is not evidence that the pinned
2218
+ // credentials cannot serve independent concurrent work. Let each
2219
+ // new request try; its own retries remain bounded and backed off.
2220
+ // Other initial cooldowns still block, as do quota/auth/policy
2221
+ // checks and the circuit admission immediately after selection.
2222
+ allowPinnedCooldown: isPinned && (transientAttempts > 0 ||
2223
+ (typeof pinnedIndex === "number" && accountManager.getAccountByIndex(pinnedIndex)?.cooldownReason === "network-error")),
2203
2224
  });
2204
2225
  return result;
2205
2226
  };
@@ -5,6 +5,11 @@
5
5
  */
6
6
  import { z } from "zod";
7
7
  export declare const PluginConfigSchema: z.ZodObject<{
8
+ locale: z.ZodOptional<z.ZodEnum<{
9
+ auto: "auto";
10
+ en: "en";
11
+ zh: "zh";
12
+ }>>;
8
13
  codexMode: z.ZodOptional<z.ZodBoolean>;
9
14
  codexRuntimeRotationProxy: z.ZodOptional<z.ZodBoolean>;
10
15
  codexTuiV2: z.ZodOptional<z.ZodBoolean>;
@@ -22,6 +22,7 @@ function schemaLog() {
22
22
  // Plugin Configuration Schema
23
23
  // ============================================================================
24
24
  export const PluginConfigSchema = z.object({
25
+ locale: z.enum(["auto", "en", "zh"]).optional(),
25
26
  codexMode: z.boolean().optional(),
26
27
  codexRuntimeRotationProxy: z.boolean().optional(),
27
28
  codexTuiV2: z.boolean().optional(),
@@ -0,0 +1 @@
1
+ export declare function renderDashboardHtml(token: string | null, locale?: "en" | "zh"): string;