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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +4 -2
- package/config/schema/config.schema.json +8 -0
- package/dist/lib/api-route-store.d.ts +1 -0
- package/dist/lib/api-route-store.js +2 -1
- 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/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 +4 -0
- package/dist/lib/codex-manager/commands/status.js +476 -5
- package/dist/lib/codex-manager/help.js +3 -1
- package/dist/lib/codex-manager.js +98 -29
- package/dist/lib/config.js +3 -0
- package/dist/lib/locale.d.ts +7 -0
- package/dist/lib/locale.js +35 -0
- 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 +2 -0
- package/dist/lib/runtime/account-model-catalog.js +7 -2
- package/dist/lib/runtime/app-bind.d.ts +4 -1
- package/dist/lib/runtime/app-bind.js +63 -3
- package/dist/lib/runtime/responses-websocket.d.ts +30 -0
- package/dist/lib/runtime/responses-websocket.js +243 -29
- package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
- package/dist/lib/runtime-rotation-proxy.js +30 -9
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.js +1 -0
- 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 +45 -1
- package/scripts/codex-routing.js +1 -0
- 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
|
-
/**
|
|
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,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
|
|
119
|
-
// 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.
|
|
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
|
-
|
|
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.
|
|
226
|
-
|
|
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 = () =>
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
|
466
|
-
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);
|
|
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
|
-
|
|
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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
2200
|
-
//
|
|
2201
|
-
//
|
|
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
|
};
|
package/dist/lib/schemas.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/schemas.js
CHANGED
|
@@ -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;
|