openai-ws-opencode 0.1.28 → 0.1.30
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 +15 -1
- package/bin/setup.js +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +42 -7
- package/dist/plugin.js.map +1 -1
- package/dist/testing.d.ts +5 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -1
- package/dist/testing.js.map +1 -1
- package/dist/transport/bridge.d.ts +6 -0
- package/dist/transport/bridge.d.ts.map +1 -1
- package/dist/transport/bridge.js +139 -7
- package/dist/transport/bridge.js.map +1 -1
- package/dist/transport/coordinator.d.ts +7 -1
- package/dist/transport/coordinator.d.ts.map +1 -1
- package/dist/transport/coordinator.js +54 -13
- package/dist/transport/coordinator.js.map +1 -1
- package/dist/transport/pool.d.ts +11 -1
- package/dist/transport/pool.d.ts.map +1 -1
- package/dist/transport/pool.js +268 -27
- package/dist/transport/pool.js.map +1 -1
- package/package.json +1 -1
package/dist/transport/pool.js
CHANGED
|
@@ -9,10 +9,14 @@ let WebSocketImpl = loadDefaultWebSocketConstructor();
|
|
|
9
9
|
export const connectionPool = [];
|
|
10
10
|
const turnStateByContext = new Map();
|
|
11
11
|
const lastResponseIDByContext = new Map();
|
|
12
|
+
const contextEpochByKey = new Map();
|
|
13
|
+
const contextOwnerByKey = new Map();
|
|
12
14
|
const scopeCooldowns = new Map();
|
|
13
15
|
const authEndpoints = new Map();
|
|
16
|
+
const OAUTH_MISSING_ACCOUNT_SEED = "codex-oauth-missing-account";
|
|
14
17
|
let nextConnectionID = 1;
|
|
15
18
|
let nextStreamID = 1;
|
|
19
|
+
let contextEpochClock = 1;
|
|
16
20
|
export const readyState = {
|
|
17
21
|
CONNECTING: 0,
|
|
18
22
|
OPEN: 1,
|
|
@@ -39,14 +43,25 @@ function authScopeHash(headers) {
|
|
|
39
43
|
.join("\n");
|
|
40
44
|
return crypto.createHash("sha256").update(auth).digest("hex");
|
|
41
45
|
}
|
|
42
|
-
function accountFingerprint(headers) {
|
|
46
|
+
function accountFingerprint(headers, wsUrl) {
|
|
43
47
|
const account = headers["ChatGPT-Account-ID"] ?? headers["ChatGPT-Account-Id"] ?? headers["chatgpt-account-id"] ?? "";
|
|
44
48
|
if (account) {
|
|
45
49
|
return crypto.createHash("sha256").update(`acct:${account}`).digest("hex").slice(0, 32);
|
|
46
50
|
}
|
|
51
|
+
// Codex OAuth without account id: stable shared sentinel so bearer rotation cannot bypass account caps.
|
|
52
|
+
if (providerForUrl(wsUrl) === "codex") {
|
|
53
|
+
return crypto.createHash("sha256").update(`acct:${OAUTH_MISSING_ACCOUNT_SEED}`).digest("hex").slice(0, 32);
|
|
54
|
+
}
|
|
55
|
+
// Public API keys: fingerprint by auth material.
|
|
47
56
|
const auth = headers.Authorization ?? headers.authorization ?? "";
|
|
48
57
|
return crypto.createHash("sha256").update(`auth:${auth}`).digest("hex").slice(0, 32);
|
|
49
58
|
}
|
|
59
|
+
function authGenerationKey(endpoint, accountFp) {
|
|
60
|
+
return `${endpoint}::${accountFp}`;
|
|
61
|
+
}
|
|
62
|
+
function hashIdentity(kind, value) {
|
|
63
|
+
return crypto.createHash("sha256").update(`${kind}:${value}`).digest("hex").slice(0, 32);
|
|
64
|
+
}
|
|
50
65
|
function breakerDomainKey(provider, origin, accountFp) {
|
|
51
66
|
return crypto.createHash("sha256").update(`${provider}\n${origin}\n${accountFp}`).digest("hex");
|
|
52
67
|
}
|
|
@@ -60,7 +75,92 @@ function scopeKey(wsUrl, headers) {
|
|
|
60
75
|
return `${wsUrl}::${authScopeHash(headers)}`;
|
|
61
76
|
}
|
|
62
77
|
function contextScopeKey(wsUrl, headers, context) {
|
|
63
|
-
|
|
78
|
+
const sessionHash = context.sessionID ? hashIdentity("session", context.sessionID) : "";
|
|
79
|
+
const agentHash = context.agent ? hashIdentity("agent", context.agent) : "";
|
|
80
|
+
return `${scopeKey(wsUrl, headers)}::ctx:${sessionHash}::agt:${agentHash}`;
|
|
81
|
+
}
|
|
82
|
+
function contextKeyMatchesSession(contextKey, sessionID) {
|
|
83
|
+
const sessionHash = hashIdentity("session", sessionID);
|
|
84
|
+
return contextKey.includes(`::ctx:${sessionHash}::`);
|
|
85
|
+
}
|
|
86
|
+
function contextEntryCount() {
|
|
87
|
+
const keys = new Set();
|
|
88
|
+
for (const key of turnStateByContext.keys())
|
|
89
|
+
keys.add(key);
|
|
90
|
+
for (const key of lastResponseIDByContext.keys())
|
|
91
|
+
keys.add(key);
|
|
92
|
+
for (const key of contextEpochByKey.keys())
|
|
93
|
+
keys.add(key);
|
|
94
|
+
for (const key of contextOwnerByKey.keys())
|
|
95
|
+
keys.add(key);
|
|
96
|
+
return keys.size;
|
|
97
|
+
}
|
|
98
|
+
function scrubIdleConnectionContext(contextKey) {
|
|
99
|
+
for (const conn of connectionPool) {
|
|
100
|
+
if (conn.contextKey !== contextKey || conn.busy)
|
|
101
|
+
continue;
|
|
102
|
+
conn.lastResponseID = undefined;
|
|
103
|
+
conn.turnState = undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function boundContextMaps() {
|
|
107
|
+
const max = transportConfig.maxAccountingEntries;
|
|
108
|
+
while (contextEntryCount() > max) {
|
|
109
|
+
const oldest = contextEpochByKey.keys().next().value ??
|
|
110
|
+
turnStateByContext.keys().next().value ??
|
|
111
|
+
lastResponseIDByContext.keys().next().value ??
|
|
112
|
+
contextOwnerByKey.keys().next().value;
|
|
113
|
+
if (oldest === undefined)
|
|
114
|
+
break;
|
|
115
|
+
turnStateByContext.delete(oldest);
|
|
116
|
+
lastResponseIDByContext.delete(oldest);
|
|
117
|
+
contextEpochByKey.delete(oldest);
|
|
118
|
+
contextOwnerByKey.delete(oldest);
|
|
119
|
+
// Evicted map keys must not be reintroduced from idle local connection state.
|
|
120
|
+
scrubIdleConnectionContext(oldest);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function currentContextEpoch(contextKey) {
|
|
124
|
+
return contextEpochByKey.get(contextKey) ?? 0;
|
|
125
|
+
}
|
|
126
|
+
function bumpContextEpoch(contextKey) {
|
|
127
|
+
const next = Math.max(contextEpochClock, currentContextEpoch(contextKey) + 1);
|
|
128
|
+
contextEpochClock = next + 1;
|
|
129
|
+
contextEpochByKey.delete(contextKey);
|
|
130
|
+
contextEpochByKey.set(contextKey, next);
|
|
131
|
+
boundContextMaps();
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
function setContextMapValue(map, contextKey, value) {
|
|
135
|
+
map.delete(contextKey);
|
|
136
|
+
map.set(contextKey, value);
|
|
137
|
+
if (!contextEpochByKey.has(contextKey)) {
|
|
138
|
+
contextEpochByKey.set(contextKey, currentContextEpoch(contextKey));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const epoch = contextEpochByKey.get(contextKey);
|
|
142
|
+
contextEpochByKey.delete(contextKey);
|
|
143
|
+
contextEpochByKey.set(contextKey, epoch);
|
|
144
|
+
}
|
|
145
|
+
boundContextMaps();
|
|
146
|
+
}
|
|
147
|
+
function clearContextAccounting(contextKey, options = {}) {
|
|
148
|
+
turnStateByContext.delete(contextKey);
|
|
149
|
+
lastResponseIDByContext.delete(contextKey);
|
|
150
|
+
contextOwnerByKey.delete(contextKey);
|
|
151
|
+
if (options.bumpEpoch !== false)
|
|
152
|
+
bumpContextEpoch(contextKey);
|
|
153
|
+
else
|
|
154
|
+
boundContextMaps();
|
|
155
|
+
}
|
|
156
|
+
function canPublishContextState(conn) {
|
|
157
|
+
if (conn.staleAuth || !isCurrentAuth(conn))
|
|
158
|
+
return false;
|
|
159
|
+
return conn.contextEpoch === currentContextEpoch(conn.contextKey);
|
|
160
|
+
}
|
|
161
|
+
function admitContextTurn(conn) {
|
|
162
|
+
conn.contextEpoch = currentContextEpoch(conn.contextKey);
|
|
163
|
+
setContextMapValue(contextOwnerByKey, conn.contextKey, conn.id);
|
|
64
164
|
}
|
|
65
165
|
function headersForConnection(wsUrl, baseHeaders, context, key) {
|
|
66
166
|
const headers = { ...baseHeaders };
|
|
@@ -143,11 +243,12 @@ function boundAuthEndpoints() {
|
|
|
143
243
|
authEndpoints.delete(oldest);
|
|
144
244
|
}
|
|
145
245
|
}
|
|
146
|
-
function bindAuthGeneration(endpoint, hash) {
|
|
147
|
-
|
|
246
|
+
function bindAuthGeneration(endpoint, accountFp, hash) {
|
|
247
|
+
const key = authGenerationKey(endpoint, accountFp);
|
|
248
|
+
let state = authEndpoints.get(key);
|
|
148
249
|
if (!state) {
|
|
149
250
|
state = { currentGeneration: 1, currentHash: hash, bindings: new Map([[hash, 1]]), order: [hash] };
|
|
150
|
-
authEndpoints.set(
|
|
251
|
+
authEndpoints.set(key, state);
|
|
151
252
|
boundAuthEndpoints();
|
|
152
253
|
return 1;
|
|
153
254
|
}
|
|
@@ -171,13 +272,17 @@ function bindAuthGeneration(endpoint, hash) {
|
|
|
171
272
|
}
|
|
172
273
|
state.bindings.delete(oldest);
|
|
173
274
|
}
|
|
275
|
+
// Refresh map insertion order for bounded eviction fairness.
|
|
276
|
+
authEndpoints.delete(key);
|
|
277
|
+
authEndpoints.set(key, state);
|
|
278
|
+
boundAuthEndpoints();
|
|
174
279
|
return state.currentGeneration;
|
|
175
280
|
}
|
|
176
|
-
function currentAuthGeneration(endpoint) {
|
|
177
|
-
return authEndpoints.get(endpoint)?.currentGeneration ?? 0;
|
|
281
|
+
function currentAuthGeneration(endpoint, accountFp) {
|
|
282
|
+
return authEndpoints.get(authGenerationKey(endpoint, accountFp))?.currentGeneration ?? 0;
|
|
178
283
|
}
|
|
179
284
|
function isCurrentAuth(conn) {
|
|
180
|
-
return conn.authGeneration === currentAuthGeneration(conn.endpointKey);
|
|
285
|
+
return conn.authGeneration === currentAuthGeneration(conn.endpointKey, conn.accountFp);
|
|
181
286
|
}
|
|
182
287
|
function finishConnectPermit(conn, result) {
|
|
183
288
|
if (!conn.connectPermit || conn.connectFinished)
|
|
@@ -554,8 +659,10 @@ function clearLastResponseID(conn) {
|
|
|
554
659
|
lastResponseIDByContext.delete(conn.contextKey);
|
|
555
660
|
}
|
|
556
661
|
function persistLastResponseID(conn, responseID) {
|
|
662
|
+
if (!canPublishContextState(conn))
|
|
663
|
+
return;
|
|
557
664
|
conn.lastResponseID = responseID;
|
|
558
|
-
lastResponseIDByContext
|
|
665
|
+
setContextMapValue(lastResponseIDByContext, conn.contextKey, responseID);
|
|
559
666
|
}
|
|
560
667
|
function frameResponseID(frame) {
|
|
561
668
|
let responseID;
|
|
@@ -805,6 +912,7 @@ function connect(conn) {
|
|
|
805
912
|
return;
|
|
806
913
|
conn.lastCloseCode = 1006;
|
|
807
914
|
conn.lastCloseReason = "connection timed out";
|
|
915
|
+
conn.sawConnectTimeout = true;
|
|
808
916
|
finishConnectPermit(conn, { success: false, breakerEligible: true, timeout: true });
|
|
809
917
|
handleSocketLoss(conn);
|
|
810
918
|
}, transportConfig.connectTimeoutMs);
|
|
@@ -822,14 +930,32 @@ function connect(conn) {
|
|
|
822
930
|
if (generation !== conn.generation)
|
|
823
931
|
return;
|
|
824
932
|
const turnState = headerValue(response, X_CODEX_TURN_STATE_HEADER);
|
|
825
|
-
if (turnState) {
|
|
933
|
+
if (turnState && canPublishContextState(conn)) {
|
|
826
934
|
conn.turnState = turnState;
|
|
827
|
-
turnStateByContext
|
|
935
|
+
setContextMapValue(turnStateByContext, conn.contextKey, turnState);
|
|
828
936
|
}
|
|
829
937
|
conn.serverReasoningIncluded = headerValue(response, X_REASONING_INCLUDED_HEADER) !== undefined;
|
|
830
938
|
conn.modelsEtag = headerValue(response, X_MODELS_ETAG_HEADER);
|
|
831
939
|
conn.serverModel = headerValue(response, OPENAI_MODEL_HEADER);
|
|
832
940
|
};
|
|
941
|
+
const rejectInboundOverflow = (message, probeBytes) => {
|
|
942
|
+
const streamKey = conn.pending?.streamKey ?? `overflow:${conn.id}`;
|
|
943
|
+
// Non-mutating reject path: oversized probe increments queueOverflows and retains zero bytes.
|
|
944
|
+
getTransportCoordinator().adjustQueue({
|
|
945
|
+
provider: conn.provider,
|
|
946
|
+
streamKey,
|
|
947
|
+
bytes: probeBytes,
|
|
948
|
+
messages: 1,
|
|
949
|
+
});
|
|
950
|
+
fail(conn, transportError({
|
|
951
|
+
code: "queue_overflow",
|
|
952
|
+
phase: "active",
|
|
953
|
+
message,
|
|
954
|
+
fallbackEligible: false,
|
|
955
|
+
breakerEligible: false,
|
|
956
|
+
safeToReplay: false,
|
|
957
|
+
}), true);
|
|
958
|
+
};
|
|
833
959
|
const handleMessage = (data, isBinary) => {
|
|
834
960
|
if (generation !== conn.generation)
|
|
835
961
|
return;
|
|
@@ -838,7 +964,23 @@ function connect(conn) {
|
|
|
838
964
|
fail(conn, new Error("unexpected binary websocket event"), true);
|
|
839
965
|
return;
|
|
840
966
|
}
|
|
967
|
+
const decoded = decodeFrameData(data);
|
|
968
|
+
const maxBytes = transportConfig.queueMaxBytesPerStream;
|
|
969
|
+
const incomingBytes = Buffer.byteLength(decoded, "utf8");
|
|
970
|
+
if (incomingBytes > maxBytes) {
|
|
971
|
+
rejectInboundOverflow("OpenAI WebSocket inbound frame queue overflow", incomingBytes);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const carryBytes = Buffer.byteLength(conn.frameCarryover, "utf8");
|
|
975
|
+
if (carryBytes + incomingBytes > maxBytes) {
|
|
976
|
+
rejectInboundOverflow("OpenAI WebSocket inbound carryover queue overflow", carryBytes + incomingBytes);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
841
979
|
const parsed = parseFrames(data, conn.frameCarryover);
|
|
980
|
+
if (Buffer.byteLength(parsed.carryover, "utf8") > maxBytes) {
|
|
981
|
+
rejectInboundOverflow("OpenAI WebSocket inbound carryover queue overflow", Buffer.byteLength(parsed.carryover, "utf8"));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
842
984
|
conn.frameCarryover = parsed.carryover;
|
|
843
985
|
for (const frame of parsed.frames)
|
|
844
986
|
enqueueSSE(conn, frame);
|
|
@@ -847,6 +989,7 @@ function connect(conn) {
|
|
|
847
989
|
if (generation !== conn.generation)
|
|
848
990
|
return;
|
|
849
991
|
conn.lastErrorMessage = messageFromError(error) ?? "unknown websocket error";
|
|
992
|
+
conn.sawConnectNonTimeout = true;
|
|
850
993
|
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
851
994
|
handleSocketLoss(conn);
|
|
852
995
|
};
|
|
@@ -860,6 +1003,8 @@ function connect(conn) {
|
|
|
860
1003
|
else if (conn.lastCloseReason === undefined && closeCodeFrom(codeOrEvent) === undefined) {
|
|
861
1004
|
conn.lastCloseReason = "socket closed without close code";
|
|
862
1005
|
}
|
|
1006
|
+
if (conn.lastCloseReason !== "connection timed out")
|
|
1007
|
+
conn.sawConnectNonTimeout = true;
|
|
863
1008
|
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
864
1009
|
handleSocketLoss(conn);
|
|
865
1010
|
};
|
|
@@ -871,6 +1016,7 @@ function connect(conn) {
|
|
|
871
1016
|
conn.lastCloseCode = status;
|
|
872
1017
|
conn.retryAfterMs = parseRetryAfterMs(response);
|
|
873
1018
|
conn.lastCloseReason = `websocket handshake failed${status !== undefined ? ` with status ${status}` : ""}`;
|
|
1019
|
+
conn.sawConnectNonTimeout = true;
|
|
874
1020
|
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
875
1021
|
handleSocketLoss(conn);
|
|
876
1022
|
};
|
|
@@ -879,7 +1025,7 @@ function connect(conn) {
|
|
|
879
1025
|
try {
|
|
880
1026
|
ws = new WebSocketImpl(conn.wsUrl, {
|
|
881
1027
|
headers: conn.headers,
|
|
882
|
-
perMessageDeflate:
|
|
1028
|
+
perMessageDeflate: false,
|
|
883
1029
|
finishRequest(request) {
|
|
884
1030
|
if (attachUpgrade)
|
|
885
1031
|
request.on?.("upgrade", handleUpgrade);
|
|
@@ -889,6 +1035,7 @@ function connect(conn) {
|
|
|
889
1035
|
}
|
|
890
1036
|
catch (error) {
|
|
891
1037
|
conn.lastErrorMessage = messageFromError(error) ?? "websocket constructor failed";
|
|
1038
|
+
conn.sawConnectNonTimeout = true;
|
|
892
1039
|
finishConnectPermit(conn, { success: false, breakerEligible: true });
|
|
893
1040
|
queueMicrotask(() => handleSocketLoss(conn));
|
|
894
1041
|
return;
|
|
@@ -911,6 +1058,10 @@ function connect(conn) {
|
|
|
911
1058
|
off(ws, "close", handleClose);
|
|
912
1059
|
if (attachUpgrade)
|
|
913
1060
|
off(ws, "unexpected-response", handleUnexpectedResponse);
|
|
1061
|
+
// Keep one inert terminal listener on the detached socket so a late EventEmitter
|
|
1062
|
+
// 'error' after terminate/close (common while CONNECTING) cannot throw unhandled.
|
|
1063
|
+
// Retry disposal calls detach() directly before closing the prior socket.
|
|
1064
|
+
on(ws, "error", () => { });
|
|
914
1065
|
};
|
|
915
1066
|
if (ws.readyState === readyState.OPEN)
|
|
916
1067
|
queueMicrotask(handleOpen);
|
|
@@ -964,12 +1115,24 @@ function handleSocketLoss(conn) {
|
|
|
964
1115
|
if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted) {
|
|
965
1116
|
return;
|
|
966
1117
|
}
|
|
1118
|
+
if (conn.staleAuth || !isCurrentAuth(conn)) {
|
|
1119
|
+
getTransportCoordinator().noteAuthStale();
|
|
1120
|
+
fail(conn, transportError({
|
|
1121
|
+
code: "stale_auth",
|
|
1122
|
+
phase: "pre_send",
|
|
1123
|
+
message: "OpenAI WebSocket auth generation is stale; refusing reconnect",
|
|
1124
|
+
fallbackEligible: true,
|
|
1125
|
+
safeToReplay: true,
|
|
1126
|
+
}), true);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
967
1129
|
connect(conn);
|
|
968
1130
|
}, retryDelay(conn.reconnectAttempts, retryAfterMs)));
|
|
969
1131
|
return;
|
|
970
1132
|
}
|
|
1133
|
+
const allTimeouts = conn.sawConnectTimeout && !conn.sawConnectNonTimeout;
|
|
971
1134
|
fail(conn, transportError({
|
|
972
|
-
code: "connect_failure",
|
|
1135
|
+
code: allTimeouts ? "handshake_timeout" : "connect_failure",
|
|
973
1136
|
phase: "handshake",
|
|
974
1137
|
message: formatFailureMessage(conn, "retry limit reached before response.create was sent"),
|
|
975
1138
|
fallbackEligible: true,
|
|
@@ -987,7 +1150,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
|
|
|
987
1150
|
const endpoint = endpointKey(wsUrl);
|
|
988
1151
|
const provider = providerForUrl(wsUrl);
|
|
989
1152
|
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
990
|
-
const accountFp = accountFingerprint(headers);
|
|
1153
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
991
1154
|
const now = Date.now();
|
|
992
1155
|
const conn = {
|
|
993
1156
|
id: `ws-${nextConnectionID++}`,
|
|
@@ -1001,7 +1164,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
|
|
|
1001
1164
|
accountFp,
|
|
1002
1165
|
provider,
|
|
1003
1166
|
origin,
|
|
1004
|
-
authGeneration: bindAuthGeneration(endpoint, hash),
|
|
1167
|
+
authGeneration: bindAuthGeneration(endpoint, accountFp, hash),
|
|
1005
1168
|
busy: false,
|
|
1006
1169
|
warm,
|
|
1007
1170
|
staleAuth: false,
|
|
@@ -1025,6 +1188,9 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
|
|
|
1025
1188
|
turnCount: 0,
|
|
1026
1189
|
intent: warm ? "prewarm" : "demand",
|
|
1027
1190
|
breakerDomain: breakerDomainKey(provider, origin, accountFp),
|
|
1191
|
+
contextEpoch: currentContextEpoch(key),
|
|
1192
|
+
sawConnectTimeout: false,
|
|
1193
|
+
sawConnectNonTimeout: false,
|
|
1028
1194
|
};
|
|
1029
1195
|
connectionPool.push(conn);
|
|
1030
1196
|
getTransportCoordinator().adjustSessions(1);
|
|
@@ -1055,20 +1221,25 @@ function activeBusy(key) {
|
|
|
1055
1221
|
}
|
|
1056
1222
|
export function invalidateStaleAuthConnections(wsUrl, headers) {
|
|
1057
1223
|
const endpoint = endpointKey(wsUrl);
|
|
1224
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
1058
1225
|
const hash = authScopeHash(headers);
|
|
1059
|
-
const
|
|
1226
|
+
const authKey = authGenerationKey(endpoint, accountFp);
|
|
1227
|
+
const state = authEndpoints.get(authKey);
|
|
1060
1228
|
// ABA: presenting an old remembered binding must not become current or stale newer sockets.
|
|
1061
1229
|
if (state && hash !== state.currentHash && state.bindings.has(hash))
|
|
1062
1230
|
return;
|
|
1063
|
-
const generation = bindAuthGeneration(endpoint, hash);
|
|
1064
|
-
const keepHash = authEndpoints.get(
|
|
1065
|
-
const keepGen = authEndpoints.get(
|
|
1231
|
+
const generation = bindAuthGeneration(endpoint, accountFp, hash);
|
|
1232
|
+
const keepHash = authEndpoints.get(authKey)?.currentHash ?? hash;
|
|
1233
|
+
const keepGen = authEndpoints.get(authKey)?.currentGeneration ?? generation;
|
|
1066
1234
|
for (const conn of [...connectionPool]) {
|
|
1067
1235
|
if (conn.wsUrl !== wsUrl)
|
|
1068
1236
|
continue;
|
|
1237
|
+
if (conn.accountFp !== accountFp)
|
|
1238
|
+
continue;
|
|
1069
1239
|
if (conn.scopeHash === keepHash && conn.authGeneration === keepGen)
|
|
1070
1240
|
continue;
|
|
1071
1241
|
conn.staleAuth = true;
|
|
1242
|
+
clearContextAccounting(conn.contextKey);
|
|
1072
1243
|
if (conn.busy)
|
|
1073
1244
|
continue;
|
|
1074
1245
|
remove(conn);
|
|
@@ -1124,8 +1295,9 @@ function reserveConnection(wsUrl, headers, context) {
|
|
|
1124
1295
|
}
|
|
1125
1296
|
const endpoint = endpointKey(wsUrl);
|
|
1126
1297
|
const hash = authScopeHash(headers);
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1298
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
1299
|
+
const generation = bindAuthGeneration(endpoint, accountFp, hash);
|
|
1300
|
+
if (generation !== currentAuthGeneration(endpoint, accountFp)) {
|
|
1129
1301
|
coordinator.noteAuthStale();
|
|
1130
1302
|
admitMiss("stale_auth", "OpenAI WebSocket auth generation is stale");
|
|
1131
1303
|
}
|
|
@@ -1137,10 +1309,13 @@ function reserveConnection(wsUrl, headers, context) {
|
|
|
1137
1309
|
reusable.warm = false;
|
|
1138
1310
|
reusable.intent = "demand";
|
|
1139
1311
|
reusable.turnCount += 1;
|
|
1312
|
+
// Always take the bounded map as source of truth, including absences after clear/eviction.
|
|
1313
|
+
reusable.lastResponseID = lastResponseIDByContext.get(key);
|
|
1314
|
+
reusable.turnState = turnStateByContext.get(key);
|
|
1315
|
+
admitContextTurn(reusable);
|
|
1140
1316
|
coordinator.noteLeaseAdmission();
|
|
1141
1317
|
return reusable;
|
|
1142
1318
|
}
|
|
1143
|
-
const accountFp = accountFingerprint(headers);
|
|
1144
1319
|
const provider = providerForUrl(wsUrl);
|
|
1145
1320
|
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
1146
1321
|
const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "demand" });
|
|
@@ -1161,17 +1336,18 @@ function reserveConnection(wsUrl, headers, context) {
|
|
|
1161
1336
|
const conn = create(wsUrl, headers, context, false, started.permit);
|
|
1162
1337
|
conn.busy = true;
|
|
1163
1338
|
conn.turnCount += 1;
|
|
1339
|
+
admitContextTurn(conn);
|
|
1164
1340
|
coordinator.noteLeaseAdmission();
|
|
1165
1341
|
return conn;
|
|
1166
1342
|
}
|
|
1167
1343
|
export function acquireConnection(wsUrl, headers, context, _signal) {
|
|
1168
1344
|
return reserveConnection(wsUrl, headers, context);
|
|
1169
1345
|
}
|
|
1170
|
-
export function ensureWarmConnection(wsUrl, headers) {
|
|
1346
|
+
export function ensureWarmConnection(wsUrl, headers, context = {}) {
|
|
1171
1347
|
const coordinator = getTransportCoordinator();
|
|
1172
1348
|
coordinator.notePrewarmAttempt();
|
|
1173
1349
|
invalidateStaleAuthConnections(wsUrl, headers);
|
|
1174
|
-
const key = contextScopeKey(wsUrl, headers,
|
|
1350
|
+
const key = contextScopeKey(wsUrl, headers, context);
|
|
1175
1351
|
const now = Date.now();
|
|
1176
1352
|
cleanupExpiredIdle(now);
|
|
1177
1353
|
const existing = connectionPool.find((conn) => conn.scopeKey === key &&
|
|
@@ -1185,7 +1361,7 @@ export function ensureWarmConnection(wsUrl, headers) {
|
|
|
1185
1361
|
existing.idleTimer = null;
|
|
1186
1362
|
return existing;
|
|
1187
1363
|
}
|
|
1188
|
-
const accountFp = accountFingerprint(headers);
|
|
1364
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
1189
1365
|
const provider = providerForUrl(wsUrl);
|
|
1190
1366
|
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
1191
1367
|
const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "prewarm" });
|
|
@@ -1205,7 +1381,66 @@ export function ensureWarmConnection(wsUrl, headers) {
|
|
|
1205
1381
|
coordinator.notePrewarmDeferred();
|
|
1206
1382
|
return undefined;
|
|
1207
1383
|
}
|
|
1208
|
-
return create(wsUrl, headers,
|
|
1384
|
+
return create(wsUrl, headers, context, true, started.permit);
|
|
1385
|
+
}
|
|
1386
|
+
export function clearTransportContextState(wsUrl, headers, context) {
|
|
1387
|
+
const key = contextScopeKey(wsUrl, headers, context);
|
|
1388
|
+
clearContextAccounting(key);
|
|
1389
|
+
for (const conn of connectionPool) {
|
|
1390
|
+
if (conn.contextKey !== key)
|
|
1391
|
+
continue;
|
|
1392
|
+
conn.lastResponseID = undefined;
|
|
1393
|
+
conn.turnState = undefined;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
export function clearSessionTransportState(sessionID) {
|
|
1397
|
+
const keys = new Set();
|
|
1398
|
+
for (const key of turnStateByContext.keys()) {
|
|
1399
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1400
|
+
keys.add(key);
|
|
1401
|
+
}
|
|
1402
|
+
for (const key of lastResponseIDByContext.keys()) {
|
|
1403
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1404
|
+
keys.add(key);
|
|
1405
|
+
}
|
|
1406
|
+
for (const key of contextEpochByKey.keys()) {
|
|
1407
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1408
|
+
keys.add(key);
|
|
1409
|
+
}
|
|
1410
|
+
for (const key of contextOwnerByKey.keys()) {
|
|
1411
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1412
|
+
keys.add(key);
|
|
1413
|
+
}
|
|
1414
|
+
for (const key of scopeCooldowns.keys()) {
|
|
1415
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1416
|
+
scopeCooldowns.delete(key);
|
|
1417
|
+
}
|
|
1418
|
+
for (const key of keys)
|
|
1419
|
+
clearContextAccounting(key);
|
|
1420
|
+
for (const conn of connectionPool) {
|
|
1421
|
+
if (!contextKeyMatchesSession(conn.contextKey, sessionID))
|
|
1422
|
+
continue;
|
|
1423
|
+
conn.lastResponseID = undefined;
|
|
1424
|
+
conn.turnState = undefined;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
export function clearAllTransportState() {
|
|
1428
|
+
const keys = new Set([
|
|
1429
|
+
...turnStateByContext.keys(),
|
|
1430
|
+
...lastResponseIDByContext.keys(),
|
|
1431
|
+
...contextEpochByKey.keys(),
|
|
1432
|
+
...contextOwnerByKey.keys(),
|
|
1433
|
+
]);
|
|
1434
|
+
for (const key of keys)
|
|
1435
|
+
clearContextAccounting(key);
|
|
1436
|
+
turnStateByContext.clear();
|
|
1437
|
+
lastResponseIDByContext.clear();
|
|
1438
|
+
contextOwnerByKey.clear();
|
|
1439
|
+
// Keep bumped epochs so in-flight completions cannot republish under a recycled 0 epoch.
|
|
1440
|
+
for (const conn of connectionPool) {
|
|
1441
|
+
conn.lastResponseID = undefined;
|
|
1442
|
+
conn.turnState = undefined;
|
|
1443
|
+
}
|
|
1209
1444
|
}
|
|
1210
1445
|
export function closeConnections(predicate = () => true, message = "Session disposed") {
|
|
1211
1446
|
for (const conn of [...connectionPool]) {
|
|
@@ -1251,7 +1486,10 @@ export function syncCoordinatorFromTransportConfig() {
|
|
|
1251
1486
|
});
|
|
1252
1487
|
}
|
|
1253
1488
|
export function getTransportSnapshotForTesting() {
|
|
1254
|
-
return
|
|
1489
|
+
return {
|
|
1490
|
+
...getTransportCoordinator().snapshot(),
|
|
1491
|
+
contextEntries: contextEntryCount(),
|
|
1492
|
+
};
|
|
1255
1493
|
}
|
|
1256
1494
|
export function resetPoolForTesting() {
|
|
1257
1495
|
for (const conn of [...connectionPool]) {
|
|
@@ -1267,8 +1505,11 @@ export function resetPoolForTesting() {
|
|
|
1267
1505
|
}
|
|
1268
1506
|
turnStateByContext.clear();
|
|
1269
1507
|
lastResponseIDByContext.clear();
|
|
1508
|
+
contextEpochByKey.clear();
|
|
1509
|
+
contextOwnerByKey.clear();
|
|
1270
1510
|
scopeCooldowns.clear();
|
|
1271
1511
|
authEndpoints.clear();
|
|
1512
|
+
contextEpochClock = 1;
|
|
1272
1513
|
nextStreamID = 1;
|
|
1273
1514
|
resetTransportCoordinatorForTesting();
|
|
1274
1515
|
syncCoordinatorFromTransportConfig();
|