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.
- 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/mcp-glue.mjs +5 -5
- package/src/session-runtime/plugin-mcp.mjs +36 -1
- package/src/session-runtime/resource-api.mjs +14 -9
- package/src/session-runtime/runtime-core.mjs +0 -10
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/model-picker.mjs +48 -12
- package/src/tui/app/route-pickers.mjs +4 -0
- package/src/tui/app/slash-dispatch.mjs +6 -1
- package/src/tui/app/use-transcript-window.mjs +13 -1
- package/src/tui/components/StatusLine.jsx +5 -5
- package/src/tui/dist/index.mjs +46 -15
- 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
package/src/repl.mjs
CHANGED
|
@@ -161,6 +161,11 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
161
161
|
let streamedText = '';
|
|
162
162
|
let printedAny = false;
|
|
163
163
|
let printedToolCard = false;
|
|
164
|
+
// After the blank line emitted above, the cursor sits at a fresh line
|
|
165
|
+
// start. Track this so tool cards don't insert extra blank-line "pops":
|
|
166
|
+
// a leading newline is only needed to break away from un-terminated
|
|
167
|
+
// streamed text, never between consecutive cards.
|
|
168
|
+
let atLineStart = true;
|
|
164
169
|
try {
|
|
165
170
|
const runtime = await ensureRuntime();
|
|
166
171
|
const { result } = await runtime.ask(
|
|
@@ -169,13 +174,16 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
|
|
|
169
174
|
onToolCall: async (_iter, calls) => {
|
|
170
175
|
for (const c of calls || []) {
|
|
171
176
|
printedToolCard = true;
|
|
172
|
-
|
|
177
|
+
const lead = atLineStart ? '' : '\n';
|
|
178
|
+
stdout.write(lead + (await renderToolCardLazy(c)) + '\n');
|
|
179
|
+
atLineStart = true;
|
|
173
180
|
}
|
|
174
181
|
},
|
|
175
182
|
onTextDelta: (chunk) => {
|
|
176
183
|
printedAny = true;
|
|
177
184
|
streamedText += chunk;
|
|
178
185
|
stdout.write(chunk);
|
|
186
|
+
atLineStart = chunk.endsWith('\n');
|
|
179
187
|
},
|
|
180
188
|
onUsageDelta: (delta) => applyUsageDelta(stats, delta),
|
|
181
189
|
},
|
|
@@ -39,5 +39,7 @@
|
|
|
39
39
|
`apply_patch` and the `shell` that checks it in separate turns — not for
|
|
40
40
|
ordering (same-turn calls run in emit order), but because you must SEE the
|
|
41
41
|
patch result before verifying: a same-turn shell is already emitted and
|
|
42
|
-
can't react to a failed or partial patch.
|
|
43
|
-
|
|
42
|
+
can't react to a failed or partial patch. Put all known edits in ONE patch
|
|
43
|
+
this turn, in the order they should apply; later mutations skip after an
|
|
44
|
+
earlier patch failure. Verify all edits in ONE shell call next turn. Anything
|
|
45
|
+
else stays parallel.
|
|
@@ -69,13 +69,35 @@ function _arch() {
|
|
|
69
69
|
return a;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Originator token codex sends on every request (`codex_cli_rs`).
|
|
74
|
+
*
|
|
75
|
+
* Opt-in parity override: an operator can pin the exact originator a known-good
|
|
76
|
+
* codex build reports via MIXDOG_CODEX_ORIGINATOR when the backend fingerprints
|
|
77
|
+
* on it. Unset (default) keeps `codex_cli_rs`, so wire behavior is unchanged.
|
|
78
|
+
*/
|
|
79
|
+
export function codexOriginator() {
|
|
80
|
+
const override = String(process.env.MIXDOG_CODEX_ORIGINATOR || '').trim();
|
|
81
|
+
return override || 'codex_cli_rs';
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
/** codex_cli_rs/<version> (<os> <ver>; <arch>) <terminal> */
|
|
73
85
|
export function codexUserAgent() {
|
|
86
|
+
// Opt-in parity override: pin an exact codex User-Agent string
|
|
87
|
+
// (MIXDOG_CODEX_USER_AGENT) when the auto-derived os/arch/terminal tuple
|
|
88
|
+
// drifts from the real codex build the backend expects. Unset = default.
|
|
89
|
+
const override = String(process.env.MIXDOG_CODEX_USER_AGENT || '').trim();
|
|
90
|
+
if (override) return override;
|
|
74
91
|
const terminal = String(process.env.TERM_PROGRAM || 'unknown').trim() || 'unknown';
|
|
75
92
|
return `codex_cli_rs/${codexClientVersionSync()} (${_osType()} ${os.release()}; ${_arch()}) ${terminal}`;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
/** Bare version header value — codex built-in provider http_headers "version". */
|
|
79
96
|
export function codexVersionHeader() {
|
|
97
|
+
// Opt-in parity override: pin an exact `version` header
|
|
98
|
+
// (MIXDOG_CODEX_VERSION) instead of the npm-derived value. Unset = default
|
|
99
|
+
// (live npm version, floor fallback), so behavior is unchanged.
|
|
100
|
+
const override = String(process.env.MIXDOG_CODEX_VERSION || '').trim();
|
|
101
|
+
if (override) return override;
|
|
80
102
|
return codexClientVersionSync();
|
|
81
103
|
}
|
|
@@ -177,8 +177,27 @@ export function _attachGeminiCacheState(opts, entry, currentIter) {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
export function _resolveGeminiCacheUsage({ usageMetadata, cachedContent, providerState }) {
|
|
180
|
-
const
|
|
181
|
-
|
|
180
|
+
const firstFinite = (...values) => {
|
|
181
|
+
for (const value of values) {
|
|
182
|
+
const n = Number(value);
|
|
183
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
184
|
+
}
|
|
185
|
+
return 0;
|
|
186
|
+
};
|
|
187
|
+
const inputTokens = firstFinite(
|
|
188
|
+
usageMetadata?.promptTokenCount,
|
|
189
|
+
usageMetadata?.prompt_token_count,
|
|
190
|
+
usageMetadata?.totalTokenCount,
|
|
191
|
+
usageMetadata?.total_token_count,
|
|
192
|
+
);
|
|
193
|
+
const reportedCachedTokens = firstFinite(
|
|
194
|
+
// generateContent UsageMetadata field.
|
|
195
|
+
usageMetadata?.cachedContentTokenCount,
|
|
196
|
+
usageMetadata?.cached_content_token_count,
|
|
197
|
+
// Newer SDK convenience alias documented for implicit cache hits.
|
|
198
|
+
usageMetadata?.totalCachedTokens,
|
|
199
|
+
usageMetadata?.total_cached_tokens,
|
|
200
|
+
);
|
|
182
201
|
const cachedFallbackTokens = cachedContent
|
|
183
202
|
? Number(providerState?.gemini?.cacheTokenSize || 0) || 0
|
|
184
203
|
: 0;
|
|
@@ -782,7 +782,8 @@ export class GeminiProvider {
|
|
|
782
782
|
const um = response.usageMetadata || null;
|
|
783
783
|
// Hoist cachedTokens so the returned usage block can reuse the
|
|
784
784
|
// exact value the trace already recorded (including the
|
|
785
|
-
// cachedFallback when cachedContentTokenCount
|
|
785
|
+
// cachedFallback when cachedContentTokenCount / total_cached_tokens
|
|
786
|
+
// under-reports).
|
|
786
787
|
let cachedTokens = 0;
|
|
787
788
|
if (um) {
|
|
788
789
|
const {
|
|
@@ -88,17 +88,24 @@ function proxyHeaders() {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function resolveGrokOAuthResponsesTransport(config
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
91
|
+
function resolveGrokOAuthResponsesTransport(config) {
|
|
92
|
+
// An explicit Grok-specific transport setting is the escape hatch and wins
|
|
93
|
+
// for EVERY Grok model, api.x.ai and proxy-only alike — e.g. pin 'http' to
|
|
94
|
+
// force proxy-only models back onto cli-chat-proxy.grok.com over HTTP.
|
|
95
95
|
const raw = config?.responsesTransport
|
|
96
96
|
?? config?.transport
|
|
97
97
|
?? process.env.MIXDOG_GROK_OAUTH_RESPONSES_TRANSPORT
|
|
98
98
|
?? process.env.MIXDOG_GROK_OAUTH_TRANSPORT
|
|
99
99
|
?? '';
|
|
100
100
|
const mode = String(raw).trim().toLowerCase();
|
|
101
|
-
|
|
101
|
+
if (mode) return mode;
|
|
102
|
+
// No Grok-specific override: leave the field unset so ALL api.x.ai-capable
|
|
103
|
+
// Grok models honor the shared MIXDOG_OAI_TRANSPORT switch. Proxy-only
|
|
104
|
+
// grok-build is included: the shared xAI WebSocket connector targets the
|
|
105
|
+
// fixed wss://api.x.ai/v1/responses endpoint (not the proxy baseURL), and a
|
|
106
|
+
// live probe confirmed grok-build serves there with the OAuth token. No
|
|
107
|
+
// implicit HTTP pin — the proxy baseURL is only used when HTTP is selected.
|
|
108
|
+
return undefined;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
// Retired model aliases xAI no longer exposes by their old ids. The live
|
|
@@ -639,10 +646,9 @@ export class GrokOAuthProvider {
|
|
|
639
646
|
...this.config,
|
|
640
647
|
apiKey: token,
|
|
641
648
|
baseURL: proxy ? PROXY_BASE_URL : INFERENCE_BASE_URL,
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
// the WS connector targets api.x.ai, not the Grok CLI proxy.
|
|
649
|
+
// All Grok models — proxy-only grok-build included — inherit the
|
|
650
|
+
// shared xAI transport switch (WS→api.x.ai by default). An explicit
|
|
651
|
+
// Grok-specific http setting is the escape hatch back to the proxy.
|
|
646
652
|
responsesTransport: resolveGrokOAuthResponsesTransport(this.config, proxy),
|
|
647
653
|
// Proxy-only models additionally need the Grok CLI client headers to
|
|
648
654
|
// clear the proxy version gate (HTTP 426 otherwise).
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
resolveTimeoutMs,
|
|
19
19
|
} from '../stall-policy.mjs';
|
|
20
20
|
import { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
|
|
21
|
+
import {
|
|
22
|
+
resolveResponsesTransportPolicy,
|
|
23
|
+
RESPONSES_TRANSPORT_CAPABILITIES,
|
|
24
|
+
} from './openai-transport-policy.mjs';
|
|
21
25
|
import {
|
|
22
26
|
summarizeTraceMessages,
|
|
23
27
|
extractCompatCachedTokens,
|
|
@@ -191,11 +195,27 @@ export class OpenAICompatProvider {
|
|
|
191
195
|
// TLS handshake after an idle gap. Fire-and-forget; never awaited.
|
|
192
196
|
preconnect(this.baseURL);
|
|
193
197
|
if (this.name === 'xai' && useXaiResponsesApi(opts, this.config)) {
|
|
194
|
-
|
|
198
|
+
// Shared Responses transport switch (MIXDOG_OAI_TRANSPORT), capability-
|
|
199
|
+
// gated for xAI/Grok. Provider-local HTTP pins still win: Grok
|
|
200
|
+
// proxy-only models set responsesTransport:'http' because the WS
|
|
201
|
+
// connector targets api.x.ai, not cli-chat-proxy.grok.com.
|
|
202
|
+
const xaiTransportPolicy = resolveResponsesTransportPolicy(
|
|
203
|
+
process.env,
|
|
204
|
+
RESPONSES_TRANSPORT_CAPABILITIES.xai,
|
|
205
|
+
);
|
|
206
|
+
const configuredPreferWebSocket = useXaiResponsesWebSocket(opts, this.config);
|
|
207
|
+
const preferWebSocket = configuredPreferWebSocket === false
|
|
208
|
+
? false
|
|
209
|
+
: xaiTransportPolicy.mode === 'http-sse'
|
|
210
|
+
? false
|
|
211
|
+
: xaiTransportPolicy.transport === 'ws'
|
|
212
|
+
? true
|
|
213
|
+
: configuredPreferWebSocket;
|
|
214
|
+
if (preferWebSocket) {
|
|
195
215
|
try {
|
|
196
216
|
return await this._doSendXaiResponsesWebSocket(messages, useModel, tools, opts);
|
|
197
217
|
} catch (err) {
|
|
198
|
-
if (_shouldFallbackXaiWsToHttp(err, opts.signal)) {
|
|
218
|
+
if (xaiTransportPolicy.allowHttpFallback && _shouldFallbackXaiWsToHttp(err, opts.signal)) {
|
|
199
219
|
const reason = err?.midstreamClassifier || err?.retryClassifier || err?.code || err?.message || 'ws_failed';
|
|
200
220
|
process.stderr.write(`[xai:responses] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
|
|
201
221
|
try {
|
|
@@ -659,6 +679,16 @@ export class OpenAICompatProvider {
|
|
|
659
679
|
: null;
|
|
660
680
|
const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
|
|
661
681
|
let cacheLane = null;
|
|
682
|
+
// Fast-fallback only shortens the WS handshake retry budget when the
|
|
683
|
+
// HTTP/SSE fallback is actually enabled for this call. With no-fallback
|
|
684
|
+
// (allowHttpFallback=false) the WS path must keep its FULL retry budget,
|
|
685
|
+
// mirroring openai-oauth's httpFallbackEnabled gate.
|
|
686
|
+
const xaiTransportPolicy = resolveResponsesTransportPolicy(
|
|
687
|
+
process.env,
|
|
688
|
+
RESPONSES_TRANSPORT_CAPABILITIES.xai,
|
|
689
|
+
);
|
|
690
|
+
const httpFallbackEnabled = xaiTransportPolicy.allowHttpFallback
|
|
691
|
+
&& _envFlag('MIXDOG_XAI_WS_HTTP_FALLBACK', true);
|
|
662
692
|
const scheduled = await withXaiResponsesCacheLane({
|
|
663
693
|
opts,
|
|
664
694
|
config: this.config,
|
|
@@ -692,8 +722,9 @@ export class OpenAICompatProvider {
|
|
|
692
722
|
// enabled (outer catch → _shouldFallbackXaiWsToHttp), a first
|
|
693
723
|
// acquire/first-byte failure should skip remaining WS
|
|
694
724
|
// handshake retries instead of burning the retry budget
|
|
695
|
-
// before HTTP starts.
|
|
696
|
-
|
|
725
|
+
// before HTTP starts. Gated on httpFallbackEnabled so a
|
|
726
|
+
// no-fallback config keeps the full WS retry budget.
|
|
727
|
+
fastFallback: httpFallbackEnabled,
|
|
697
728
|
});
|
|
698
729
|
});
|
|
699
730
|
const result = scheduled.value;
|
|
@@ -63,6 +63,8 @@ import {
|
|
|
63
63
|
parseToolSearchArgs,
|
|
64
64
|
_streamResponse,
|
|
65
65
|
} from './openai-ws-stream.mjs';
|
|
66
|
+
import { _buildResponseCreateFrame } from './openai-ws-delta.mjs';
|
|
67
|
+
import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
|
|
66
68
|
|
|
67
69
|
// Legacy import paths for scripts/tool-smoke.mjs, mixdog-session-runtime.mjs
|
|
68
70
|
// (drainOpenaiWsPool), scripts/provider-toolcall-test.mjs (parseToolSearchArgs,
|
|
@@ -74,6 +76,7 @@ export {
|
|
|
74
76
|
parseToolSearchArgs,
|
|
75
77
|
_streamResponse,
|
|
76
78
|
_cacheObservation as _cacheObservationForTest,
|
|
79
|
+
_warmupContinuityTrace as _warmupContinuityTraceForTest,
|
|
77
80
|
};
|
|
78
81
|
|
|
79
82
|
globalThis.__mixdogOpenaiWsRuntimeLoaded = true;
|
|
@@ -234,33 +237,53 @@ function _codexInstallationId(sendOpts) {
|
|
|
234
237
|
|| `mixdog-${_hashText(`${process.env.USERPROFILE || process.env.HOME || ''}:${process.cwd()}`, 32)}`;
|
|
235
238
|
}
|
|
236
239
|
|
|
237
|
-
function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts } = {}) {
|
|
240
|
+
function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = false } = {}) {
|
|
238
241
|
const sessionId = _cleanMetaString(sendOpts?.codexSessionId || sendOpts?.session?.codexSessionId || poolKey || cacheKey)
|
|
239
242
|
|| 'mixdog-session';
|
|
240
243
|
const threadId = _cleanMetaString(sendOpts?.threadId || sendOpts?.codexThreadId || sendOpts?.session?.threadId || cacheKey || sessionId)
|
|
241
244
|
|| sessionId;
|
|
242
|
-
const turnId = _cleanMetaString(sendOpts?.turnId || sendOpts?.codexTurnId || sendOpts?.session?.turnId || sessionId)
|
|
243
|
-
|| sessionId;
|
|
244
|
-
const windowId = _cleanMetaString(sendOpts?.windowId || sendOpts?.codexWindowId || sendOpts?.session?.windowId)
|
|
245
|
-
|| `${threadId}:1`;
|
|
246
245
|
const installationId = _codexInstallationId(sendOpts);
|
|
247
246
|
const startedAt = Number.isFinite(Number(sendOpts?.turnStartedAtUnixMs))
|
|
248
247
|
? Math.floor(Number(sendOpts.turnStartedAtUnixMs))
|
|
249
248
|
: _sessionStartedAtUnixMs(sessionId);
|
|
250
249
|
const requestKind = _codexRequestKind(sendOpts, sessionId);
|
|
250
|
+
const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
|
|
251
|
+
// codex opens the WS with a prewarm (empty turn_id) BEFORE issuing the
|
|
252
|
+
// real turn (client.rs). Our handshake headers are built once, up-front,
|
|
253
|
+
// and were carrying a turn_id (= sessionId) — i.e. presenting the
|
|
254
|
+
// handshake as a live turn. Under wire parity, treat the handshake as the
|
|
255
|
+
// prewarm so its turn_id is empty, matching codex. Default (parity off) is
|
|
256
|
+
// unchanged: the wireParity gate below leaves turnId as before.
|
|
257
|
+
const isPrewarm = requestKind === 'prewarm' || handshake === true;
|
|
258
|
+
const explicitTurnId = _cleanMetaString(sendOpts?.turnId || sendOpts?.codexTurnId || sendOpts?.session?.turnId);
|
|
259
|
+
const explicitWindowId = _cleanMetaString(sendOpts?.windowId || sendOpts?.codexWindowId || sendOpts?.session?.windowId);
|
|
260
|
+
const turnId = wireParity && isPrewarm
|
|
261
|
+
? ''
|
|
262
|
+
: (explicitTurnId || sessionId);
|
|
263
|
+
// Under wire parity the handshake IS the prewarm (empty turn_id above), so
|
|
264
|
+
// its request_kind must be 'prewarm' too — codex tags the prewarm request
|
|
265
|
+
// as prewarm, not turn (client.rs). Previously we emptied turn_id but left
|
|
266
|
+
// request_kind='turn', presenting the prewarm as a live turn. Default
|
|
267
|
+
// (parity off) is unchanged: requestKind passes through untouched.
|
|
268
|
+
const effectiveRequestKind = wireParity && isPrewarm ? 'prewarm' : requestKind;
|
|
269
|
+
const windowId = explicitWindowId
|
|
270
|
+
|| `${threadId}:${wireParity ? 0 : 1}`;
|
|
251
271
|
const turnMetadata = {
|
|
252
272
|
installation_id: installationId,
|
|
253
273
|
session_id: sessionId,
|
|
254
274
|
thread_id: threadId,
|
|
255
275
|
turn_id: turnId,
|
|
256
276
|
window_id: windowId,
|
|
257
|
-
request_kind:
|
|
277
|
+
request_kind: effectiveRequestKind,
|
|
258
278
|
// Richer codex turn-metadata fields (responses_metadata.rs:264-280:
|
|
259
279
|
// thread_source "user" per protocol.rs:2751-2765, sandbox label).
|
|
260
280
|
// A/B 2026-07-04 (rvA/rvB interleaved, 24 sessions/arm): no effect
|
|
261
281
|
// (it2 full 3 vs 2, miss 2 vs 3 — noise). Default OFF; keep the knob
|
|
262
282
|
// for future probes if the backend starts gating on payload richness.
|
|
263
|
-
|
|
283
|
+
// Codex WS metadata parity (opt-in): the richer turn-metadata fields
|
|
284
|
+
// codex emits (responses_metadata.rs:264-280) also go on the wire under
|
|
285
|
+
// MIXDOG_OAI_CODEX_WIRE_PARITY. Default (both flags off) is unchanged.
|
|
286
|
+
...((process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' || wireParity) ? {
|
|
264
287
|
thread_source: 'user',
|
|
265
288
|
sandbox: 'read-only',
|
|
266
289
|
} : {}),
|
|
@@ -332,10 +355,22 @@ function _codexWsCompatibilityHeaders(context = {}) {
|
|
|
332
355
|
delete headers['x-codex-turn-metadata'];
|
|
333
356
|
}
|
|
334
357
|
// probe === '1' | 'true' | 'yes' | 'on' | 'turn-metadata' => full set as built above.
|
|
358
|
+
// Turn-state gate experiment bundle (MIXDOG_OAI_CODEX_TURN_STATE_GATE): the
|
|
359
|
+
// debugger's hypothesis is that x-codex-turn-state issuance also wants the
|
|
360
|
+
// parent-thread header present, independent of MIXDOG_OAI_TURN_METADATA.
|
|
361
|
+
// Attach it (= thread_id) when the gate is explicitly enabled and the probe
|
|
362
|
+
// above didn't already set it. Default OFF; composes with the probe.
|
|
363
|
+
const gate = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_GATE || '').trim().toLowerCase();
|
|
364
|
+
if (['1', 'true', 'yes', 'on'].includes(gate) && !headers['x-codex-parent-thread-id']) {
|
|
365
|
+
const parentThreadId = _cleanMetaString(context?.sendOpts?.parentThreadId
|
|
366
|
+
|| context?.sendOpts?.codexParentThreadId
|
|
367
|
+
|| metadata.thread_id);
|
|
368
|
+
if (parentThreadId) headers['x-codex-parent-thread-id'] = parentThreadId;
|
|
369
|
+
}
|
|
335
370
|
return headers;
|
|
336
371
|
}
|
|
337
372
|
|
|
338
|
-
function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
|
|
373
|
+
export function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
|
|
339
374
|
if (!enabled || !frame || typeof frame !== 'object') return frame;
|
|
340
375
|
const base = _codexMetadataBase(entry, context);
|
|
341
376
|
const metadata = {
|
|
@@ -347,10 +382,24 @@ function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
|
|
|
347
382
|
// codex scopes x-codex-turn-state to ONE turn (client.rs:263-279 —
|
|
348
383
|
// "must not send it between different turns"). Pooled sockets span
|
|
349
384
|
// turns, so key the stored token by the turn that captured it and
|
|
350
|
-
// drop it when the turn changes.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
385
|
+
// drop it when the turn changes. turnState is captured at handshake
|
|
386
|
+
// (pool) / mid-stream (ws-stream) without knowing the owning turn, so
|
|
387
|
+
// attribute it to the FIRST turn that observes it here; once turn_id
|
|
388
|
+
// moves off that owner, drop the stale token. (Previously
|
|
389
|
+
// turnStateTurnId was checked but never assigned, so this guard was
|
|
390
|
+
// dead and stale turn-state could leak across turns under parity.)
|
|
391
|
+
if (entry.turnState) {
|
|
392
|
+
// An empty turn_id (parity prewarm) is a VALID owner attribution, not
|
|
393
|
+
// "unassigned". Test against null/undefined so a prewarm-owned token
|
|
394
|
+
// (turn_id '') is retired once turn_id advances to the real turn,
|
|
395
|
+
// instead of being reattributed onto it. Default (parity off) never
|
|
396
|
+
// produces an empty turn_id, so this is unchanged there.
|
|
397
|
+
if (entry.turnStateTurnId == null) {
|
|
398
|
+
entry.turnStateTurnId = base.turn_id;
|
|
399
|
+
} else if (entry.turnStateTurnId !== base.turn_id) {
|
|
400
|
+
entry.turnState = null;
|
|
401
|
+
entry.turnStateTurnId = null;
|
|
402
|
+
}
|
|
354
403
|
}
|
|
355
404
|
entry.currentTurnId = base.turn_id;
|
|
356
405
|
}
|
|
@@ -402,6 +451,33 @@ function _cacheObservation({ entry, result }) {
|
|
|
402
451
|
};
|
|
403
452
|
}
|
|
404
453
|
|
|
454
|
+
// Warmup→first-real continuity trace (Codex prewarm_websocket parity
|
|
455
|
+
// observability). Pure/deterministic so it unit-tests without a live socket.
|
|
456
|
+
// The R23 finding forbids the post-warmup request rewrite, so parity is
|
|
457
|
+
// asserted via metrics instead of behavior: does the warmup's response_id
|
|
458
|
+
// become the anchor the FIRST real request chains from, and what is the
|
|
459
|
+
// hit/miss outcome of the first up-to-3 real requests on the socket.
|
|
460
|
+
export function _warmupContinuityTrace({
|
|
461
|
+
warmupUsed,
|
|
462
|
+
warmupResponseId,
|
|
463
|
+
priorEntryResponseId,
|
|
464
|
+
sentPrevResponseId,
|
|
465
|
+
earlyCacheMisses,
|
|
466
|
+
} = {}) {
|
|
467
|
+
const misses = Array.isArray(earlyCacheMisses) ? earlyCacheMisses.slice(0, 3) : [];
|
|
468
|
+
// The first real request is a full frame (no prev_id, per R23), so its
|
|
469
|
+
// anchor is what the entry held at build time — which the warmup wrote.
|
|
470
|
+
const firstRealPrevId = sentPrevResponseId || priorEntryResponseId || null;
|
|
471
|
+
return {
|
|
472
|
+
warmup_first_real_prev_id: firstRealPrevId,
|
|
473
|
+
warmup_chain_continuous: !!warmupUsed
|
|
474
|
+
&& !!warmupResponseId
|
|
475
|
+
&& firstRealPrevId === warmupResponseId,
|
|
476
|
+
early_cache_misses: misses,
|
|
477
|
+
early_cache_miss_count: misses.filter(Boolean).length,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
405
481
|
/**
|
|
406
482
|
* Run `_acquire({auth, poolKey, cacheKey})` with bounded exponential-backoff
|
|
407
483
|
* retry on transient handshake failures. The injection seams (`_acquire`,
|
|
@@ -600,7 +676,7 @@ export async function sendViaWebSocket({
|
|
|
600
676
|
const useCodexWsClientMetadata = traceProvider === 'openai-oauth';
|
|
601
677
|
const codexMetadataContext = { poolKey, cacheKey, sendOpts };
|
|
602
678
|
const codexHandshakeHeaders = useCodexWsClientMetadata
|
|
603
|
-
? _codexWsCompatibilityHeaders(codexMetadataContext)
|
|
679
|
+
? _codexWsCompatibilityHeaders({ ...codexMetadataContext, handshake: true })
|
|
604
680
|
: null;
|
|
605
681
|
|
|
606
682
|
for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
|
|
@@ -725,8 +801,27 @@ export async function sendViaWebSocket({
|
|
|
725
801
|
// has no prior request state. A reused pooled socket with a live
|
|
726
802
|
// chain must go straight to the real request.
|
|
727
803
|
if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0 && !entry.lastResponseId) {
|
|
728
|
-
|
|
729
|
-
|
|
804
|
+
// Codex WS prewarm parity (opt-in): codex's prewarm frame is a
|
|
805
|
+
// minimal generate:false request that omits transport-only
|
|
806
|
+
// fields (stream/background) on the wire (prewarm_websocket,
|
|
807
|
+
// client.rs:1673-1705). Under MIXDOG_OAI_CODEX_WIRE_PARITY force
|
|
808
|
+
// that omission for the warmup frame too; default is unchanged.
|
|
809
|
+
const warmupWireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
|
|
810
|
+
const parityWarmupBody = warmupWireParity
|
|
811
|
+
? { ...warmupBody, input: [], generate: false }
|
|
812
|
+
: warmupBody;
|
|
813
|
+
const warmupFrame = _buildResponseCreateFrame(parityWarmupBody, { omitTransportFields: warmupWireParity });
|
|
814
|
+
const warmupMetadataContext = warmupWireParity
|
|
815
|
+
? {
|
|
816
|
+
...codexMetadataContext,
|
|
817
|
+
sendOpts: {
|
|
818
|
+
...(codexMetadataContext?.sendOpts || {}),
|
|
819
|
+
requestKind: 'prewarm',
|
|
820
|
+
codexRequestKind: 'prewarm',
|
|
821
|
+
},
|
|
822
|
+
}
|
|
823
|
+
: codexMetadataContext;
|
|
824
|
+
const wireWarmupFrame = _withCodexWsClientMetadata(warmupFrame, entry, useCodexWsClientMetadata, warmupMetadataContext);
|
|
730
825
|
wireFrameHadTurnState = !!wireWarmupFrame?.client_metadata?.['x-codex-turn-state'];
|
|
731
826
|
wireFrameMetadataTrace = _metadataTrace(wireWarmupFrame?.client_metadata);
|
|
732
827
|
await _sendFrameFn(entry, wireWarmupFrame);
|
|
@@ -761,8 +856,8 @@ export async function sendViaWebSocket({
|
|
|
761
856
|
throw new Error('Responses WS warmup completed without response id');
|
|
762
857
|
}
|
|
763
858
|
entry.lastResponseId = warmupResult.responseId;
|
|
764
|
-
entry.lastRequestSansInput = _stableStringify(_sansInput(
|
|
765
|
-
const warmupInputArr = Array.isArray(
|
|
859
|
+
entry.lastRequestSansInput = _stableStringify(_sansInput(parityWarmupBody));
|
|
860
|
+
const warmupInputArr = Array.isArray(parityWarmupBody.input) ? parityWarmupBody.input : [];
|
|
766
861
|
entry.lastRequestInput = _cloneJson(warmupInputArr);
|
|
767
862
|
entry.lastResponseItems = _cloneJson(Array.isArray(warmupResult.responseItems) ? warmupResult.responseItems : []);
|
|
768
863
|
entry.lastInputLen = warmupInputArr.length;
|
|
@@ -800,7 +895,7 @@ export async function sendViaWebSocket({
|
|
|
800
895
|
// Delta opt-in still chains via entry.lastResponseId above.
|
|
801
896
|
}
|
|
802
897
|
|
|
803
|
-
const delta = _computeDelta({ entry, body: requestBody });
|
|
898
|
+
const delta = _computeDelta({ entry, body: requestBody, traceProvider });
|
|
804
899
|
({ mode, frame } = delta);
|
|
805
900
|
deltaReason = delta.reason || null;
|
|
806
901
|
strippedResponseItems = delta.strippedResponseItems || 0;
|
|
@@ -1093,6 +1188,22 @@ export async function sendViaWebSocket({
|
|
|
1093
1188
|
_num(entry.promptCacheMaxCachedTokens, 0),
|
|
1094
1189
|
cacheObservation.cachedTokens,
|
|
1095
1190
|
);
|
|
1191
|
+
// Early-session cache-miss ledger (first up-to-3 real requests on this
|
|
1192
|
+
// socket) for the warmup→first-real continuity trace below. Warmup
|
|
1193
|
+
// itself is excluded — this block only runs on the real send.
|
|
1194
|
+
if (!Array.isArray(entry.earlyCacheMisses)) entry.earlyCacheMisses = [];
|
|
1195
|
+
if (entry.earlyCacheMisses.length < 3) {
|
|
1196
|
+
entry.earlyCacheMisses.push(
|
|
1197
|
+
cacheObservation.actualMiss ? (cacheObservation.missReason || 'miss') : false,
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
const warmupContinuity = _warmupContinuityTrace({
|
|
1201
|
+
warmupUsed: !!warmupResult,
|
|
1202
|
+
warmupResponseId: warmupResult?.responseId || null,
|
|
1203
|
+
priorEntryResponseId,
|
|
1204
|
+
sentPrevResponseId,
|
|
1205
|
+
earlyCacheMisses: entry.earlyCacheMisses,
|
|
1206
|
+
});
|
|
1096
1207
|
// Extra WS-specific observability: transport + per-iteration delta bytes.
|
|
1097
1208
|
try {
|
|
1098
1209
|
const transportPayload = {
|
|
@@ -1140,6 +1251,7 @@ export async function sendViaWebSocket({
|
|
|
1140
1251
|
frame_has_instructions: typeof frame.instructions === 'string' && frame.instructions.length > 0,
|
|
1141
1252
|
warmup_used: !!warmupResult,
|
|
1142
1253
|
warmup_response_id: warmupResult?.responseId || null,
|
|
1254
|
+
...warmupContinuity,
|
|
1143
1255
|
tool_call_count: resultToolCallCount,
|
|
1144
1256
|
keep_socket: keepSocket,
|
|
1145
1257
|
keep_response_chain: keepResponseChain,
|
|
@@ -17,6 +17,7 @@ import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
|
17
17
|
import { makeModelCache } from './model-cache.mjs';
|
|
18
18
|
|
|
19
19
|
import { sendViaWebSocket } from './openai-oauth-ws.mjs';
|
|
20
|
+
import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
|
|
20
21
|
import {
|
|
21
22
|
buildStableProviderPromptCacheKey,
|
|
22
23
|
resolveProviderPromptCacheLane,
|
|
@@ -398,9 +399,13 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
398
399
|
const out = [];
|
|
399
400
|
const pendingToolMedia = [];
|
|
400
401
|
const customToolCallNameById = new Map();
|
|
402
|
+
const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
|
|
403
|
+
const wireMessage = (role, content) => (wireParity
|
|
404
|
+
? { type: 'message', role, content, internal_chat_message_metadata_passthrough: {} }
|
|
405
|
+
: { role, content });
|
|
401
406
|
const flushToolMedia = () => {
|
|
402
407
|
if (!pendingToolMedia.length) return;
|
|
403
|
-
out.push(
|
|
408
|
+
out.push(wireMessage('user', pendingToolMedia.splice(0)));
|
|
404
409
|
};
|
|
405
410
|
for (const m of messages) {
|
|
406
411
|
if (!m || m.role === 'system') continue;
|
|
@@ -442,7 +447,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
442
447
|
// for the WS_IDLE_MS window even after a socket close).
|
|
443
448
|
// Server-side state already preserves the prefix; sending
|
|
444
449
|
// reasoning in `input` triggers "Duplicate item".
|
|
445
|
-
if (m.content) out.push(
|
|
450
|
+
if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
|
|
446
451
|
for (const tc of m.toolCalls) {
|
|
447
452
|
if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
|
|
448
453
|
out.push({
|
|
@@ -470,10 +475,10 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
470
475
|
}
|
|
471
476
|
continue;
|
|
472
477
|
}
|
|
473
|
-
out.push(
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
478
|
+
out.push(wireMessage(
|
|
479
|
+
m.role === 'assistant' ? 'assistant' : 'user',
|
|
480
|
+
normalizeContentForOpenAIResponses(m.content, { role: m.role }),
|
|
481
|
+
));
|
|
477
482
|
}
|
|
478
483
|
flushToolMedia();
|
|
479
484
|
return out;
|
|
@@ -812,7 +817,9 @@ export class OpenAIOAuthProvider {
|
|
|
812
817
|
// Fast-fallback is only meaningful when HTTP/SSE fallback is actually
|
|
813
818
|
// configured for this provider; WS-only paths keep the full handshake
|
|
814
819
|
// retry budget. This mirrors _shouldUseOpenAIHttpFallback's `enabled`.
|
|
815
|
-
const
|
|
820
|
+
const transportPolicy = resolveOpenAiTransportPolicy();
|
|
821
|
+
const httpFallbackEnabled = transportPolicy.allowHttpFallback
|
|
822
|
+
&& _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
|
|
816
823
|
const _t1 = Date.now();
|
|
817
824
|
const recordLiveModel = (result) => {
|
|
818
825
|
if (result?.model && !_codexCatalogHas(result.model)) {
|
|
@@ -936,9 +943,12 @@ export class OpenAIOAuthProvider {
|
|
|
936
943
|
? { ...body, generate: false }
|
|
937
944
|
: null,
|
|
938
945
|
});
|
|
939
|
-
if (
|
|
940
|
-
||
|
|
941
|
-
|
|
946
|
+
if (transportPolicy.transport === 'http'
|
|
947
|
+
|| (transportPolicy.allowHttpFallback && (
|
|
948
|
+
opts.forceHttpFallback === true
|
|
949
|
+
|| httpFallbackActive()
|
|
950
|
+
|| _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)
|
|
951
|
+
))) {
|
|
942
952
|
return dispatchHttp('forced');
|
|
943
953
|
}
|
|
944
954
|
|
|
@@ -970,7 +980,7 @@ export class OpenAIOAuthProvider {
|
|
|
970
980
|
return recordLiveModel(result);
|
|
971
981
|
} catch (retryErr) {
|
|
972
982
|
traceWsError(retryErr, 'auth_retry');
|
|
973
|
-
if (_shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
|
|
983
|
+
if (httpFallbackEnabled && _shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
|
|
974
984
|
try {
|
|
975
985
|
return await dispatchHttp(
|
|
976
986
|
retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed',
|
|
@@ -997,7 +1007,7 @@ export class OpenAIOAuthProvider {
|
|
|
997
1007
|
await this._refreshModelCache();
|
|
998
1008
|
return this.send(messages, model, tools, { ...opts, _modelRetry: true });
|
|
999
1009
|
}
|
|
1000
|
-
if (_shouldUseOpenAIHttpFallback(err, externalSignal)) {
|
|
1010
|
+
if (httpFallbackEnabled && _shouldUseOpenAIHttpFallback(err, externalSignal)) {
|
|
1001
1011
|
// Fast-path trace: the WS handshake acquire/first-byte failed on
|
|
1002
1012
|
// its FIRST attempt while fast-fallback was armed, so the
|
|
1003
1013
|
// remaining backoff retries were skipped and HTTP starts now.
|