openai-ws-opencode 0.1.28 → 0.1.29
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 +1 -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 +264 -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;
|
|
@@ -964,12 +1111,24 @@ function handleSocketLoss(conn) {
|
|
|
964
1111
|
if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent || conn.pending.writeCommitted) {
|
|
965
1112
|
return;
|
|
966
1113
|
}
|
|
1114
|
+
if (conn.staleAuth || !isCurrentAuth(conn)) {
|
|
1115
|
+
getTransportCoordinator().noteAuthStale();
|
|
1116
|
+
fail(conn, transportError({
|
|
1117
|
+
code: "stale_auth",
|
|
1118
|
+
phase: "pre_send",
|
|
1119
|
+
message: "OpenAI WebSocket auth generation is stale; refusing reconnect",
|
|
1120
|
+
fallbackEligible: true,
|
|
1121
|
+
safeToReplay: true,
|
|
1122
|
+
}), true);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
967
1125
|
connect(conn);
|
|
968
1126
|
}, retryDelay(conn.reconnectAttempts, retryAfterMs)));
|
|
969
1127
|
return;
|
|
970
1128
|
}
|
|
1129
|
+
const allTimeouts = conn.sawConnectTimeout && !conn.sawConnectNonTimeout;
|
|
971
1130
|
fail(conn, transportError({
|
|
972
|
-
code: "connect_failure",
|
|
1131
|
+
code: allTimeouts ? "handshake_timeout" : "connect_failure",
|
|
973
1132
|
phase: "handshake",
|
|
974
1133
|
message: formatFailureMessage(conn, "retry limit reached before response.create was sent"),
|
|
975
1134
|
fallbackEligible: true,
|
|
@@ -987,7 +1146,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
|
|
|
987
1146
|
const endpoint = endpointKey(wsUrl);
|
|
988
1147
|
const provider = providerForUrl(wsUrl);
|
|
989
1148
|
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
990
|
-
const accountFp = accountFingerprint(headers);
|
|
1149
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
991
1150
|
const now = Date.now();
|
|
992
1151
|
const conn = {
|
|
993
1152
|
id: `ws-${nextConnectionID++}`,
|
|
@@ -1001,7 +1160,7 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
|
|
|
1001
1160
|
accountFp,
|
|
1002
1161
|
provider,
|
|
1003
1162
|
origin,
|
|
1004
|
-
authGeneration: bindAuthGeneration(endpoint, hash),
|
|
1163
|
+
authGeneration: bindAuthGeneration(endpoint, accountFp, hash),
|
|
1005
1164
|
busy: false,
|
|
1006
1165
|
warm,
|
|
1007
1166
|
staleAuth: false,
|
|
@@ -1025,6 +1184,9 @@ function create(wsUrl, headers, context = {}, warm = false, permit) {
|
|
|
1025
1184
|
turnCount: 0,
|
|
1026
1185
|
intent: warm ? "prewarm" : "demand",
|
|
1027
1186
|
breakerDomain: breakerDomainKey(provider, origin, accountFp),
|
|
1187
|
+
contextEpoch: currentContextEpoch(key),
|
|
1188
|
+
sawConnectTimeout: false,
|
|
1189
|
+
sawConnectNonTimeout: false,
|
|
1028
1190
|
};
|
|
1029
1191
|
connectionPool.push(conn);
|
|
1030
1192
|
getTransportCoordinator().adjustSessions(1);
|
|
@@ -1055,20 +1217,25 @@ function activeBusy(key) {
|
|
|
1055
1217
|
}
|
|
1056
1218
|
export function invalidateStaleAuthConnections(wsUrl, headers) {
|
|
1057
1219
|
const endpoint = endpointKey(wsUrl);
|
|
1220
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
1058
1221
|
const hash = authScopeHash(headers);
|
|
1059
|
-
const
|
|
1222
|
+
const authKey = authGenerationKey(endpoint, accountFp);
|
|
1223
|
+
const state = authEndpoints.get(authKey);
|
|
1060
1224
|
// ABA: presenting an old remembered binding must not become current or stale newer sockets.
|
|
1061
1225
|
if (state && hash !== state.currentHash && state.bindings.has(hash))
|
|
1062
1226
|
return;
|
|
1063
|
-
const generation = bindAuthGeneration(endpoint, hash);
|
|
1064
|
-
const keepHash = authEndpoints.get(
|
|
1065
|
-
const keepGen = authEndpoints.get(
|
|
1227
|
+
const generation = bindAuthGeneration(endpoint, accountFp, hash);
|
|
1228
|
+
const keepHash = authEndpoints.get(authKey)?.currentHash ?? hash;
|
|
1229
|
+
const keepGen = authEndpoints.get(authKey)?.currentGeneration ?? generation;
|
|
1066
1230
|
for (const conn of [...connectionPool]) {
|
|
1067
1231
|
if (conn.wsUrl !== wsUrl)
|
|
1068
1232
|
continue;
|
|
1233
|
+
if (conn.accountFp !== accountFp)
|
|
1234
|
+
continue;
|
|
1069
1235
|
if (conn.scopeHash === keepHash && conn.authGeneration === keepGen)
|
|
1070
1236
|
continue;
|
|
1071
1237
|
conn.staleAuth = true;
|
|
1238
|
+
clearContextAccounting(conn.contextKey);
|
|
1072
1239
|
if (conn.busy)
|
|
1073
1240
|
continue;
|
|
1074
1241
|
remove(conn);
|
|
@@ -1124,8 +1291,9 @@ function reserveConnection(wsUrl, headers, context) {
|
|
|
1124
1291
|
}
|
|
1125
1292
|
const endpoint = endpointKey(wsUrl);
|
|
1126
1293
|
const hash = authScopeHash(headers);
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1294
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
1295
|
+
const generation = bindAuthGeneration(endpoint, accountFp, hash);
|
|
1296
|
+
if (generation !== currentAuthGeneration(endpoint, accountFp)) {
|
|
1129
1297
|
coordinator.noteAuthStale();
|
|
1130
1298
|
admitMiss("stale_auth", "OpenAI WebSocket auth generation is stale");
|
|
1131
1299
|
}
|
|
@@ -1137,10 +1305,13 @@ function reserveConnection(wsUrl, headers, context) {
|
|
|
1137
1305
|
reusable.warm = false;
|
|
1138
1306
|
reusable.intent = "demand";
|
|
1139
1307
|
reusable.turnCount += 1;
|
|
1308
|
+
// Always take the bounded map as source of truth, including absences after clear/eviction.
|
|
1309
|
+
reusable.lastResponseID = lastResponseIDByContext.get(key);
|
|
1310
|
+
reusable.turnState = turnStateByContext.get(key);
|
|
1311
|
+
admitContextTurn(reusable);
|
|
1140
1312
|
coordinator.noteLeaseAdmission();
|
|
1141
1313
|
return reusable;
|
|
1142
1314
|
}
|
|
1143
|
-
const accountFp = accountFingerprint(headers);
|
|
1144
1315
|
const provider = providerForUrl(wsUrl);
|
|
1145
1316
|
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
1146
1317
|
const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "demand" });
|
|
@@ -1161,17 +1332,18 @@ function reserveConnection(wsUrl, headers, context) {
|
|
|
1161
1332
|
const conn = create(wsUrl, headers, context, false, started.permit);
|
|
1162
1333
|
conn.busy = true;
|
|
1163
1334
|
conn.turnCount += 1;
|
|
1335
|
+
admitContextTurn(conn);
|
|
1164
1336
|
coordinator.noteLeaseAdmission();
|
|
1165
1337
|
return conn;
|
|
1166
1338
|
}
|
|
1167
1339
|
export function acquireConnection(wsUrl, headers, context, _signal) {
|
|
1168
1340
|
return reserveConnection(wsUrl, headers, context);
|
|
1169
1341
|
}
|
|
1170
|
-
export function ensureWarmConnection(wsUrl, headers) {
|
|
1342
|
+
export function ensureWarmConnection(wsUrl, headers, context = {}) {
|
|
1171
1343
|
const coordinator = getTransportCoordinator();
|
|
1172
1344
|
coordinator.notePrewarmAttempt();
|
|
1173
1345
|
invalidateStaleAuthConnections(wsUrl, headers);
|
|
1174
|
-
const key = contextScopeKey(wsUrl, headers,
|
|
1346
|
+
const key = contextScopeKey(wsUrl, headers, context);
|
|
1175
1347
|
const now = Date.now();
|
|
1176
1348
|
cleanupExpiredIdle(now);
|
|
1177
1349
|
const existing = connectionPool.find((conn) => conn.scopeKey === key &&
|
|
@@ -1185,7 +1357,7 @@ export function ensureWarmConnection(wsUrl, headers) {
|
|
|
1185
1357
|
existing.idleTimer = null;
|
|
1186
1358
|
return existing;
|
|
1187
1359
|
}
|
|
1188
|
-
const accountFp = accountFingerprint(headers);
|
|
1360
|
+
const accountFp = accountFingerprint(headers, wsUrl);
|
|
1189
1361
|
const provider = providerForUrl(wsUrl);
|
|
1190
1362
|
const origin = canonicalWebSocketOrigin(wsUrl);
|
|
1191
1363
|
const reserved = coordinator.reserveSocket({ accountFp, provider, intent: "prewarm" });
|
|
@@ -1205,7 +1377,66 @@ export function ensureWarmConnection(wsUrl, headers) {
|
|
|
1205
1377
|
coordinator.notePrewarmDeferred();
|
|
1206
1378
|
return undefined;
|
|
1207
1379
|
}
|
|
1208
|
-
return create(wsUrl, headers,
|
|
1380
|
+
return create(wsUrl, headers, context, true, started.permit);
|
|
1381
|
+
}
|
|
1382
|
+
export function clearTransportContextState(wsUrl, headers, context) {
|
|
1383
|
+
const key = contextScopeKey(wsUrl, headers, context);
|
|
1384
|
+
clearContextAccounting(key);
|
|
1385
|
+
for (const conn of connectionPool) {
|
|
1386
|
+
if (conn.contextKey !== key)
|
|
1387
|
+
continue;
|
|
1388
|
+
conn.lastResponseID = undefined;
|
|
1389
|
+
conn.turnState = undefined;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
export function clearSessionTransportState(sessionID) {
|
|
1393
|
+
const keys = new Set();
|
|
1394
|
+
for (const key of turnStateByContext.keys()) {
|
|
1395
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1396
|
+
keys.add(key);
|
|
1397
|
+
}
|
|
1398
|
+
for (const key of lastResponseIDByContext.keys()) {
|
|
1399
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1400
|
+
keys.add(key);
|
|
1401
|
+
}
|
|
1402
|
+
for (const key of contextEpochByKey.keys()) {
|
|
1403
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1404
|
+
keys.add(key);
|
|
1405
|
+
}
|
|
1406
|
+
for (const key of contextOwnerByKey.keys()) {
|
|
1407
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1408
|
+
keys.add(key);
|
|
1409
|
+
}
|
|
1410
|
+
for (const key of scopeCooldowns.keys()) {
|
|
1411
|
+
if (contextKeyMatchesSession(key, sessionID))
|
|
1412
|
+
scopeCooldowns.delete(key);
|
|
1413
|
+
}
|
|
1414
|
+
for (const key of keys)
|
|
1415
|
+
clearContextAccounting(key);
|
|
1416
|
+
for (const conn of connectionPool) {
|
|
1417
|
+
if (!contextKeyMatchesSession(conn.contextKey, sessionID))
|
|
1418
|
+
continue;
|
|
1419
|
+
conn.lastResponseID = undefined;
|
|
1420
|
+
conn.turnState = undefined;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
export function clearAllTransportState() {
|
|
1424
|
+
const keys = new Set([
|
|
1425
|
+
...turnStateByContext.keys(),
|
|
1426
|
+
...lastResponseIDByContext.keys(),
|
|
1427
|
+
...contextEpochByKey.keys(),
|
|
1428
|
+
...contextOwnerByKey.keys(),
|
|
1429
|
+
]);
|
|
1430
|
+
for (const key of keys)
|
|
1431
|
+
clearContextAccounting(key);
|
|
1432
|
+
turnStateByContext.clear();
|
|
1433
|
+
lastResponseIDByContext.clear();
|
|
1434
|
+
contextOwnerByKey.clear();
|
|
1435
|
+
// Keep bumped epochs so in-flight completions cannot republish under a recycled 0 epoch.
|
|
1436
|
+
for (const conn of connectionPool) {
|
|
1437
|
+
conn.lastResponseID = undefined;
|
|
1438
|
+
conn.turnState = undefined;
|
|
1439
|
+
}
|
|
1209
1440
|
}
|
|
1210
1441
|
export function closeConnections(predicate = () => true, message = "Session disposed") {
|
|
1211
1442
|
for (const conn of [...connectionPool]) {
|
|
@@ -1251,7 +1482,10 @@ export function syncCoordinatorFromTransportConfig() {
|
|
|
1251
1482
|
});
|
|
1252
1483
|
}
|
|
1253
1484
|
export function getTransportSnapshotForTesting() {
|
|
1254
|
-
return
|
|
1485
|
+
return {
|
|
1486
|
+
...getTransportCoordinator().snapshot(),
|
|
1487
|
+
contextEntries: contextEntryCount(),
|
|
1488
|
+
};
|
|
1255
1489
|
}
|
|
1256
1490
|
export function resetPoolForTesting() {
|
|
1257
1491
|
for (const conn of [...connectionPool]) {
|
|
@@ -1267,8 +1501,11 @@ export function resetPoolForTesting() {
|
|
|
1267
1501
|
}
|
|
1268
1502
|
turnStateByContext.clear();
|
|
1269
1503
|
lastResponseIDByContext.clear();
|
|
1504
|
+
contextEpochByKey.clear();
|
|
1505
|
+
contextOwnerByKey.clear();
|
|
1270
1506
|
scopeCooldowns.clear();
|
|
1271
1507
|
authEndpoints.clear();
|
|
1508
|
+
contextEpochClock = 1;
|
|
1272
1509
|
nextStreamID = 1;
|
|
1273
1510
|
resetTransportCoordinatorForTesting();
|
|
1274
1511
|
syncCoordinatorFromTransportConfig();
|