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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +8 -5
- package/config/schema/config.schema.json +8 -0
- package/dist/lib/accounts.d.ts +5 -0
- package/dist/lib/accounts.js +33 -4
- package/dist/lib/api-route-store.d.ts +7 -0
- package/dist/lib/api-route-store.js +100 -31
- package/dist/lib/auth/account-access.js +2 -1
- package/dist/lib/auth/auth.js +3 -2
- package/dist/lib/auth/device-auth.js +2 -1
- package/dist/lib/auth/http.d.ts +1 -0
- package/dist/lib/auth/http.js +1 -0
- package/dist/lib/codex-manager/account-manager-commands.js +1 -0
- package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
- package/dist/lib/codex-manager/api-login-menu.js +4 -6
- package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
- package/dist/lib/codex-manager/commands/locale.js +25 -0
- package/dist/lib/codex-manager/commands/status.d.ts +5 -1
- package/dist/lib/codex-manager/commands/status.js +501 -13
- package/dist/lib/codex-manager/help.js +3 -1
- package/dist/lib/codex-manager.js +98 -23
- package/dist/lib/config.js +3 -0
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/locale.d.ts +7 -0
- package/dist/lib/locale.js +35 -0
- package/dist/lib/model-route-policy.d.ts +1 -0
- package/dist/lib/model-route-policy.js +2 -1
- package/dist/lib/quota-probe.js +2 -1
- package/dist/lib/request/proxy-fetch.d.ts +5 -0
- package/dist/lib/request/proxy-fetch.js +37 -0
- package/dist/lib/request/url-rewriting.js +17 -0
- package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
- package/dist/lib/runtime/account-model-catalog.js +7 -2
- package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
- package/dist/lib/runtime/api-model-capabilities.js +155 -24
- package/dist/lib/runtime/app-bind.d.ts +7 -0
- package/dist/lib/runtime/app-bind.js +92 -1
- package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
- package/dist/lib/runtime/automatic-api-checks.js +188 -0
- package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
- package/dist/lib/runtime/model-discovery-status.js +64 -49
- package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
- package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
- package/dist/lib/runtime/responses-websocket.d.ts +48 -5
- package/dist/lib/runtime/responses-websocket.js +275 -39
- package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
- package/dist/lib/runtime/rotation-account-selection.js +94 -4
- package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
- package/dist/lib/runtime/rotation-proxy-state.js +376 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
- package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
- package/dist/lib/runtime/rotation-token-refresh.js +218 -27
- package/dist/lib/runtime-rotation-proxy.js +601 -105
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.js +1 -0
- package/dist/lib/storage/credential-sidecars.js +34 -2
- package/dist/lib/storage/file-lock.d.ts +2 -0
- package/dist/lib/storage/file-lock.js +44 -30
- package/dist/lib/usage/ledger.js +1 -0
- package/dist/lib/usage/redaction.js +1 -0
- package/dist/lib/usage/types.d.ts +1 -1
- package/dist/lib/web-dashboard-html.d.ts +1 -0
- package/dist/lib/web-dashboard-html.js +66 -0
- package/dist/lib/web-dashboard.d.ts +36 -0
- package/dist/lib/web-dashboard.js +322 -0
- package/package.json +3 -1
- package/scripts/codex-app-router.js +86 -14
- package/scripts/codex-routing.js +1 -0
- 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
|
-
/**
|
|
66
|
+
/** Remove the router's HTTP-only flag; preserve every explicit client beta. */
|
|
36
67
|
function mergeBeta(current, client) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
return [...new Set(
|
|
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
|
|
118
|
-
// WebSocket
|
|
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
|
-
|
|
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.
|
|
225
|
-
|
|
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",
|
|
244
|
-
active.off("error",
|
|
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",
|
|
325
|
-
active.once("error",
|
|
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(
|
|
334
|
-
if (error)
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
444
|
-
this.
|
|
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
|
-
|
|
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
|
}
|