mixdog 0.9.33 → 0.9.34
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/package.json +4 -2
- package/scripts/compact-trigger-migration-smoke.mjs +37 -31
- package/scripts/provider-toolcall-test.mjs +535 -36
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +2 -3
- package/src/repl.mjs +9 -1
- package/src/rules/shared/01-tool.md +4 -2
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +21 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -12
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +131 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +102 -26
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +194 -9
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/compact.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +39 -6
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +15 -23
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +16 -8
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +50 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +53 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +270 -47
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +7 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +5 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +18 -3
- package/src/runtime/memory/lib/memory-embed.mjs +89 -8
- package/src/session-runtime/context-status.mjs +7 -6
- package/src/session-runtime/runtime-core.mjs +0 -10
- package/src/tui/app/use-transcript-window.mjs +13 -1
- package/src/tui/components/StatusLine.jsx +5 -5
- package/src/tui/dist/index.mjs +5 -3
- package/src/ui/statusline.mjs +4 -10
- package/src/vendor/statusline/bin/statusline-route.mjs +4 -5
- package/src/vendor/statusline/src/gateway/route-meta.mjs +3 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* openai-transport-policy.mjs — single source of truth for the shared
|
|
3
|
+
* Responses-API transport policy switch (OpenAI OAuth/direct + xAI/compat).
|
|
4
|
+
*
|
|
5
|
+
* One env knob, MIXDOG_OAI_TRANSPORT, selects among the transport modes:
|
|
6
|
+
* - 'ws-delta' (DEFAULT): WS transport, refs-compatible delta ON.
|
|
7
|
+
* - 'ws-full' WS transport, delta OFF (always full frames).
|
|
8
|
+
* - 'http-sse' force the HTTP/SSE transport directly (delta is WS-only, so
|
|
9
|
+
* it stays off).
|
|
10
|
+
* - 'auto' compatibility spelling for the default ws-delta route.
|
|
11
|
+
*
|
|
12
|
+
* No mode performs an implicit HTTP fallback: explicit modes pin their
|
|
13
|
+
* transport. Delta is selected solely via MIXDOG_OAI_TRANSPORT=ws-delta
|
|
14
|
+
* (or by leaving the env unset).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Normalize the transport mode token. Underscores/spaces and a few common
|
|
18
|
+
// spellings collapse to the canonical modes. Unknown/empty → null so the
|
|
19
|
+
// caller falls back to the default 'ws-delta'.
|
|
20
|
+
export function _normalizeTransportMode(raw) {
|
|
21
|
+
const v = String(raw || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
22
|
+
switch (v) {
|
|
23
|
+
case 'ws-full': case 'wsfull': case 'full': case 'ws': case 'websocket-full':
|
|
24
|
+
return 'ws-full';
|
|
25
|
+
case 'ws-delta': case 'wsdelta': case 'delta': case 'websocket-delta':
|
|
26
|
+
return 'ws-delta';
|
|
27
|
+
case 'http-sse': case 'httpsse': case 'http': case 'sse': case 'http/sse':
|
|
28
|
+
return 'http-sse';
|
|
29
|
+
case 'auto':
|
|
30
|
+
return 'ws-delta';
|
|
31
|
+
default:
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DELTA_OFF = Object.freeze({ force: false, refs: false, optIn: false });
|
|
37
|
+
const DELTA_REFS = Object.freeze({ force: false, refs: true, optIn: true });
|
|
38
|
+
|
|
39
|
+
// Per-provider transport capabilities for the shared Responses policy. Every
|
|
40
|
+
// Responses backend can speak WS and HTTP/SSE, and the WS *delta* continuation
|
|
41
|
+
// (previous_response_id + incremental input in openai-ws-delta.mjs) is enabled
|
|
42
|
+
// for BOTH OpenAI and xAI/Grok — but they use it for different reasons and
|
|
43
|
+
// with different risk profiles:
|
|
44
|
+
// - OpenAI/codex: delta is the prefix-stripping optimization that is only
|
|
45
|
+
// fully cache-safe with the x-codex-turn-state sticky token; the refs-mode
|
|
46
|
+
// structural guards (openai-ws-delta.mjs) exist to bound that risk.
|
|
47
|
+
// - xAI/Grok: the official xAI Responses WebSocket guide *documents*
|
|
48
|
+
// previous_response_id + incremental input as the standard continuation
|
|
49
|
+
// shape (no Codex turn-state, no prefix-strip caveat). Enabling delta here
|
|
50
|
+
// mirrors that guidance rather than a codex-only hack, so 'ws-delta' drives
|
|
51
|
+
// the official continuation instead of collapsing to 'ws-full'.
|
|
52
|
+
// Unknown providers get the permissive full-capability default.
|
|
53
|
+
export const FULL_RESPONSES_TRANSPORT_CAPS = Object.freeze({ ws: true, http: true, delta: true });
|
|
54
|
+
export const RESPONSES_TRANSPORT_CAPABILITIES = Object.freeze({
|
|
55
|
+
'openai-oauth': Object.freeze({ ws: true, http: true, delta: true }),
|
|
56
|
+
'openai': Object.freeze({ ws: true, http: true, delta: true }), // direct
|
|
57
|
+
'xai': Object.freeze({ ws: true, http: true, delta: true }), // official WS continuation
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Down-shift a requested mode to the nearest mode the provider actually
|
|
61
|
+
// supports. Pure/idempotent: full-capability providers pass every mode through
|
|
62
|
+
// unchanged, so the OpenAI OAuth/direct resolution stays byte-identical.
|
|
63
|
+
export function _gateTransportMode(mode, caps) {
|
|
64
|
+
let m = mode;
|
|
65
|
+
// Delta unsupported → keep WS transport but force full frames.
|
|
66
|
+
if (m === 'ws-delta' && !caps.delta) m = 'ws-full';
|
|
67
|
+
// WS unsupported → prefer HTTP, else defer to auto.
|
|
68
|
+
if ((m === 'ws-full' || m === 'ws-delta') && !caps.ws) m = caps.http ? 'http-sse' : 'ws-delta';
|
|
69
|
+
// HTTP unsupported → prefer full-frame WS, else defer to auto.
|
|
70
|
+
if (m === 'http-sse' && !caps.http) m = caps.ws ? 'ws-full' : 'auto';
|
|
71
|
+
return m;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the shared Responses transport policy from the environment, gated by
|
|
76
|
+
* per-provider capabilities.
|
|
77
|
+
* @param {Record<string,string|undefined>} [env=process.env]
|
|
78
|
+
* @param {{ws?:boolean,http?:boolean,delta?:boolean}} [capabilities=FULL_RESPONSES_TRANSPORT_CAPS]
|
|
79
|
+
* @returns {{ mode: 'ws-full'|'ws-delta'|'http-sse',
|
|
80
|
+
* requestedMode: 'ws-full'|'ws-delta'|'http-sse',
|
|
81
|
+
* transport: 'auto'|'ws'|'http',
|
|
82
|
+
* allowHttpFallback: boolean,
|
|
83
|
+
* delta: { force: boolean, refs: boolean, optIn: boolean },
|
|
84
|
+
* capabilities: {ws:boolean,http:boolean,delta:boolean} }}
|
|
85
|
+
*/
|
|
86
|
+
export function resolveResponsesTransportPolicy(env = process.env, capabilities = FULL_RESPONSES_TRANSPORT_CAPS) {
|
|
87
|
+
const caps = { ...FULL_RESPONSES_TRANSPORT_CAPS, ...(capabilities || {}) };
|
|
88
|
+
const requestedMode = _normalizeTransportMode(env?.MIXDOG_OAI_TRANSPORT) || 'ws-delta';
|
|
89
|
+
const mode = _gateTransportMode(requestedMode, caps);
|
|
90
|
+
let transport;
|
|
91
|
+
let delta;
|
|
92
|
+
switch (mode) {
|
|
93
|
+
case 'http-sse':
|
|
94
|
+
transport = 'http';
|
|
95
|
+
delta = DELTA_OFF; // delta is a WS-only optimization
|
|
96
|
+
break;
|
|
97
|
+
case 'ws-full':
|
|
98
|
+
transport = 'ws';
|
|
99
|
+
delta = DELTA_OFF; // explicit full frames
|
|
100
|
+
break;
|
|
101
|
+
case 'ws-delta':
|
|
102
|
+
transport = 'ws';
|
|
103
|
+
// Reachable only when caps.delta is true (else gated to ws-full).
|
|
104
|
+
delta = caps.delta ? DELTA_REFS : DELTA_OFF;
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
transport = 'ws';
|
|
108
|
+
delta = caps.delta ? DELTA_REFS : DELTA_OFF;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
mode,
|
|
113
|
+
requestedMode,
|
|
114
|
+
transport,
|
|
115
|
+
// No mode performs an implicit HTTP fallback; explicit http-sse pins the
|
|
116
|
+
// HTTP transport instead. Kept as a field so existing callers gate off.
|
|
117
|
+
allowHttpFallback: false,
|
|
118
|
+
delta,
|
|
119
|
+
capabilities: caps,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Backward-compatible OpenAI OAuth/direct resolver — a thin full-capability
|
|
125
|
+
* wrapper over the shared policy so existing callers (openai-oauth/openai-ws/
|
|
126
|
+
* openai-ws-delta) and their tests keep byte-identical behavior.
|
|
127
|
+
* @param {Record<string,string|undefined>} [env=process.env]
|
|
128
|
+
*/
|
|
129
|
+
export function resolveOpenAiTransportPolicy(env = process.env) {
|
|
130
|
+
return resolveResponsesTransportPolicy(env, FULL_RESPONSES_TRANSPORT_CAPS);
|
|
131
|
+
}
|
|
@@ -10,11 +10,25 @@
|
|
|
10
10
|
* (openai-oauth-ws.mjs et al) resolve unchanged.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import {
|
|
14
|
+
resolveResponsesTransportPolicy,
|
|
15
|
+
RESPONSES_TRANSPORT_CAPABILITIES,
|
|
16
|
+
FULL_RESPONSES_TRANSPORT_CAPS,
|
|
17
|
+
} from './openai-transport-policy.mjs';
|
|
18
|
+
|
|
13
19
|
// If the cached request (sans input) matches the current one and the current
|
|
14
20
|
// input starts with the cached input, return only the tail. Otherwise return
|
|
15
21
|
// the full input (fresh turn).
|
|
16
22
|
export function _sansInput(body) {
|
|
17
|
-
const { input: _ignored, previous_response_id: _prevIgnored, ...rest } = body;
|
|
23
|
+
const { input: _ignored, previous_response_id: _prevIgnored, generate, ...rest } = body;
|
|
24
|
+
// Warmup/prewarm frames carry generate:false on the wire (codex prewarm
|
|
25
|
+
// marker, openai-oauth.mjs warmupBody). That flag must NOT count as a
|
|
26
|
+
// request-property change on the warmup->first-real comparison, or the
|
|
27
|
+
// first real turn always retreats to a full frame. Normalize away ONLY the
|
|
28
|
+
// warmup-only generate:false; any other generate value stays in the
|
|
29
|
+
// comparison snapshot so genuine differences still break the delta. The
|
|
30
|
+
// wire body is untouched — frames are built from the raw body, not this.
|
|
31
|
+
if (generate !== false && generate !== undefined) rest.generate = generate;
|
|
18
32
|
return rest;
|
|
19
33
|
}
|
|
20
34
|
|
|
@@ -170,59 +184,121 @@ export function _stripResponseItemsFromHead(items, responseItems) {
|
|
|
170
184
|
return { ok: true, reason: null, tail: tail.slice(cursor), stripped, skipped };
|
|
171
185
|
}
|
|
172
186
|
|
|
173
|
-
|
|
187
|
+
// Official OpenAI Responses WebSocket guide: response.create WS frames mirror
|
|
188
|
+
// the Responses body EXCEPT the transport-only fields `stream`/`background`,
|
|
189
|
+
// which the socket carries implicitly and the guide says to omit. This set is
|
|
190
|
+
// stripped from a frame (any build shape) only when omitTransportFields is on.
|
|
191
|
+
const TRANSPORT_ONLY_FRAME_FIELDS = new Set(['stream', 'background']);
|
|
192
|
+
|
|
193
|
+
// Canonical response.create frame builder. Every WS send (warmup, main
|
|
194
|
+
// full-frame, and delta) routes through this so the serialized key order is
|
|
195
|
+
// identical byte-for-byte: `type` always leads, then the body's codex
|
|
196
|
+
// struct-order keys follow verbatim. A delta send passes previousResponseId
|
|
197
|
+
// (inserted immediately before `input`, matching codex's refs position) and
|
|
198
|
+
// inputOverride (the stripped tail); an empty instructions string is dropped
|
|
199
|
+
// in that case because the server resolves it from previous_response_id.
|
|
200
|
+
// Full/warmup frames pass the body unchanged and keep every key in place.
|
|
201
|
+
// omitTransportFields is used by wire-parity/prewarm helpers to drop stream/background.
|
|
202
|
+
export function _buildResponseCreateFrame(body, { previousResponseId = null, inputOverride, omitTransportFields = false } = {}) {
|
|
203
|
+
const src = body && typeof body === 'object' ? body : {};
|
|
204
|
+
if (previousResponseId == null && inputOverride === undefined) {
|
|
205
|
+
if (!omitTransportFields) return { type: 'response.create', ...src };
|
|
206
|
+
const frame = { type: 'response.create' };
|
|
207
|
+
for (const key of Object.keys(src)) {
|
|
208
|
+
if (TRANSPORT_ONLY_FRAME_FIELDS.has(key)) continue;
|
|
209
|
+
frame[key] = src[key];
|
|
210
|
+
}
|
|
211
|
+
return frame;
|
|
212
|
+
}
|
|
213
|
+
const frame = { type: 'response.create' };
|
|
214
|
+
for (const key of Object.keys(src)) {
|
|
215
|
+
if (omitTransportFields && TRANSPORT_ONLY_FRAME_FIELDS.has(key)) continue;
|
|
216
|
+
if (key === 'instructions') {
|
|
217
|
+
const instr = src.instructions;
|
|
218
|
+
if (typeof instr === 'string' && instr.length) frame.instructions = instr;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (key === 'input') {
|
|
222
|
+
if (previousResponseId != null) frame.previous_response_id = previousResponseId;
|
|
223
|
+
frame.input = inputOverride === undefined ? src.input : inputOverride;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
frame[key] = src[key];
|
|
227
|
+
}
|
|
228
|
+
if (!('input' in frame) && inputOverride !== undefined) {
|
|
229
|
+
if (previousResponseId != null) frame.previous_response_id = previousResponseId;
|
|
230
|
+
frame.input = inputOverride;
|
|
231
|
+
}
|
|
232
|
+
return frame;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function _computeDelta({ entry, body, traceProvider }) {
|
|
174
236
|
// DEFAULT: full-frame sends. codex's delta path is only cache-safe with
|
|
175
237
|
// the x-codex-turn-state sticky-routing token, which the backend issues to
|
|
176
238
|
// codex but never to us (R11-R14 2026-07-03: zero turn-state events across
|
|
177
239
|
// 200+ calls despite UA/version/beta-features/client_metadata parity;
|
|
178
240
|
// delta measured 18-28% warm miss vs full-frame 0.0%). Without the sticky
|
|
179
241
|
// token, previous_response_id requests hop cache nodes and only the first
|
|
180
|
-
// prefix blocks hit. Re-enable delta explicitly via
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
242
|
+
// prefix blocks hit. Re-enable delta explicitly via
|
|
243
|
+
// Delta gating flows through the single transport-policy switch
|
|
244
|
+
// (MIXDOG_OAI_TRANSPORT: ws-full | ws-delta | http-sse). Default ws-delta
|
|
245
|
+
// selects the refs-compatible safe delta; 'ws-full'/'http-sse' force full
|
|
246
|
+
// frames.
|
|
247
|
+
// refs-compatible mode actively uses previous_response_id without demanding
|
|
248
|
+
// the x-codex-turn-state token, but KEEPS every structural safety check
|
|
249
|
+
// (anchor present, request-props unchanged, input-prefix match, response
|
|
250
|
+
// items strip clean). Any of those failing still retreats to a full frame.
|
|
251
|
+
// Resolve the transport policy under the caller's provider capabilities so
|
|
252
|
+
// the delta gate honors per-provider limits instead of always assuming full
|
|
253
|
+
// OpenAI caps. xAI's WS path now carries delta capability (caps.delta=true),
|
|
254
|
+
// so 'ws-delta' builds the OFFICIAL xAI continuation frame
|
|
255
|
+
// (previous_response_id + incremental input tail) documented by the xAI
|
|
256
|
+
// Responses WebSocket guide — NOT the codex prefix-strip hack: no
|
|
257
|
+
// x-codex-turn-state token is required or fabricated for xAI, and the
|
|
258
|
+
// structural refs guards below still bound any prefix mismatch. openai-oauth/
|
|
259
|
+
// direct keep full capabilities, so their resolution stays byte-identical.
|
|
260
|
+
const caps = traceProvider === 'xai'
|
|
261
|
+
? RESPONSES_TRANSPORT_CAPABILITIES.xai
|
|
262
|
+
: FULL_RESPONSES_TRANSPORT_CAPS;
|
|
263
|
+
const { delta } = resolveResponsesTransportPolicy(process.env, caps);
|
|
264
|
+
const deltaForce = delta.force;
|
|
265
|
+
const deltaRefs = delta.refs;
|
|
266
|
+
const deltaOptIn = delta.optIn;
|
|
267
|
+
const buildFrame = (b, opts) => _buildResponseCreateFrame(b, opts || {});
|
|
185
268
|
if (!deltaOptIn) {
|
|
186
|
-
return { mode: 'full', reason: 'full_default', frame:
|
|
269
|
+
return { mode: 'full', reason: 'full_default', frame: buildFrame(body) };
|
|
187
270
|
}
|
|
188
271
|
if (!entry || !entry.lastRequestSansInput || !entry.lastResponseId) {
|
|
189
|
-
return { mode: 'full', reason: 'no_anchor', frame:
|
|
272
|
+
return { mode: 'full', reason: 'no_anchor', frame: buildFrame(body) };
|
|
190
273
|
}
|
|
191
|
-
if (!deltaForce && !entry.turnState) {
|
|
192
|
-
return { mode: 'full', reason: 'delta_missing_turn_state', frame:
|
|
274
|
+
if (!deltaForce && !deltaRefs && !entry.turnState) {
|
|
275
|
+
return { mode: 'full', reason: 'delta_missing_turn_state', frame: buildFrame(body) };
|
|
193
276
|
}
|
|
194
277
|
if (!Array.isArray(entry.lastRequestInput)) {
|
|
195
|
-
return { mode: 'full', reason: 'no_input_snapshot', frame:
|
|
278
|
+
return { mode: 'full', reason: 'no_input_snapshot', frame: buildFrame(body) };
|
|
196
279
|
}
|
|
197
280
|
const curSans = _stableStringify(_sansInput(body));
|
|
198
281
|
if (curSans !== entry.lastRequestSansInput) {
|
|
199
|
-
return { mode: 'full', reason: 'request_properties_changed', frame:
|
|
282
|
+
return { mode: 'full', reason: 'request_properties_changed', frame: buildFrame(body) };
|
|
200
283
|
}
|
|
201
284
|
const curInput = Array.isArray(body.input) ? body.input : [];
|
|
202
285
|
const afterPreviousInput = _stripRequestPrefix(curInput, entry.lastRequestInput);
|
|
203
286
|
if (!afterPreviousInput) {
|
|
204
|
-
return { mode: 'full', reason: 'input_prefix_mismatch', frame:
|
|
287
|
+
return { mode: 'full', reason: 'input_prefix_mismatch', frame: buildFrame(body) };
|
|
205
288
|
}
|
|
206
289
|
const stripped = _stripResponseItemsFromHead(afterPreviousInput, entry.lastResponseItems);
|
|
207
290
|
if (!stripped.ok) {
|
|
208
|
-
return { mode: 'full', reason: stripped.reason, frame:
|
|
291
|
+
return { mode: 'full', reason: stripped.reason, frame: buildFrame(body) };
|
|
209
292
|
}
|
|
210
293
|
return {
|
|
211
294
|
mode: 'delta',
|
|
212
295
|
reason: null,
|
|
213
296
|
strippedResponseItems: stripped.stripped,
|
|
214
297
|
skippedResponseItems: stripped.skipped,
|
|
215
|
-
frame: (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
model,
|
|
220
|
-
...(typeof instructions === 'string' && instructions.length ? { instructions } : {}),
|
|
221
|
-
previous_response_id: entry.lastResponseId,
|
|
222
|
-
input: stripped.tail,
|
|
223
|
-
...rest,
|
|
224
|
-
};
|
|
225
|
-
})(),
|
|
298
|
+
frame: buildFrame(body, {
|
|
299
|
+
previousResponseId: entry.lastResponseId,
|
|
300
|
+
inputOverride: stripped.tail,
|
|
301
|
+
}),
|
|
226
302
|
};
|
|
227
303
|
}
|
|
228
304
|
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import WebSocket from 'ws';
|
|
10
10
|
import { errText } from '../../../shared/err-text.mjs';
|
|
11
11
|
import { createHash, randomBytes } from 'crypto';
|
|
12
|
-
import { appendFileSync } from 'node:fs';
|
|
13
|
-
import {
|
|
12
|
+
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { codexOriginator, codexUserAgent, codexVersionHeader } from './codex-client-meta.mjs';
|
|
14
15
|
import {
|
|
15
16
|
PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
|
|
16
17
|
PROVIDER_WS_HANDSHAKE_TIMEOUT_MS,
|
|
@@ -26,7 +27,6 @@ export function _wsErrLabel(p) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const CODEX_WS_URL = 'wss://chatgpt.com/backend-api/codex/responses';
|
|
29
|
-
const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
|
|
30
30
|
const OPENAI_WS_URL = 'wss://api.openai.com/v1/responses';
|
|
31
31
|
const XAI_WS_URL = 'wss://api.x.ai/v1/responses';
|
|
32
32
|
export const WS_IDLE_MS = resolveTimeoutMs(
|
|
@@ -73,6 +73,158 @@ function _envOn(name) {
|
|
|
73
73
|
return ['1', 'true', 'yes', 'on'].includes(v);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// --- Opt-in Codex fingerprint/id parity knobs ------------------------------
|
|
77
|
+
// All default OFF; unset envs leave the winning combo
|
|
78
|
+
// (MIXDOG_OAI_CODEX_WIRE_PARITY=1 + ws-delta + underscore session_id) exactly
|
|
79
|
+
// as-is. These add EXTRA parity dimensions for backend fingerprint probes.
|
|
80
|
+
|
|
81
|
+
// codex sends dashed RFC-4122 UUIDs as session-id/thread-id (client.rs:1033-
|
|
82
|
+
// 1057); we key those dashed handshake headers off the underscore cacheKey by
|
|
83
|
+
// default. Opt in with MIXDOG_OAI_CODEX_WIRE_PARITY_UUID_IDS to reshape ONLY
|
|
84
|
+
// the dashed pair (session-id/thread-id/x-client-request-id) into codex's UUID
|
|
85
|
+
// format. The value is derived deterministically from the id so it stays
|
|
86
|
+
// stable per cache key (prefix-cache continuity preserved), and the underscore
|
|
87
|
+
// `session_id` header (the backend prefix-dedupe key) is left untouched.
|
|
88
|
+
function _codexUuidIdParity() {
|
|
89
|
+
const v = String(process.env.MIXDOG_OAI_CODEX_WIRE_PARITY_UUID_IDS || '').trim().toLowerCase();
|
|
90
|
+
return ['1', 'true', 'yes', 'on', 'uuid', 'dashed'].includes(v);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _codexDashedId(value) {
|
|
94
|
+
const h = createHash('sha256').update(String(value)).digest();
|
|
95
|
+
const b = Buffer.from(h.subarray(0, 16));
|
|
96
|
+
b[6] = (b[6] & 0x0f) | 0x50; // version 5 nibble
|
|
97
|
+
b[8] = (b[8] & 0x3f) | 0x80; // RFC-4122 variant
|
|
98
|
+
const hex = b.toString('hex');
|
|
99
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Opt-in turn-state gate experiment bundle (MIXDOG_OAI_CODEX_TURN_STATE_GATE).
|
|
103
|
+
// The debugger's working hypothesis for what gates x-codex-turn-state issuance
|
|
104
|
+
// is a specific wire shape: Codex-shaped UUIDv7 session/thread ids + dashed-only
|
|
105
|
+
// handshake headers (drop the underscore session_id) + NO duplicated
|
|
106
|
+
// x-client-request-id + a parent-thread header (added in openai-oauth-ws.mjs).
|
|
107
|
+
// This env ties those dimensions together as ONE experiment so operators can
|
|
108
|
+
// A/B the whole gate shape without hand-composing four flags. Default OFF; it
|
|
109
|
+
// leaves MIXDOG_OAI_CODEX_WIRE_PARITY_UUID_IDS / _SESSION_ID and every existing
|
|
110
|
+
// flag intact and only reshapes the wire when explicitly enabled.
|
|
111
|
+
function _codexTurnStateGate() {
|
|
112
|
+
const v = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_GATE || '').trim().toLowerCase();
|
|
113
|
+
return ['1', 'true', 'yes', 'on'].includes(v);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Same derivation as _codexDashedId but stamps the version-7 nibble so the
|
|
117
|
+
// dashed pair reads as a Codex-shaped UUIDv7. Deterministic per id (prefix
|
|
118
|
+
// cache continuity preserved); only used under the turn-state gate bundle.
|
|
119
|
+
function _codexDashedIdV7(value) {
|
|
120
|
+
const h = createHash('sha256').update(String(value)).digest();
|
|
121
|
+
const b = Buffer.from(h.subarray(0, 16));
|
|
122
|
+
b[6] = (b[6] & 0x0f) | 0x70; // version 7 nibble
|
|
123
|
+
b[8] = (b[8] & 0x3f) | 0x80; // RFC-4122 variant
|
|
124
|
+
const hex = b.toString('hex');
|
|
125
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// codex advertises enabled beta features on the WS handshake; the backend
|
|
129
|
+
// plausibly gates x-codex-turn-state issuance on that list (see the
|
|
130
|
+
// x-codex-beta-features comment below). MIXDOG_CODEX_BETA_FEATURES replaces the
|
|
131
|
+
// whole list; MIXDOG_OAI_CODEX_TURN_STATE_FEATURES is a safe ADD-ONLY opt-in
|
|
132
|
+
// that appends turn-state-gating feature token(s) (comma-separated, de-duped)
|
|
133
|
+
// without dropping the default `remote_compaction_v2`. Unset = default list.
|
|
134
|
+
function _codexBetaFeatures() {
|
|
135
|
+
const base = process.env.MIXDOG_CODEX_BETA_FEATURES || 'remote_compaction_v2';
|
|
136
|
+
const extra = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_FEATURES || '').trim();
|
|
137
|
+
if (!extra) return base;
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const tok of `${base},${extra}`.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
141
|
+
if (seen.has(tok)) continue;
|
|
142
|
+
seen.add(tok);
|
|
143
|
+
out.push(tok);
|
|
144
|
+
}
|
|
145
|
+
return out.join(',');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Opt-in raw WS capture for byte-diff against codex-rs -------------------
|
|
149
|
+
// Enabled ONLY when MIXDOG_OAI_WS_DUMP_DIR names a directory. Persists the
|
|
150
|
+
// (redacted) handshake header metadata and the exact serialized
|
|
151
|
+
// response.create frame bytes so our wire format can be byte-diffed against
|
|
152
|
+
// codex. Secrets (Authorization / Cookie / account-id / routing tokens) are
|
|
153
|
+
// hashed, never written in clear. When the env is unset both helpers are
|
|
154
|
+
// no-ops, so there is no default behavior change.
|
|
155
|
+
const _WS_DUMP_SECRET_RE = /^(authorization|proxy-authorization|cookie|set-cookie|chatgpt-account-id|x-codex-turn-state|session_id|session-id|thread-id|x-codex-parent-thread-id|x-client-request-id|x-session-affinity)$/i;
|
|
156
|
+
|
|
157
|
+
function _wsDumpDir() {
|
|
158
|
+
const dir = String(process.env.MIXDOG_OAI_WS_DUMP_DIR || '').trim();
|
|
159
|
+
return dir || null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _redactHeaderValue(key, value) {
|
|
163
|
+
const v = String(value ?? '');
|
|
164
|
+
if (_WS_DUMP_SECRET_RE.test(String(key))) {
|
|
165
|
+
if (!v) return '';
|
|
166
|
+
return `<redacted:sha256:${createHash('sha256').update(v).digest('hex').slice(0, 12)}:len${v.length}>`;
|
|
167
|
+
}
|
|
168
|
+
return v;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _redactDumpUrl(url) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = new URL(String(url));
|
|
174
|
+
for (const [key, value] of parsed.searchParams.entries()) {
|
|
175
|
+
if (_WS_DUMP_SECRET_RE.test(String(key))) {
|
|
176
|
+
parsed.searchParams.set(key, _redactHeaderValue(key, value));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return parsed.toString();
|
|
180
|
+
} catch {
|
|
181
|
+
return String(url || '');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _dumpHandshakeHeaders(url, headers) {
|
|
186
|
+
const dir = _wsDumpDir();
|
|
187
|
+
if (!dir) return;
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(dir, { recursive: true });
|
|
190
|
+
const redacted = {};
|
|
191
|
+
for (const [k, v] of Object.entries(headers || {})) redacted[k] = _redactHeaderValue(k, v);
|
|
192
|
+
const rec = {
|
|
193
|
+
ts: new Date().toISOString(),
|
|
194
|
+
kind: 'ws_handshake',
|
|
195
|
+
url: _redactDumpUrl(url),
|
|
196
|
+
// Header order matters for the codex byte-diff; keep it explicit.
|
|
197
|
+
headerOrder: Object.keys(headers || {}),
|
|
198
|
+
headers: redacted,
|
|
199
|
+
};
|
|
200
|
+
const stamp = `${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
201
|
+
writeFileSync(join(dir, `handshake-${stamp}.json`), JSON.stringify(rec, null, 2));
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _dumpFrame(payload) {
|
|
206
|
+
const dir = _wsDumpDir();
|
|
207
|
+
if (!dir) return;
|
|
208
|
+
try {
|
|
209
|
+
mkdirSync(dir, { recursive: true });
|
|
210
|
+
const stamp = `${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
211
|
+
// Persist the serialized response.create bytes for codex byte-diff,
|
|
212
|
+
// but never dump x-codex-turn-state in clear. That token is a
|
|
213
|
+
// per-turn credential-like routing secret; header dumps already hash
|
|
214
|
+
// it, so frame dumps must do the same when client_metadata carries it.
|
|
215
|
+
let out = payload;
|
|
216
|
+
try {
|
|
217
|
+
const frame = JSON.parse(String(payload));
|
|
218
|
+
const meta = frame?.client_metadata;
|
|
219
|
+
if (meta && typeof meta === 'object' && typeof meta['x-codex-turn-state'] === 'string') {
|
|
220
|
+
meta['x-codex-turn-state'] = _redactHeaderValue('x-codex-turn-state', meta['x-codex-turn-state']);
|
|
221
|
+
out = JSON.stringify(frame);
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
writeFileSync(join(dir, `frame-${stamp}.json`), out);
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
|
|
76
228
|
function _cfCookieAccountKey(auth) {
|
|
77
229
|
return String(auth?.account_id || auth?.apiKey || 'default');
|
|
78
230
|
}
|
|
@@ -166,6 +318,7 @@ export function _sendFrame(entry, frame) {
|
|
|
166
318
|
reject(err);
|
|
167
319
|
return;
|
|
168
320
|
}
|
|
321
|
+
_dumpFrame(payload);
|
|
169
322
|
try {
|
|
170
323
|
// Do NOT await the send callback: on a wedged-but-OPEN socket the
|
|
171
324
|
// ws write callback may never fire, which would hang this Promise
|
|
@@ -199,7 +352,7 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
|
|
|
199
352
|
: {
|
|
200
353
|
'Authorization': `Bearer ${auth.access_token}`,
|
|
201
354
|
'chatgpt-account-id': auth.account_id || '',
|
|
202
|
-
'originator':
|
|
355
|
+
'originator': codexOriginator(),
|
|
203
356
|
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
|
204
357
|
// codex-rs merges provider http_headers ("version") plus
|
|
205
358
|
// default_headers (User-Agent) into the WS handshake
|
|
@@ -215,7 +368,7 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
|
|
|
215
368
|
// exactly "remote_compaction_v2" (features/src/lib.rs: the only
|
|
216
369
|
// always-advertised Stable default-on feature). Servers gate
|
|
217
370
|
// behavior (plausibly incl. x-codex-turn-state issuance) on it.
|
|
218
|
-
'x-codex-beta-features':
|
|
371
|
+
'x-codex-beta-features': _codexBetaFeatures(),
|
|
219
372
|
};
|
|
220
373
|
const isOpenAiOauth = auth.type !== 'xai' && auth.type !== 'openai-direct';
|
|
221
374
|
// codex-rs sends only the dashed session-id/thread-id pair
|
|
@@ -224,15 +377,35 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
|
|
|
224
377
|
// its in-memory prefix state by the underscore session_id handshake
|
|
225
378
|
// header, and the only 0.0%-miss full-frame rounds (R7/R8, R15 regressed
|
|
226
379
|
// to 13% after this header was dropped) all had it present. Send both.
|
|
227
|
-
|
|
380
|
+
// The underscore session_id is the backend prefix-dedupe key (2026-04-19
|
|
381
|
+
// probes; R15 regressed to 13% miss when it was dropped). Codex parity
|
|
382
|
+
// (client.rs:1033-1057) sends ONLY the dashed pair, but dropping this
|
|
383
|
+
// header is a KNOWN cache-unsafe change — so the general parity flag no
|
|
384
|
+
// longer silently drops it. Keep it unless an operator EXPLICITLY opts
|
|
385
|
+
// into the codex-exact dashed-only wire via
|
|
386
|
+
// MIXDOG_OAI_CODEX_WIRE_PARITY_SESSION_ID=dashed.
|
|
387
|
+
// The gate bundle implies the dashed-only wire (drop the underscore
|
|
388
|
+
// session_id) on top of the standalone opt-in.
|
|
389
|
+
const gate = _codexTurnStateGate();
|
|
390
|
+
const dropUnderscoreSessionId = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY_SESSION_ID === 'dashed' || gate;
|
|
391
|
+
if (sessionToken && !dropUnderscoreSessionId) {
|
|
228
392
|
headers['session_id'] = String(sessionToken);
|
|
229
393
|
}
|
|
230
394
|
if (isOpenAiOauth && (sessionToken || _cacheKey)) {
|
|
231
|
-
|
|
232
|
-
|
|
395
|
+
// Gate bundle forces Codex-shaped UUIDv7 ids; the standalone parity flag
|
|
396
|
+
// keeps its v5-derived shape.
|
|
397
|
+
const uuidIds = _codexUuidIdParity() || gate;
|
|
398
|
+
const shapeId = gate ? _codexDashedIdV7 : _codexDashedId;
|
|
399
|
+
// Underscore `session_id` above is left as-is (backend prefix-dedupe
|
|
400
|
+
// key); only the dashed codex pair is reshaped under the opt-in.
|
|
401
|
+
const threadId = uuidIds ? shapeId(String(_cacheKey || sessionToken)) : String(_cacheKey || sessionToken);
|
|
402
|
+
const sessionId = uuidIds ? shapeId(String(sessionToken || _cacheKey)) : String(sessionToken || _cacheKey);
|
|
233
403
|
headers['session-id'] = sessionId;
|
|
234
404
|
headers['thread-id'] = threadId;
|
|
235
|
-
|
|
405
|
+
// Default/parity wire duplicates the dashed thread-id here. The gate
|
|
406
|
+
// hypothesis is that a duplicated x-client-request-id suppresses
|
|
407
|
+
// turn-state issuance, so omit it entirely under the bundle.
|
|
408
|
+
if (!gate) headers['x-client-request-id'] = threadId;
|
|
236
409
|
if (codexHeaders && typeof codexHeaders === 'object') {
|
|
237
410
|
for (const [key, value] of Object.entries(codexHeaders)) {
|
|
238
411
|
if (typeof key === 'string' && typeof value === 'string' && value) {
|
|
@@ -240,6 +413,17 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
|
|
|
240
413
|
}
|
|
241
414
|
}
|
|
242
415
|
}
|
|
416
|
+
// Gate-only: the wire thread-id above is reshaped to a Codex UUIDv7
|
|
417
|
+
// (shapeId), but _codexWsCompatibilityHeaders derives
|
|
418
|
+
// x-codex-parent-thread-id from the RAW dashed thread_id, so the merged
|
|
419
|
+
// codexHeaders carry a parent-thread id that no longer matches the
|
|
420
|
+
// thread-id on the same handshake. Realign it to the reshaped thread-id
|
|
421
|
+
// (single source of truth: the pool owns the wire id shape) so the
|
|
422
|
+
// gate handshake is internally consistent. Non-gate / standalone
|
|
423
|
+
// parity paths keep codex's raw value untouched.
|
|
424
|
+
if (gate && headers['x-codex-parent-thread-id']) {
|
|
425
|
+
headers['x-codex-parent-thread-id'] = threadId;
|
|
426
|
+
}
|
|
243
427
|
} else {
|
|
244
428
|
// xAI/direct keep a per-request value so their server-side traces stay
|
|
245
429
|
// distinguishable across reconnects.
|
|
@@ -290,6 +474,7 @@ function _openSocket({ auth, sessionToken, turnState, externalSignal, cacheKey,
|
|
|
290
474
|
const url = baseUrl + (sessionToken && process.env.MIXDOG_OAI_WS_URL_SESSION === '1'
|
|
291
475
|
? `?session_id=${encodeURIComponent(String(sessionToken))}`
|
|
292
476
|
: '');
|
|
477
|
+
_dumpHandshakeHeaders(url, headers);
|
|
293
478
|
return new Promise((resolve, reject) => {
|
|
294
479
|
let settled = false;
|
|
295
480
|
let abortListener = null;
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
_stripResponseItemsFromHead,
|
|
39
39
|
_computeDelta,
|
|
40
40
|
_estimateFrameTokens,
|
|
41
|
+
_buildResponseCreateFrame,
|
|
41
42
|
} from './openai-ws-delta.mjs';
|
|
42
43
|
import {
|
|
43
44
|
_combineUsageWithWarmup,
|
|
@@ -59,6 +60,7 @@ export {
|
|
|
59
60
|
_stripResponseItemsFromHead,
|
|
60
61
|
_computeDelta,
|
|
61
62
|
_estimateFrameTokens,
|
|
63
|
+
_buildResponseCreateFrame,
|
|
62
64
|
_combineUsageWithWarmup,
|
|
63
65
|
};
|
|
64
66
|
|
|
@@ -126,13 +128,22 @@ function _hasHeaderKey(headers, name) {
|
|
|
126
128
|
return _headerKeys(headers).includes(wanted);
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
function _captureTurnStateFromEvent(entry, event) {
|
|
131
|
+
export function _captureTurnStateFromEvent(entry, event) {
|
|
130
132
|
if (!entry || entry.turnState || !event || typeof event !== 'object') return;
|
|
131
133
|
const turnState = _headerString(event.headers, X_CODEX_TURN_STATE_HEADER)
|
|
132
134
|
|| _headerString(event.response?.headers, X_CODEX_TURN_STATE_HEADER)
|
|
133
135
|
|| _headerString(event.response?.metadata?.headers, X_CODEX_TURN_STATE_HEADER)
|
|
134
136
|
|| _headerString(event.metadata?.headers, X_CODEX_TURN_STATE_HEADER);
|
|
135
|
-
if (turnState)
|
|
137
|
+
if (turnState) {
|
|
138
|
+
entry.turnState = turnState;
|
|
139
|
+
// Attribute the captured token to the turn currently on the wire so
|
|
140
|
+
// the per-turn drop guard (_withCodexWsClientMetadata) can retire it
|
|
141
|
+
// once turn_id advances; null falls back to first-use attribution. An
|
|
142
|
+
// empty turn_id (parity prewarm) is a valid owner — preserve '' rather
|
|
143
|
+
// than collapsing it to null, so a prewarm-captured token is retired on
|
|
144
|
+
// the next real turn instead of leaking onto it.
|
|
145
|
+
entry.turnStateTurnId = entry.currentTurnId != null ? entry.currentTurnId : null;
|
|
146
|
+
}
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
function _traceWsHeaderKeys(entry, event, midState, traceProvider, model) {
|
|
@@ -18,6 +18,7 @@ import { enrichModels } from './model-catalog.mjs';
|
|
|
18
18
|
import { sanitizeModelList } from './model-list-sanitize.mjs';
|
|
19
19
|
import { sendViaHttpSse, _envFlag } from './openai-oauth-http-sse.mjs';
|
|
20
20
|
import { shouldFallbackTransport } from './retry-classifier.mjs';
|
|
21
|
+
import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
|
|
21
22
|
import { loadConfig } from '../config.mjs';
|
|
22
23
|
import {
|
|
23
24
|
resolveProviderCacheKey,
|
|
@@ -146,19 +147,35 @@ export class OpenAIDirectProvider {
|
|
|
146
147
|
auth: a,
|
|
147
148
|
sendOpts: opts,
|
|
148
149
|
displayModel: (id) => id,
|
|
150
|
+
// Public direct WS must not inherit the openai-oauth trace provider:
|
|
151
|
+
// that key drives the Codex WS client-metadata path
|
|
152
|
+
// (useCodexWsClientMetadata = traceProvider === 'openai-oauth') and
|
|
153
|
+
// the OAuth/Codex handshake headers. Direct API-key auth pins its own
|
|
154
|
+
// provider so it stays on the public (non-Codex) envelope.
|
|
155
|
+
traceProvider: 'openai-direct',
|
|
149
156
|
});
|
|
150
157
|
// WS→HTTP/SSE fallback mirrors the openai-oauth wrapper: the shared
|
|
151
158
|
// HTTP transport now accepts auth.type==='openai-direct' (public
|
|
152
159
|
// Responses endpoint + Bearer <apiKey>), so the api-key provider gets
|
|
153
160
|
// the same envelope. Gate via shouldFallbackTransport (denies
|
|
154
161
|
// 401/403/404/429 + liveTextEmitted/emittedToolCall/unsafeToRetry).
|
|
155
|
-
const
|
|
162
|
+
const transportPolicy = resolveOpenAiTransportPolicy();
|
|
163
|
+
const httpFallbackEnabled = transportPolicy.allowHttpFallback
|
|
164
|
+
&& _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
|
|
156
165
|
const dispatchHttp = (a) => {
|
|
157
166
|
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) {
|
|
158
167
|
process.stderr.write('[openai-ws] WebSocket unhealthy; falling back to HTTP/SSE\n');
|
|
159
168
|
}
|
|
160
169
|
return sendViaHttpSse({ ...common, auth: a, opts, fetchFn: opts._fetchFn });
|
|
161
170
|
};
|
|
171
|
+
// Transport-policy switch (MIXDOG_OAI_TRANSPORT). 'http-sse' forces the
|
|
172
|
+
// HTTP/SSE transport directly — no WS attempt, so skip the fallback log
|
|
173
|
+
// that dispatchHttp emits (it is not a fallback here). All other modes
|
|
174
|
+
// ('auto'/'ws-full'/'ws-delta') keep the WS-first path below; ws-full vs
|
|
175
|
+
// ws-delta only affects the delta gate inside openai-ws-delta.mjs.
|
|
176
|
+
if (transportPolicy.transport === 'http') {
|
|
177
|
+
return await sendViaHttpSse({ ...common, auth, opts, fetchFn: opts._fetchFn });
|
|
178
|
+
}
|
|
162
179
|
try {
|
|
163
180
|
return await dispatchWs(auth);
|
|
164
181
|
} catch (err) {
|