openai-ws-opencode 0.1.26 → 0.1.28
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/README.md +11 -4
- package/bin/setup.js +3 -3
- package/dist/constants.d.ts +4 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -2
- package/dist/constants.js.map +1 -1
- package/dist/models/catalog.d.ts.map +1 -1
- package/dist/models/catalog.js +11 -1
- package/dist/models/catalog.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +28 -16
- package/dist/plugin.js.map +1 -1
- package/dist/testing.d.ts +96 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +18 -2
- package/dist/testing.js.map +1 -1
- package/dist/transport/breaker.d.ts +41 -0
- package/dist/transport/breaker.d.ts.map +1 -0
- package/dist/transport/breaker.js +207 -0
- package/dist/transport/breaker.js.map +1 -0
- package/dist/transport/bridge.d.ts +6 -1
- package/dist/transport/bridge.d.ts.map +1 -1
- package/dist/transport/bridge.js +274 -72
- package/dist/transport/bridge.js.map +1 -1
- package/dist/transport/config.d.ts +37 -1
- package/dist/transport/config.d.ts.map +1 -1
- package/dist/transport/config.js +318 -6
- package/dist/transport/config.js.map +1 -1
- package/dist/transport/coordinator.d.ts +223 -0
- package/dist/transport/coordinator.d.ts.map +1 -0
- package/dist/transport/coordinator.js +506 -0
- package/dist/transport/coordinator.js.map +1 -0
- package/dist/transport/errors.d.ts +24 -0
- package/dist/transport/errors.d.ts.map +1 -0
- package/dist/transport/errors.js +49 -0
- package/dist/transport/errors.js.map +1 -0
- package/dist/transport/headers.d.ts +3 -3
- package/dist/transport/headers.d.ts.map +1 -1
- package/dist/transport/headers.js +32 -10
- package/dist/transport/headers.js.map +1 -1
- package/dist/transport/pool.d.ts +114 -7
- package/dist/transport/pool.d.ts.map +1 -1
- package/dist/transport/pool.js +473 -188
- package/dist/transport/pool.js.map +1 -1
- package/package.json +2 -2
package/dist/transport/pool.js
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { CODEX_WS_URL, OPENAI_MODEL_HEADER, OPENAI_WS_INSTALLATION_ID_ENV, RESPONSE_PROCESSED_DISABLE_ENV, RESPONSE_PROCESSED_ENV, X_CODEX_INSTALLATION_ID_HEADER, X_CODEX_TURN_STATE_HEADER, X_CODEX_WINDOW_ID_HEADER, X_MODELS_ETAG_HEADER, X_OPENAI_SUBAGENT_HEADER, X_REASONING_INCLUDED_HEADER, } from "../constants.js";
|
|
3
3
|
import { loadDefaultWebSocketConstructor } from "./bun-websocket.js";
|
|
4
|
+
import { createTransportBreaker } from "./breaker.js";
|
|
4
5
|
import { transportConfig } from "./config.js";
|
|
6
|
+
import { canonicalWebSocketOrigin, configureTransportCoordinator, getTransportCoordinator, resetTransportCoordinatorForTesting, } from "./coordinator.js";
|
|
7
|
+
import { isWebSocketTransportError, WebSocketTransportError } from "./errors.js";
|
|
5
8
|
let WebSocketImpl = loadDefaultWebSocketConstructor();
|
|
6
|
-
export class WebSocketPreStreamTransportError extends Error {
|
|
7
|
-
constructor(message) {
|
|
8
|
-
super(message);
|
|
9
|
-
this.name = "WebSocketPreStreamTransportError";
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
export function isWebSocketPreStreamTransportError(error) {
|
|
13
|
-
return error instanceof WebSocketPreStreamTransportError || (error instanceof Error && error.name === "WebSocketPreStreamTransportError");
|
|
14
|
-
}
|
|
15
9
|
export const connectionPool = [];
|
|
16
|
-
const acquisitionQueues = new Map();
|
|
17
10
|
const turnStateByContext = new Map();
|
|
18
11
|
const lastResponseIDByContext = new Map();
|
|
12
|
+
const scopeCooldowns = new Map();
|
|
13
|
+
const authEndpoints = new Map();
|
|
19
14
|
let nextConnectionID = 1;
|
|
15
|
+
let nextStreamID = 1;
|
|
20
16
|
export const readyState = {
|
|
21
17
|
CONNECTING: 0,
|
|
22
18
|
OPEN: 1,
|
|
@@ -43,6 +39,23 @@ function authScopeHash(headers) {
|
|
|
43
39
|
.join("\n");
|
|
44
40
|
return crypto.createHash("sha256").update(auth).digest("hex");
|
|
45
41
|
}
|
|
42
|
+
function accountFingerprint(headers) {
|
|
43
|
+
const account = headers["ChatGPT-Account-ID"] ?? headers["ChatGPT-Account-Id"] ?? headers["chatgpt-account-id"] ?? "";
|
|
44
|
+
if (account) {
|
|
45
|
+
return crypto.createHash("sha256").update(`acct:${account}`).digest("hex").slice(0, 32);
|
|
46
|
+
}
|
|
47
|
+
const auth = headers.Authorization ?? headers.authorization ?? "";
|
|
48
|
+
return crypto.createHash("sha256").update(`auth:${auth}`).digest("hex").slice(0, 32);
|
|
49
|
+
}
|
|
50
|
+
function breakerDomainKey(provider, origin, accountFp) {
|
|
51
|
+
return crypto.createHash("sha256").update(`${provider}\n${origin}\n${accountFp}`).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
function providerForUrl(wsUrl) {
|
|
54
|
+
return wsUrl === CODEX_WS_URL || wsUrl.includes("chatgpt.com/backend-api/codex") ? "codex" : "openai";
|
|
55
|
+
}
|
|
56
|
+
function endpointKey(wsUrl) {
|
|
57
|
+
return canonicalWebSocketOrigin(wsUrl);
|
|
58
|
+
}
|
|
46
59
|
function scopeKey(wsUrl, headers) {
|
|
47
60
|
return `${wsUrl}::${authScopeHash(headers)}`;
|
|
48
61
|
}
|
|
@@ -110,6 +123,82 @@ function finalizePending(pending) {
|
|
|
110
123
|
pending.onFinalize?.();
|
|
111
124
|
pending.onFinalize = undefined;
|
|
112
125
|
}
|
|
126
|
+
function transportError(options) {
|
|
127
|
+
return new WebSocketTransportError({
|
|
128
|
+
code: options.code,
|
|
129
|
+
phase: options.phase,
|
|
130
|
+
message: options.message,
|
|
131
|
+
fallbackEligible: options.fallbackEligible,
|
|
132
|
+
breakerEligible: options.breakerEligible ?? false,
|
|
133
|
+
safeToReplay: options.safeToReplay ?? options.fallbackEligible,
|
|
134
|
+
cause: options.cause,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function boundAuthEndpoints() {
|
|
138
|
+
const max = transportConfig.maxAccountingEntries;
|
|
139
|
+
while (authEndpoints.size > max) {
|
|
140
|
+
const oldest = authEndpoints.keys().next().value;
|
|
141
|
+
if (oldest === undefined)
|
|
142
|
+
break;
|
|
143
|
+
authEndpoints.delete(oldest);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function bindAuthGeneration(endpoint, hash) {
|
|
147
|
+
let state = authEndpoints.get(endpoint);
|
|
148
|
+
if (!state) {
|
|
149
|
+
state = { currentGeneration: 1, currentHash: hash, bindings: new Map([[hash, 1]]), order: [hash] };
|
|
150
|
+
authEndpoints.set(endpoint, state);
|
|
151
|
+
boundAuthEndpoints();
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
if (hash === state.currentHash)
|
|
155
|
+
return state.currentGeneration;
|
|
156
|
+
// ABA-safe: a remembered non-current binding must never become current.
|
|
157
|
+
const remembered = state.bindings.get(hash);
|
|
158
|
+
if (remembered !== undefined)
|
|
159
|
+
return remembered;
|
|
160
|
+
state.currentGeneration += 1;
|
|
161
|
+
state.currentHash = hash;
|
|
162
|
+
state.bindings.set(hash, state.currentGeneration);
|
|
163
|
+
state.order.push(hash);
|
|
164
|
+
const max = transportConfig.authGenerationMaxBindings;
|
|
165
|
+
while (state.order.length > max) {
|
|
166
|
+
const oldest = state.order.shift();
|
|
167
|
+
if (!oldest || oldest === state.currentHash) {
|
|
168
|
+
if (oldest)
|
|
169
|
+
state.order.push(oldest);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
state.bindings.delete(oldest);
|
|
173
|
+
}
|
|
174
|
+
return state.currentGeneration;
|
|
175
|
+
}
|
|
176
|
+
function currentAuthGeneration(endpoint) {
|
|
177
|
+
return authEndpoints.get(endpoint)?.currentGeneration ?? 0;
|
|
178
|
+
}
|
|
179
|
+
function isCurrentAuth(conn) {
|
|
180
|
+
return conn.authGeneration === currentAuthGeneration(conn.endpointKey);
|
|
181
|
+
}
|
|
182
|
+
function finishConnectPermit(conn, result) {
|
|
183
|
+
if (!conn.connectPermit || conn.connectFinished)
|
|
184
|
+
return;
|
|
185
|
+
conn.connectFinished = true;
|
|
186
|
+
getTransportCoordinator().finishConnect(conn.connectPermit, result);
|
|
187
|
+
conn.connectPermit = null;
|
|
188
|
+
}
|
|
189
|
+
function releaseSocketAccounting(conn) {
|
|
190
|
+
if (!conn.socketReserved || conn.socketReleased)
|
|
191
|
+
return;
|
|
192
|
+
conn.socketReleased = true;
|
|
193
|
+
getTransportCoordinator().releaseSocket({ accountFp: conn.accountFp, provider: conn.provider });
|
|
194
|
+
getTransportCoordinator().noteSocketClosed(Math.max(0, Date.now() - conn.createdAt), conn.turnCount);
|
|
195
|
+
}
|
|
196
|
+
function releaseSessionAccounting(conn) {
|
|
197
|
+
if (!conn.sessionCounted)
|
|
198
|
+
return;
|
|
199
|
+
conn.sessionCounted = false;
|
|
200
|
+
getTransportCoordinator().adjustSessions(-1);
|
|
201
|
+
}
|
|
113
202
|
function detach(conn) {
|
|
114
203
|
if (conn.detach) {
|
|
115
204
|
conn.detach();
|
|
@@ -127,6 +216,9 @@ function remove(conn) {
|
|
|
127
216
|
if (index >= 0)
|
|
128
217
|
connectionPool.splice(index, 1);
|
|
129
218
|
detach(conn);
|
|
219
|
+
finishConnectPermit(conn, { success: false, breakerEligible: false });
|
|
220
|
+
releaseSocketAccounting(conn);
|
|
221
|
+
releaseSessionAccounting(conn);
|
|
130
222
|
}
|
|
131
223
|
function retryDelay(attempt, retryAfterMs) {
|
|
132
224
|
if (retryAfterMs !== undefined)
|
|
@@ -147,16 +239,8 @@ function closeSocket(conn, code, reason) {
|
|
|
147
239
|
}
|
|
148
240
|
catch { }
|
|
149
241
|
}
|
|
150
|
-
function terminalRelease(conn) {
|
|
151
|
-
release(conn);
|
|
152
|
-
drainQueue(conn.scopeKey);
|
|
153
|
-
}
|
|
154
|
-
function removeAndDrain(conn) {
|
|
155
|
-
remove(conn);
|
|
156
|
-
drainQueue(conn.scopeKey);
|
|
157
|
-
}
|
|
158
242
|
export function closeConnection(conn, reason = "connection closed", graceful = false) {
|
|
159
|
-
|
|
243
|
+
remove(conn);
|
|
160
244
|
if (graceful) {
|
|
161
245
|
try {
|
|
162
246
|
conn.ws?.close(1000, reason);
|
|
@@ -178,16 +262,22 @@ function release(conn) {
|
|
|
178
262
|
conn.frameCarryover = "";
|
|
179
263
|
clearTimer(conn.idleTimer);
|
|
180
264
|
conn.idleTimer = null;
|
|
181
|
-
if (conn.staleAuth) {
|
|
182
|
-
|
|
265
|
+
if (conn.staleAuth || !isCurrentAuth(conn)) {
|
|
266
|
+
remove(conn);
|
|
183
267
|
closeSocket(conn, 1000, "auth changed");
|
|
184
268
|
return;
|
|
185
269
|
}
|
|
270
|
+
if (Date.now() >= conn.expiresAt) {
|
|
271
|
+
getTransportCoordinator().noteMaxAgeRetire();
|
|
272
|
+
remove(conn);
|
|
273
|
+
closeSocket(conn, 1000, "max age");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
186
276
|
if (!conn.warm) {
|
|
187
277
|
conn.idleTimer = unrefTimer(setTimeout(() => {
|
|
188
278
|
conn.idleTimer = null;
|
|
189
279
|
if (!conn.busy) {
|
|
190
|
-
|
|
280
|
+
remove(conn);
|
|
191
281
|
try {
|
|
192
282
|
conn.ws?.close(1000, "idle eviction");
|
|
193
283
|
}
|
|
@@ -231,7 +321,7 @@ function fail(conn, error, shouldCloseSocket) {
|
|
|
231
321
|
else {
|
|
232
322
|
finalizePending(pending);
|
|
233
323
|
try {
|
|
234
|
-
pending.
|
|
324
|
+
pending.sink.error(error);
|
|
235
325
|
}
|
|
236
326
|
catch { }
|
|
237
327
|
}
|
|
@@ -241,12 +331,9 @@ function fail(conn, error, shouldCloseSocket) {
|
|
|
241
331
|
conn.activeSessionID = undefined;
|
|
242
332
|
conn.activeAgent = undefined;
|
|
243
333
|
if (shouldCloseSocket) {
|
|
244
|
-
|
|
334
|
+
remove(conn);
|
|
245
335
|
closeSocket(conn, 1000, "aborted");
|
|
246
336
|
}
|
|
247
|
-
else {
|
|
248
|
-
drainQueue(conn.scopeKey);
|
|
249
|
-
}
|
|
250
337
|
}
|
|
251
338
|
function isTerminalEvent(eventType) {
|
|
252
339
|
return ["response.completed", "response.failed", "response.incomplete"].includes(eventType);
|
|
@@ -277,7 +364,12 @@ function schedulePendingIdleTimeout(conn, reason) {
|
|
|
277
364
|
current.idleTimer = null;
|
|
278
365
|
conn.lastCloseCode = 1006;
|
|
279
366
|
conn.lastCloseReason = "idle timeout waiting for websocket";
|
|
280
|
-
fail(conn,
|
|
367
|
+
fail(conn, transportError({
|
|
368
|
+
code: "active_socket_loss",
|
|
369
|
+
phase: "active",
|
|
370
|
+
message: formatFailureMessage(conn, `idle timeout waiting for websocket after ${reason}`),
|
|
371
|
+
fallbackEligible: false,
|
|
372
|
+
}), true);
|
|
281
373
|
}, transportConfig.streamIdleTimeoutMs));
|
|
282
374
|
}
|
|
283
375
|
function decodeFrameData(data) {
|
|
@@ -366,15 +458,63 @@ function messageFromError(error) {
|
|
|
366
458
|
}
|
|
367
459
|
return error !== undefined && error !== null ? String(error) : undefined;
|
|
368
460
|
}
|
|
369
|
-
function
|
|
461
|
+
function websocketErrorCode(frame) {
|
|
462
|
+
const error = frame.error && typeof frame.error === "object" ? frame.error : {};
|
|
463
|
+
return typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
|
|
464
|
+
}
|
|
465
|
+
function hasReplaySafeInput(body) {
|
|
466
|
+
const input = body.input;
|
|
467
|
+
if (typeof input === "string")
|
|
468
|
+
return input.length > 0;
|
|
469
|
+
if (!Array.isArray(input))
|
|
470
|
+
return false;
|
|
471
|
+
return (input.length > 0 &&
|
|
472
|
+
input.every((item) => {
|
|
473
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
474
|
+
return false;
|
|
475
|
+
const value = item;
|
|
476
|
+
return value.type === "message";
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
function wrappedProviderRejection(frame, pending) {
|
|
370
480
|
if (frame.type !== "error")
|
|
371
481
|
return undefined;
|
|
482
|
+
const code = websocketErrorCode(frame);
|
|
372
483
|
const error = frame.error && typeof frame.error === "object" ? frame.error : {};
|
|
373
|
-
const code = typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
|
|
374
484
|
const message = typeof error.message === "string" ? error.message : typeof frame.message === "string" ? frame.message : code;
|
|
485
|
+
if (code === "previous_response_not_found") {
|
|
486
|
+
pending.metadata.responseId = undefined;
|
|
487
|
+
const replaySafe = hasReplaySafeInput(pending.body) && !pending.framesReceived;
|
|
488
|
+
return transportError({
|
|
489
|
+
code: "previous_response_not_found",
|
|
490
|
+
phase: pending.framesReceived ? "active" : "pre_send",
|
|
491
|
+
message: replaySafe
|
|
492
|
+
? message ?? "Previous response not found"
|
|
493
|
+
: "OpenAI WebSocket previous_response_not_found; cannot safely retry without previous_response_id because request input does not contain replayable message context",
|
|
494
|
+
fallbackEligible: replaySafe,
|
|
495
|
+
safeToReplay: replaySafe,
|
|
496
|
+
breakerEligible: false,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
375
499
|
if (code === "websocket_connection_limit_reached") {
|
|
376
|
-
return
|
|
500
|
+
return transportError({
|
|
501
|
+
code: "provider_connection_limit",
|
|
502
|
+
phase: pending.framesReceived ? "active" : "pre_send",
|
|
503
|
+
message: message ??
|
|
504
|
+
"Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.",
|
|
505
|
+
fallbackEligible: !pending.framesReceived,
|
|
506
|
+
safeToReplay: !pending.framesReceived,
|
|
507
|
+
breakerEligible: false,
|
|
508
|
+
});
|
|
377
509
|
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
function wrappedWebSocketError(frame) {
|
|
513
|
+
if (frame.type !== "error")
|
|
514
|
+
return undefined;
|
|
515
|
+
const error = frame.error && typeof frame.error === "object" ? frame.error : {};
|
|
516
|
+
const code = typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
|
|
517
|
+
const message = typeof error.message === "string" ? error.message : typeof frame.message === "string" ? frame.message : code;
|
|
378
518
|
const status = numberFrom(frame.status) ??
|
|
379
519
|
numberFrom(frame.status_code) ??
|
|
380
520
|
numberFrom(error.status) ??
|
|
@@ -385,7 +525,7 @@ function wrappedWebSocketError(frame) {
|
|
|
385
525
|
return new Error(`OpenAI WebSocket error${status !== undefined ? ` ${status}` : ""}${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`);
|
|
386
526
|
}
|
|
387
527
|
function isCodexConnection(conn) {
|
|
388
|
-
return conn.
|
|
528
|
+
return conn.provider === "codex";
|
|
389
529
|
}
|
|
390
530
|
function shouldSendResponseProcessed(conn) {
|
|
391
531
|
if (process.env[RESPONSE_PROCESSED_DISABLE_ENV] === "1")
|
|
@@ -409,23 +549,6 @@ function sendResponseProcessed(conn, pending, eventType) {
|
|
|
409
549
|
conn.lastErrorMessage = error instanceof Error ? error.message : String(error);
|
|
410
550
|
}
|
|
411
551
|
}
|
|
412
|
-
function websocketErrorCode(frame) {
|
|
413
|
-
const error = frame.error && typeof frame.error === "object" ? frame.error : {};
|
|
414
|
-
return typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
|
|
415
|
-
}
|
|
416
|
-
function hasReplaySafeInput(body) {
|
|
417
|
-
const input = body.input;
|
|
418
|
-
if (typeof input === "string")
|
|
419
|
-
return input.length > 0;
|
|
420
|
-
if (!Array.isArray(input))
|
|
421
|
-
return false;
|
|
422
|
-
return input.length > 0 && input.every((item) => {
|
|
423
|
-
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
424
|
-
return false;
|
|
425
|
-
const value = item;
|
|
426
|
-
return value.type === "message";
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
552
|
function clearLastResponseID(conn) {
|
|
430
553
|
conn.lastResponseID = undefined;
|
|
431
554
|
lastResponseIDByContext.delete(conn.contextKey);
|
|
@@ -458,30 +581,25 @@ function cacheResponseMetadata(conn, pending, responseID) {
|
|
|
458
581
|
function isStaleResponseFrame(pending, responseID) {
|
|
459
582
|
return Boolean(responseID && pending.metadata.activeResponseId && responseID !== pending.metadata.activeResponseId);
|
|
460
583
|
}
|
|
461
|
-
function
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
fail(conn, new Error("OpenAI WebSocket previous_response_not_found after retrying without previous_response_id"), true);
|
|
466
|
-
return true;
|
|
584
|
+
function sweepScopeCooldowns(now) {
|
|
585
|
+
for (const [key, until] of scopeCooldowns) {
|
|
586
|
+
if (until <= now)
|
|
587
|
+
scopeCooldowns.delete(key);
|
|
467
588
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
pending.sent = false;
|
|
475
|
-
pending.writeCommitted = false;
|
|
476
|
-
pending.framesReceived = false;
|
|
477
|
-
pending.finalMessageOutputReceived = false;
|
|
478
|
-
pending.processedAckSent = false;
|
|
479
|
-
conn.frameCarryover = "";
|
|
480
|
-
clearPendingTimer(pending);
|
|
481
|
-
if (conn.ws?.readyState !== readyState.OPEN || !sendPending(conn)) {
|
|
482
|
-
fail(conn, new Error("OpenAI WebSocket previous_response_not_found; retry without previous_response_id could not be sent"), true);
|
|
589
|
+
const max = transportConfig.maxAccountingEntries;
|
|
590
|
+
while (scopeCooldowns.size > max) {
|
|
591
|
+
const oldest = scopeCooldowns.keys().next().value;
|
|
592
|
+
if (oldest === undefined)
|
|
593
|
+
break;
|
|
594
|
+
scopeCooldowns.delete(oldest);
|
|
483
595
|
}
|
|
484
|
-
|
|
596
|
+
}
|
|
597
|
+
function setScopeCooldown(scopeKeyValue) {
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
sweepScopeCooldowns(now);
|
|
600
|
+
scopeCooldowns.set(scopeKeyValue, now + transportConfig.sessionCooldownMs);
|
|
601
|
+
sweepScopeCooldowns(now);
|
|
602
|
+
getTransportCoordinator().noteCooldown();
|
|
485
603
|
}
|
|
486
604
|
function enqueueSSE(conn, frame) {
|
|
487
605
|
const pending = conn.pending;
|
|
@@ -491,24 +609,36 @@ function enqueueSSE(conn, frame) {
|
|
|
491
609
|
const eventType = typeof frame.type === "string" ? frame.type : "message";
|
|
492
610
|
if (eventType !== "response.created" && isStaleResponseFrame(pending, responseID))
|
|
493
611
|
return;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
612
|
+
const providerRejection = wrappedProviderRejection(frame, pending);
|
|
613
|
+
if (providerRejection) {
|
|
614
|
+
if (providerRejection.code === "previous_response_not_found")
|
|
615
|
+
clearLastResponseID(conn);
|
|
616
|
+
if (providerRejection.code === "provider_connection_limit")
|
|
617
|
+
setScopeCooldown(conn.scopeKey);
|
|
618
|
+
fail(conn, providerRejection, true);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
499
621
|
const mappedError = wrappedWebSocketError(frame);
|
|
500
622
|
if (mappedError) {
|
|
501
|
-
if (websocketErrorCode(frame) === "previous_response_not_found" && handlePreviousResponseNotFound(conn, pending))
|
|
502
|
-
return;
|
|
503
623
|
clearLastResponseID(conn);
|
|
504
624
|
fail(conn, mappedError, true);
|
|
505
625
|
return;
|
|
506
626
|
}
|
|
627
|
+
pending.writeCommitted = true;
|
|
628
|
+
pending.framesReceived = true;
|
|
629
|
+
cacheResponseMetadata(conn, pending, responseID);
|
|
630
|
+
if (isFinalMessageOutputItem(frame))
|
|
631
|
+
pending.finalMessageOutputReceived = true;
|
|
507
632
|
const encoded = new TextEncoder().encode(`event: ${eventType}\ndata: ${JSON.stringify(frame)}\n\n`);
|
|
508
633
|
try {
|
|
509
|
-
pending.
|
|
634
|
+
pending.sink.enqueue(encoded);
|
|
510
635
|
}
|
|
511
|
-
catch {
|
|
636
|
+
catch (error) {
|
|
637
|
+
if (isWebSocketTransportError(error) && error.code === "queue_overflow") {
|
|
638
|
+
fail(conn, error, true);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
fail(conn, error instanceof Error ? error : new Error(String(error)), true);
|
|
512
642
|
return;
|
|
513
643
|
}
|
|
514
644
|
if (!isTerminalEvent(eventType))
|
|
@@ -518,23 +648,22 @@ function enqueueSSE(conn, frame) {
|
|
|
518
648
|
pending.done = true;
|
|
519
649
|
sendResponseProcessed(conn, pending, eventType);
|
|
520
650
|
try {
|
|
521
|
-
pending.
|
|
651
|
+
pending.sink.close();
|
|
522
652
|
}
|
|
523
653
|
catch { }
|
|
524
|
-
if (pending.metadata.responseId)
|
|
654
|
+
if (pending.metadata.responseId)
|
|
525
655
|
persistLastResponseID(conn, pending.metadata.responseId);
|
|
526
|
-
|
|
527
|
-
else {
|
|
656
|
+
else
|
|
528
657
|
clearLastResponseID(conn);
|
|
529
|
-
|
|
530
|
-
|
|
658
|
+
conn.pending = null;
|
|
659
|
+
release(conn);
|
|
531
660
|
}
|
|
532
661
|
}
|
|
533
662
|
function finishAfterFinalOutputSocketClose(conn, pending) {
|
|
534
663
|
finalizePending(pending);
|
|
535
664
|
pending.done = true;
|
|
536
665
|
try {
|
|
537
|
-
pending.
|
|
666
|
+
pending.sink.close();
|
|
538
667
|
}
|
|
539
668
|
catch { }
|
|
540
669
|
if (pending.metadata.responseId)
|
|
@@ -545,7 +674,7 @@ function finishAfterFinalOutputSocketClose(conn, pending) {
|
|
|
545
674
|
conn.busy = false;
|
|
546
675
|
conn.activeSessionID = undefined;
|
|
547
676
|
conn.activeAgent = undefined;
|
|
548
|
-
|
|
677
|
+
remove(conn);
|
|
549
678
|
}
|
|
550
679
|
export function sendPending(conn) {
|
|
551
680
|
const pending = conn.pending;
|
|
@@ -554,6 +683,17 @@ export function sendPending(conn) {
|
|
|
554
683
|
const ws = conn.ws;
|
|
555
684
|
if (!ws || ws.readyState !== readyState.OPEN)
|
|
556
685
|
return false;
|
|
686
|
+
if (!isCurrentAuth(conn) || conn.staleAuth) {
|
|
687
|
+
getTransportCoordinator().noteAuthStale();
|
|
688
|
+
fail(conn, transportError({
|
|
689
|
+
code: "stale_auth",
|
|
690
|
+
phase: "pre_send",
|
|
691
|
+
message: "OpenAI WebSocket auth generation is stale; refusing response.create",
|
|
692
|
+
fallbackEligible: true,
|
|
693
|
+
safeToReplay: true,
|
|
694
|
+
}), true);
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
557
697
|
const generation = conn.generation;
|
|
558
698
|
try {
|
|
559
699
|
const body = { ...pending.body };
|
|
@@ -625,7 +765,36 @@ function parseRetryAfterMs(source) {
|
|
|
625
765
|
return Math.max(0, date - Date.now());
|
|
626
766
|
return undefined;
|
|
627
767
|
}
|
|
768
|
+
function beginHandshake(conn) {
|
|
769
|
+
const coordinator = getTransportCoordinator();
|
|
770
|
+
const started = coordinator.startConnect({
|
|
771
|
+
intent: conn.intent,
|
|
772
|
+
provider: conn.provider,
|
|
773
|
+
origin: conn.origin,
|
|
774
|
+
accountFp: conn.accountFp,
|
|
775
|
+
scopeKey: conn.breakerDomain,
|
|
776
|
+
});
|
|
777
|
+
if (!started.ok) {
|
|
778
|
+
coordinator.noteAdmissionMiss(started.reason);
|
|
779
|
+
fail(conn, transportError({
|
|
780
|
+
code: started.reason,
|
|
781
|
+
phase: "handshake",
|
|
782
|
+
message: `OpenAI WebSocket handshake admission denied: ${started.reason}`,
|
|
783
|
+
fallbackEligible: true,
|
|
784
|
+
breakerEligible: started.reason === "provider_breaker",
|
|
785
|
+
}), true);
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
conn.connectPermit = started.permit;
|
|
789
|
+
conn.connectFinished = false;
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
628
792
|
function connect(conn) {
|
|
793
|
+
// Initial create pre-reserves a permit; each bounded pre-send reconnect takes a fresh one.
|
|
794
|
+
if (!conn.connectPermit || conn.connectFinished) {
|
|
795
|
+
if (!beginHandshake(conn))
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
629
798
|
const generation = ++conn.generation;
|
|
630
799
|
conn.frameCarryover = "";
|
|
631
800
|
clearTimer(conn.retryTimer);
|
|
@@ -636,6 +805,7 @@ function connect(conn) {
|
|
|
636
805
|
return;
|
|
637
806
|
conn.lastCloseCode = 1006;
|
|
638
807
|
conn.lastCloseReason = "connection timed out";
|
|
808
|
+
finishConnectPermit(conn, { success: false, breakerEligible: true, timeout: true });
|
|
639
809
|
handleSocketLoss(conn);
|
|
640
810
|
}, transportConfig.connectTimeoutMs);
|
|
641
811
|
conn.connectTimer = unrefTimer(connectTimer);
|
|
@@ -645,6 +815,7 @@ function connect(conn) {
|
|
|
645
815
|
clearTimer(conn.connectTimer);
|
|
646
816
|
conn.connectTimer = null;
|
|
647
817
|
conn.lastActivityAt = Date.now();
|
|
818
|
+
finishConnectPermit(conn, { success: true });
|
|
648
819
|
sendPending(conn);
|
|
649
820
|
};
|
|
650
821
|
const handleUpgrade = (response) => {
|
|
@@ -676,6 +847,7 @@ function connect(conn) {
|
|
|
676
847
|
if (generation !== conn.generation)
|
|
677
848
|
return;
|
|
678
849
|
conn.lastErrorMessage = messageFromError(error) ?? "unknown websocket error";
|
|
850
|
+
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
679
851
|
handleSocketLoss(conn);
|
|
680
852
|
};
|
|
681
853
|
const handleClose = (codeOrEvent, reason) => {
|
|
@@ -685,8 +857,10 @@ function connect(conn) {
|
|
|
685
857
|
const normalizedReason = closeReasonFrom(codeOrEvent, reason);
|
|
686
858
|
if (normalizedReason !== undefined)
|
|
687
859
|
conn.lastCloseReason = normalizedReason;
|
|
688
|
-
else if (conn.lastCloseReason === undefined && closeCodeFrom(codeOrEvent) === undefined)
|
|
860
|
+
else if (conn.lastCloseReason === undefined && closeCodeFrom(codeOrEvent) === undefined) {
|
|
689
861
|
conn.lastCloseReason = "socket closed without close code";
|
|
862
|
+
}
|
|
863
|
+
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
690
864
|
handleSocketLoss(conn);
|
|
691
865
|
};
|
|
692
866
|
const handleUnexpectedResponse = (_request, response) => {
|
|
@@ -697,6 +871,7 @@ function connect(conn) {
|
|
|
697
871
|
conn.lastCloseCode = status;
|
|
698
872
|
conn.retryAfterMs = parseRetryAfterMs(response);
|
|
699
873
|
conn.lastCloseReason = `websocket handshake failed${status !== undefined ? ` with status ${status}` : ""}`;
|
|
874
|
+
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
700
875
|
handleSocketLoss(conn);
|
|
701
876
|
};
|
|
702
877
|
const attachUpgrade = supportsUpgradeEvent(WebSocketImpl);
|
|
@@ -714,6 +889,7 @@ function connect(conn) {
|
|
|
714
889
|
}
|
|
715
890
|
catch (error) {
|
|
716
891
|
conn.lastErrorMessage = messageFromError(error) ?? "websocket constructor failed";
|
|
892
|
+
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
717
893
|
queueMicrotask(() => handleSocketLoss(conn));
|
|
718
894
|
return;
|
|
719
895
|
}
|
|
@@ -745,7 +921,7 @@ function handleSocketLoss(conn) {
|
|
|
745
921
|
const pending = conn.pending;
|
|
746
922
|
if (!pending || pending.done) {
|
|
747
923
|
finalizePending(pending);
|
|
748
|
-
|
|
924
|
+
remove(conn);
|
|
749
925
|
closeSocket(conn, 1000, "socket lost");
|
|
750
926
|
return;
|
|
751
927
|
}
|
|
@@ -753,8 +929,13 @@ function handleSocketLoss(conn) {
|
|
|
753
929
|
finishAfterFinalOutputSocketClose(conn, pending);
|
|
754
930
|
return;
|
|
755
931
|
}
|
|
756
|
-
if (pending.writeCommitted) {
|
|
757
|
-
fail(conn,
|
|
932
|
+
if (pending.framesReceived || pending.writeCommitted) {
|
|
933
|
+
fail(conn, transportError({
|
|
934
|
+
code: "active_socket_loss",
|
|
935
|
+
phase: "active",
|
|
936
|
+
message: formatFailureMessage(conn, "response.create was already sent"),
|
|
937
|
+
fallbackEligible: false,
|
|
938
|
+
}), true);
|
|
758
939
|
return;
|
|
759
940
|
}
|
|
760
941
|
const canRetry = conn.reconnectAttempts < transportConfig.maxReconnectAttempts;
|
|
@@ -780,17 +961,34 @@ function handleSocketLoss(conn) {
|
|
|
780
961
|
conn.ws = null;
|
|
781
962
|
conn.retryTimer = unrefTimer(setTimeout(() => {
|
|
782
963
|
conn.retryTimer = null;
|
|
783
|
-
if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted)
|
|
964
|
+
if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted) {
|
|
784
965
|
return;
|
|
966
|
+
}
|
|
785
967
|
connect(conn);
|
|
786
968
|
}, retryDelay(conn.reconnectAttempts, retryAfterMs)));
|
|
787
969
|
return;
|
|
788
970
|
}
|
|
789
|
-
fail(conn,
|
|
790
|
-
|
|
791
|
-
|
|
971
|
+
fail(conn, transportError({
|
|
972
|
+
code: "connect_failure",
|
|
973
|
+
phase: "handshake",
|
|
974
|
+
message: formatFailureMessage(conn, "retry limit reached before response.create was sent"),
|
|
975
|
+
fallbackEligible: true,
|
|
976
|
+
breakerEligible: true,
|
|
977
|
+
safeToReplay: true,
|
|
978
|
+
}), true);
|
|
979
|
+
}
|
|
980
|
+
function computeExpiresAt(now) {
|
|
981
|
+
const jitter = Math.floor(Math.random() * (transportConfig.connectionMaxAgeJitterMs + 1));
|
|
982
|
+
return now + transportConfig.connectionMaxAgeMs - jitter;
|
|
983
|
+
}
|
|
984
|
+
function create(wsUrl, headers, context = {}, warm = false, permit) {
|
|
792
985
|
const hash = authScopeHash(headers);
|
|
793
986
|
const key = contextScopeKey(wsUrl, headers, context);
|
|
987
|
+
const endpoint = endpointKey(wsUrl);
|
|
988
|
+
const provider = providerForUrl(wsUrl);
|
|
989
|
+
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
990
|
+
const accountFp = accountFingerprint(headers);
|
|
991
|
+
const now = Date.now();
|
|
794
992
|
const conn = {
|
|
795
993
|
id: `ws-${nextConnectionID++}`,
|
|
796
994
|
ws: null,
|
|
@@ -799,6 +997,11 @@ function create(wsUrl, headers, context = {}, warm = false) {
|
|
|
799
997
|
scopeKey: key,
|
|
800
998
|
scopeHash: hash,
|
|
801
999
|
contextKey: key,
|
|
1000
|
+
endpointKey: endpoint,
|
|
1001
|
+
accountFp,
|
|
1002
|
+
provider,
|
|
1003
|
+
origin,
|
|
1004
|
+
authGeneration: bindAuthGeneration(endpoint, hash),
|
|
802
1005
|
busy: false,
|
|
803
1006
|
warm,
|
|
804
1007
|
staleAuth: false,
|
|
@@ -806,20 +1009,30 @@ function create(wsUrl, headers, context = {}, warm = false) {
|
|
|
806
1009
|
lastResponseID: lastResponseIDByContext.get(key),
|
|
807
1010
|
generation: 0,
|
|
808
1011
|
reconnectAttempts: 0,
|
|
809
|
-
createdAt:
|
|
810
|
-
|
|
1012
|
+
createdAt: now,
|
|
1013
|
+
expiresAt: computeExpiresAt(now),
|
|
1014
|
+
lastActivityAt: now,
|
|
811
1015
|
idleTimer: null,
|
|
812
1016
|
connectTimer: null,
|
|
813
1017
|
retryTimer: null,
|
|
814
1018
|
frameCarryover: "",
|
|
815
1019
|
detach: null,
|
|
1020
|
+
socketReserved: true,
|
|
1021
|
+
socketReleased: false,
|
|
1022
|
+
sessionCounted: true,
|
|
1023
|
+
connectPermit: permit,
|
|
1024
|
+
connectFinished: false,
|
|
1025
|
+
turnCount: 0,
|
|
1026
|
+
intent: warm ? "prewarm" : "demand",
|
|
1027
|
+
breakerDomain: breakerDomainKey(provider, origin, accountFp),
|
|
816
1028
|
};
|
|
817
1029
|
connectionPool.push(conn);
|
|
1030
|
+
getTransportCoordinator().adjustSessions(1);
|
|
818
1031
|
connect(conn);
|
|
819
1032
|
return conn;
|
|
820
1033
|
}
|
|
821
1034
|
function isReusable(conn, key, context, now) {
|
|
822
|
-
if (conn.staleAuth)
|
|
1035
|
+
if (conn.staleAuth || !isCurrentAuth(conn))
|
|
823
1036
|
return false;
|
|
824
1037
|
if (conn.busy)
|
|
825
1038
|
return false;
|
|
@@ -829,7 +1042,7 @@ function isReusable(conn, key, context, now) {
|
|
|
829
1042
|
return false;
|
|
830
1043
|
if (now - conn.lastActivityAt > transportConfig.staleReuseMs)
|
|
831
1044
|
return false;
|
|
832
|
-
if (now
|
|
1045
|
+
if (now >= conn.expiresAt)
|
|
833
1046
|
return false;
|
|
834
1047
|
if (context.sessionID && conn.lastSessionID && conn.lastSessionID !== context.sessionID)
|
|
835
1048
|
return false;
|
|
@@ -837,129 +1050,162 @@ function isReusable(conn, key, context, now) {
|
|
|
837
1050
|
return false;
|
|
838
1051
|
return true;
|
|
839
1052
|
}
|
|
840
|
-
function
|
|
841
|
-
return connectionPool.
|
|
1053
|
+
function activeBusy(key) {
|
|
1054
|
+
return connectionPool.some((conn) => conn.scopeKey === key && conn.busy);
|
|
842
1055
|
}
|
|
843
1056
|
export function invalidateStaleAuthConnections(wsUrl, headers) {
|
|
844
|
-
const
|
|
1057
|
+
const endpoint = endpointKey(wsUrl);
|
|
1058
|
+
const hash = authScopeHash(headers);
|
|
1059
|
+
const state = authEndpoints.get(endpoint);
|
|
1060
|
+
// ABA: presenting an old remembered binding must not become current or stale newer sockets.
|
|
1061
|
+
if (state && hash !== state.currentHash && state.bindings.has(hash))
|
|
1062
|
+
return;
|
|
1063
|
+
const generation = bindAuthGeneration(endpoint, hash);
|
|
1064
|
+
const keepHash = authEndpoints.get(endpoint)?.currentHash ?? hash;
|
|
1065
|
+
const keepGen = authEndpoints.get(endpoint)?.currentGeneration ?? generation;
|
|
845
1066
|
for (const conn of [...connectionPool]) {
|
|
846
|
-
if (conn.wsUrl !== wsUrl
|
|
1067
|
+
if (conn.wsUrl !== wsUrl)
|
|
1068
|
+
continue;
|
|
1069
|
+
if (conn.scopeHash === keepHash && conn.authGeneration === keepGen)
|
|
847
1070
|
continue;
|
|
848
1071
|
conn.staleAuth = true;
|
|
849
1072
|
if (conn.busy)
|
|
850
1073
|
continue;
|
|
851
|
-
|
|
1074
|
+
remove(conn);
|
|
852
1075
|
closeSocket(conn, 1000, "auth changed");
|
|
853
1076
|
}
|
|
854
1077
|
}
|
|
855
|
-
function
|
|
1078
|
+
function cleanupExpiredIdle(now) {
|
|
1079
|
+
sweepScopeCooldowns(now);
|
|
856
1080
|
for (const candidate of [...connectionPool]) {
|
|
857
|
-
if (candidate.busy
|
|
1081
|
+
if (candidate.busy)
|
|
858
1082
|
continue;
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1083
|
+
const state = candidate.ws?.readyState;
|
|
1084
|
+
if (state !== readyState.OPEN && state !== readyState.CONNECTING)
|
|
1085
|
+
continue;
|
|
1086
|
+
const maxAgeExpired = now >= candidate.expiresAt;
|
|
1087
|
+
const staleIdle = now - candidate.lastActivityAt > transportConfig.staleReuseMs;
|
|
1088
|
+
if (!maxAgeExpired && !staleIdle)
|
|
1089
|
+
continue;
|
|
1090
|
+
// Max-age retirement is only for jittered expiry, never inactivity alone.
|
|
1091
|
+
if (maxAgeExpired)
|
|
1092
|
+
getTransportCoordinator().noteMaxAgeRetire();
|
|
1093
|
+
remove(candidate);
|
|
1094
|
+
try {
|
|
1095
|
+
candidate.ws?.close(1000, maxAgeExpired ? "max age" : "stale idle");
|
|
867
1096
|
}
|
|
1097
|
+
catch { }
|
|
868
1098
|
}
|
|
869
1099
|
}
|
|
1100
|
+
function admitMiss(code, message) {
|
|
1101
|
+
getTransportCoordinator().noteAdmissionMiss(code);
|
|
1102
|
+
throw transportError({
|
|
1103
|
+
code,
|
|
1104
|
+
phase: "admission",
|
|
1105
|
+
message,
|
|
1106
|
+
fallbackEligible: true,
|
|
1107
|
+
breakerEligible: code === "provider_breaker",
|
|
1108
|
+
safeToReplay: true,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
870
1111
|
function reserveConnection(wsUrl, headers, context) {
|
|
1112
|
+
const coordinator = getTransportCoordinator();
|
|
1113
|
+
coordinator.noteLeaseAttempt();
|
|
871
1114
|
invalidateStaleAuthConnections(wsUrl, headers);
|
|
872
1115
|
const key = contextScopeKey(wsUrl, headers, context);
|
|
873
1116
|
const now = Date.now();
|
|
874
|
-
|
|
1117
|
+
cleanupExpiredIdle(now);
|
|
1118
|
+
const cooldownUntil = scopeCooldowns.get(key);
|
|
1119
|
+
if (cooldownUntil && now < cooldownUntil) {
|
|
1120
|
+
admitMiss("session_cooldown", `OpenAI WebSocket session cooldown active for ${Math.max(0, cooldownUntil - now)}ms`);
|
|
1121
|
+
}
|
|
1122
|
+
if (activeBusy(key)) {
|
|
1123
|
+
admitMiss("session_busy", "OpenAI WebSocket session already has an active turn");
|
|
1124
|
+
}
|
|
1125
|
+
const endpoint = endpointKey(wsUrl);
|
|
1126
|
+
const hash = authScopeHash(headers);
|
|
1127
|
+
const generation = bindAuthGeneration(endpoint, hash);
|
|
1128
|
+
if (generation !== currentAuthGeneration(endpoint)) {
|
|
1129
|
+
coordinator.noteAuthStale();
|
|
1130
|
+
admitMiss("stale_auth", "OpenAI WebSocket auth generation is stale");
|
|
1131
|
+
}
|
|
875
1132
|
const reusable = connectionPool.find((conn) => isReusable(conn, key, context, now));
|
|
876
1133
|
if (reusable) {
|
|
877
1134
|
clearTimer(reusable.idleTimer);
|
|
878
1135
|
reusable.idleTimer = null;
|
|
879
1136
|
reusable.busy = true;
|
|
880
1137
|
reusable.warm = false;
|
|
1138
|
+
reusable.intent = "demand";
|
|
1139
|
+
reusable.turnCount += 1;
|
|
1140
|
+
coordinator.noteLeaseAdmission();
|
|
881
1141
|
return reusable;
|
|
882
1142
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
resolve,
|
|
897
|
-
reject,
|
|
898
|
-
signal,
|
|
899
|
-
onAbort: () => {
|
|
900
|
-
const queue = acquisitionQueues.get(key);
|
|
901
|
-
if (queue) {
|
|
902
|
-
const index = queue.indexOf(entry);
|
|
903
|
-
if (index >= 0)
|
|
904
|
-
queue.splice(index, 1);
|
|
905
|
-
if (queue.length === 0)
|
|
906
|
-
acquisitionQueues.delete(key);
|
|
907
|
-
}
|
|
908
|
-
reject(signal?.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError"));
|
|
909
|
-
},
|
|
910
|
-
};
|
|
911
|
-
if (signal?.aborted) {
|
|
912
|
-
entry.onAbort();
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
signal?.addEventListener("abort", entry.onAbort, { once: true });
|
|
916
|
-
const queue = acquisitionQueues.get(key) ?? [];
|
|
917
|
-
queue.push(entry);
|
|
918
|
-
acquisitionQueues.set(key, queue);
|
|
1143
|
+
const accountFp = accountFingerprint(headers);
|
|
1144
|
+
const provider = providerForUrl(wsUrl);
|
|
1145
|
+
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
1146
|
+
const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "demand" });
|
|
1147
|
+
if (!reserved.ok) {
|
|
1148
|
+
admitMiss(reserved.reason, `OpenAI WebSocket socket admission denied: ${reserved.reason}`);
|
|
1149
|
+
}
|
|
1150
|
+
const started = coordinator.startConnect({
|
|
1151
|
+
intent: "demand",
|
|
1152
|
+
provider,
|
|
1153
|
+
origin,
|
|
1154
|
+
accountFp,
|
|
1155
|
+
scopeKey: breakerDomainKey(provider, origin, accountFp),
|
|
919
1156
|
});
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
if (!queue?.length)
|
|
924
|
-
return;
|
|
925
|
-
while (queue.length) {
|
|
926
|
-
const entry = queue[0];
|
|
927
|
-
if (entry.signal?.aborted) {
|
|
928
|
-
queue.shift();
|
|
929
|
-
entry.onAbort();
|
|
930
|
-
continue;
|
|
931
|
-
}
|
|
932
|
-
const conn = reserveConnection(entry.wsUrl, entry.headers, entry.context);
|
|
933
|
-
if (!conn)
|
|
934
|
-
break;
|
|
935
|
-
queue.shift();
|
|
936
|
-
entry.signal?.removeEventListener("abort", entry.onAbort);
|
|
937
|
-
entry.resolve(conn);
|
|
1157
|
+
if (!started.ok) {
|
|
1158
|
+
coordinator.releaseSocket({ accountFp, provider });
|
|
1159
|
+
admitMiss(started.reason, `OpenAI WebSocket connect admission denied: ${started.reason}`);
|
|
938
1160
|
}
|
|
939
|
-
|
|
940
|
-
|
|
1161
|
+
const conn = create(wsUrl, headers, context, false, started.permit);
|
|
1162
|
+
conn.busy = true;
|
|
1163
|
+
conn.turnCount += 1;
|
|
1164
|
+
coordinator.noteLeaseAdmission();
|
|
1165
|
+
return conn;
|
|
941
1166
|
}
|
|
942
|
-
export function acquireConnection(wsUrl, headers, context,
|
|
943
|
-
|
|
944
|
-
if (conn)
|
|
945
|
-
return conn;
|
|
946
|
-
return queueAcquire(wsUrl, headers, context, signal);
|
|
1167
|
+
export function acquireConnection(wsUrl, headers, context, _signal) {
|
|
1168
|
+
return reserveConnection(wsUrl, headers, context);
|
|
947
1169
|
}
|
|
948
1170
|
export function ensureWarmConnection(wsUrl, headers) {
|
|
1171
|
+
const coordinator = getTransportCoordinator();
|
|
1172
|
+
coordinator.notePrewarmAttempt();
|
|
949
1173
|
invalidateStaleAuthConnections(wsUrl, headers);
|
|
950
1174
|
const key = contextScopeKey(wsUrl, headers, {});
|
|
951
1175
|
const now = Date.now();
|
|
952
|
-
|
|
953
|
-
const existing = connectionPool.find((conn) => conn.scopeKey === key &&
|
|
1176
|
+
cleanupExpiredIdle(now);
|
|
1177
|
+
const existing = connectionPool.find((conn) => conn.scopeKey === key &&
|
|
1178
|
+
!conn.busy &&
|
|
1179
|
+
!conn.staleAuth &&
|
|
1180
|
+
isCurrentAuth(conn) &&
|
|
1181
|
+
(conn.ws?.readyState === readyState.OPEN || conn.ws?.readyState === readyState.CONNECTING));
|
|
954
1182
|
if (existing) {
|
|
955
1183
|
existing.warm = true;
|
|
956
1184
|
clearTimer(existing.idleTimer);
|
|
957
1185
|
existing.idleTimer = null;
|
|
958
1186
|
return existing;
|
|
959
1187
|
}
|
|
960
|
-
|
|
1188
|
+
const accountFp = accountFingerprint(headers);
|
|
1189
|
+
const provider = providerForUrl(wsUrl);
|
|
1190
|
+
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
1191
|
+
const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "prewarm" });
|
|
1192
|
+
if (!reserved.ok) {
|
|
1193
|
+
coordinator.notePrewarmDeferred();
|
|
961
1194
|
return undefined;
|
|
962
|
-
|
|
1195
|
+
}
|
|
1196
|
+
const started = coordinator.startConnect({
|
|
1197
|
+
intent: "prewarm",
|
|
1198
|
+
provider,
|
|
1199
|
+
origin,
|
|
1200
|
+
accountFp,
|
|
1201
|
+
scopeKey: breakerDomainKey(provider, origin, accountFp),
|
|
1202
|
+
});
|
|
1203
|
+
if (!started.ok) {
|
|
1204
|
+
coordinator.releaseSocket({ accountFp, provider });
|
|
1205
|
+
coordinator.notePrewarmDeferred();
|
|
1206
|
+
return undefined;
|
|
1207
|
+
}
|
|
1208
|
+
return create(wsUrl, headers, {}, true, started.permit);
|
|
963
1209
|
}
|
|
964
1210
|
export function closeConnections(predicate = () => true, message = "Session disposed") {
|
|
965
1211
|
for (const conn of [...connectionPool]) {
|
|
@@ -968,6 +1214,45 @@ export function closeConnections(predicate = () => true, message = "Session disp
|
|
|
968
1214
|
fail(conn, new Error(message), true);
|
|
969
1215
|
}
|
|
970
1216
|
}
|
|
1217
|
+
export function nextStreamKeyForTesting() {
|
|
1218
|
+
return `stream-${nextStreamID++}`;
|
|
1219
|
+
}
|
|
1220
|
+
export function allocateStreamKey() {
|
|
1221
|
+
return `stream-${nextStreamID++}`;
|
|
1222
|
+
}
|
|
1223
|
+
export function syncCoordinatorFromTransportConfig() {
|
|
1224
|
+
configureTransportCoordinator({
|
|
1225
|
+
processMaxSockets: transportConfig.processMaxSockets,
|
|
1226
|
+
maxSocketsPerProvider: transportConfig.maxSocketsPerProvider,
|
|
1227
|
+
maxSocketsPerAccount: transportConfig.maxSocketsPerAccount,
|
|
1228
|
+
maxConnecting: transportConfig.maxConnecting,
|
|
1229
|
+
prewarmMaxConnecting: transportConfig.prewarmMaxConnecting,
|
|
1230
|
+
prewarmDemandReserve: transportConfig.prewarmDemandReserve,
|
|
1231
|
+
connectRateProcessPerMin: transportConfig.connectRateProcessPerMin,
|
|
1232
|
+
connectRateProviderPerMin: transportConfig.connectRateProviderPerMin,
|
|
1233
|
+
connectRateOriginPerMin: transportConfig.connectRateOriginPerMin,
|
|
1234
|
+
connectRateAccountPerMin: transportConfig.connectRateAccountPerMin,
|
|
1235
|
+
maxAccountingEntries: transportConfig.maxAccountingEntries,
|
|
1236
|
+
queueMaxBytesPerStream: transportConfig.queueMaxBytesPerStream,
|
|
1237
|
+
queueMaxBytesTotal: transportConfig.queueMaxBytesTotal,
|
|
1238
|
+
maxSessions: transportConfig.shedMaxSessions,
|
|
1239
|
+
eventLoopLagMs: transportConfig.shedLagMs,
|
|
1240
|
+
rssMb: transportConfig.shedRssMb,
|
|
1241
|
+
breaker: createTransportBreaker({
|
|
1242
|
+
windowMs: transportConfig.breakerWindowMs,
|
|
1243
|
+
minSamples: transportConfig.breakerMinSamples,
|
|
1244
|
+
failurePercent: Math.round(transportConfig.breakerFailureRatio * 100),
|
|
1245
|
+
openMs: transportConfig.breakerOpenMs,
|
|
1246
|
+
jitterMs: transportConfig.breakerJitterMs,
|
|
1247
|
+
halfOpenSuccesses: transportConfig.breakerHalfOpenSuccesses,
|
|
1248
|
+
maxEntries: transportConfig.breakerMaxEntries,
|
|
1249
|
+
enabled: transportConfig.breakerEnabled,
|
|
1250
|
+
}),
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
export function getTransportSnapshotForTesting() {
|
|
1254
|
+
return getTransportCoordinator().snapshot();
|
|
1255
|
+
}
|
|
971
1256
|
export function resetPoolForTesting() {
|
|
972
1257
|
for (const conn of [...connectionPool]) {
|
|
973
1258
|
try {
|
|
@@ -980,15 +1265,13 @@ export function resetPoolForTesting() {
|
|
|
980
1265
|
catch { }
|
|
981
1266
|
remove(conn);
|
|
982
1267
|
}
|
|
983
|
-
for (const queue of acquisitionQueues.values()) {
|
|
984
|
-
for (const entry of queue) {
|
|
985
|
-
entry.signal?.removeEventListener("abort", entry.onAbort);
|
|
986
|
-
entry.reject(new Error("Pool reset"));
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
acquisitionQueues.clear();
|
|
990
1268
|
turnStateByContext.clear();
|
|
991
1269
|
lastResponseIDByContext.clear();
|
|
1270
|
+
scopeCooldowns.clear();
|
|
1271
|
+
authEndpoints.clear();
|
|
1272
|
+
nextStreamID = 1;
|
|
1273
|
+
resetTransportCoordinatorForTesting();
|
|
1274
|
+
syncCoordinatorFromTransportConfig();
|
|
992
1275
|
}
|
|
993
1276
|
export function setWebSocketConstructorForTesting(ctor) {
|
|
994
1277
|
WebSocketImpl = ctor;
|
|
@@ -996,4 +1279,6 @@ export function setWebSocketConstructorForTesting(ctor) {
|
|
|
996
1279
|
export function resetWebSocketConstructorForTesting() {
|
|
997
1280
|
WebSocketImpl = loadDefaultWebSocketConstructor();
|
|
998
1281
|
}
|
|
1282
|
+
// Initialize coordinator against boot-latched config.
|
|
1283
|
+
syncCoordinatorFromTransportConfig();
|
|
999
1284
|
//# sourceMappingURL=pool.js.map
|