mixdog 0.9.32 → 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.
Files changed (51) hide show
  1. package/package.json +4 -2
  2. package/scripts/compact-trigger-migration-smoke.mjs +37 -31
  3. package/scripts/provider-toolcall-test.mjs +535 -36
  4. package/src/agents/heavy-worker/AGENT.md +3 -4
  5. package/src/agents/worker/AGENT.md +2 -3
  6. package/src/repl.mjs +9 -1
  7. package/src/rules/shared/01-tool.md +4 -2
  8. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +22 -0
  9. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +21 -2
  10. package/src/runtime/agent/orchestrator/providers/gemini.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +15 -9
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -18
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -12
  15. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +131 -0
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +102 -26
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +194 -9
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
  19. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/session/compact.mjs +3 -0
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +39 -6
  23. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +15 -23
  24. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +28 -0
  25. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +5 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +25 -0
  27. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +16 -8
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +50 -4
  29. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +53 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -2
  31. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +270 -47
  32. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -2
  33. package/src/runtime/channels/lib/output-forwarder.mjs +7 -1
  34. package/src/runtime/channels/lib/owned-runtime.mjs +5 -2
  35. package/src/runtime/memory/lib/embedding-worker.mjs +18 -3
  36. package/src/runtime/memory/lib/memory-embed.mjs +89 -8
  37. package/src/session-runtime/context-status.mjs +7 -6
  38. package/src/session-runtime/mcp-glue.mjs +5 -5
  39. package/src/session-runtime/plugin-mcp.mjs +36 -1
  40. package/src/session-runtime/resource-api.mjs +14 -9
  41. package/src/session-runtime/runtime-core.mjs +0 -10
  42. package/src/tui/app/extension-pickers.mjs +1 -1
  43. package/src/tui/app/model-picker.mjs +48 -12
  44. package/src/tui/app/route-pickers.mjs +4 -0
  45. package/src/tui/app/slash-dispatch.mjs +6 -1
  46. package/src/tui/app/use-transcript-window.mjs +13 -1
  47. package/src/tui/components/StatusLine.jsx +5 -5
  48. package/src/tui/dist/index.mjs +46 -15
  49. package/src/ui/statusline.mjs +4 -10
  50. package/src/vendor/statusline/bin/statusline-route.mjs +4 -5
  51. 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
- export function _computeDelta({ entry, body }) {
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 MIXDOG_OAI_WS_DELTA=1
181
- // for future probes if the backend starts issuing turn-state.
182
- const deltaMode = String(process.env.MIXDOG_OAI_WS_DELTA || '').trim().toLowerCase();
183
- const deltaForce = ['force', 'unsafe', 'always'].includes(deltaMode);
184
- const deltaOptIn = deltaForce || ['1', 'true', 'yes', 'on'].includes(deltaMode);
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: { type: 'response.create', ...body } };
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: { type: 'response.create', ...body } };
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: { type: 'response.create', ...body } };
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: { type: 'response.create', ...body } };
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: { type: 'response.create', ...body } };
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: { type: 'response.create', ...body } };
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: { type: 'response.create', ...body } };
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
- const { model, instructions, input: _input, ...rest } = body;
217
- return {
218
- type: 'response.create',
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 { codexUserAgent, codexVersionHeader } from './codex-client-meta.mjs';
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': CODEX_OAUTH_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': process.env.MIXDOG_CODEX_BETA_FEATURES || 'remote_compaction_v2',
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
- if (sessionToken) {
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
- const threadId = String(_cacheKey || sessionToken);
232
- const sessionId = String(sessionToken || _cacheKey);
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
- headers['x-client-request-id'] = threadId;
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) entry.turnState = 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 httpFallbackEnabled = _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
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) {