Summary
When a routed model is configured for shell tool mode (codexToolMode: "shell", i.e. tool_mode unset), the outbound request catalog declares exec_command (and apply_patch), but not exec. Models such as DeepSeek V4 Flash frequently emit the code-mode tool name exec — sometimes in the same turn as exec_command. Because normalizeDeclaredToolName only maps legacy shell names → exec when exec IS declared, bare exec calls stay undeclared in shell mode, and both the Responses passthrough guard (src/server/responses-undeclared-tool-guard.ts) and src/bridge.ts fail the stream:
stream disconnected before completion: routed provider emitted undeclared client tool "exec"; only request-declared tools may be called
This is the mirror image of #2112: that fix lets models that emit top-level exec_command run in shell mode, but the same models also emit exec, which shell mode does not declare.
Environment
- opencodex 2.33.0 (also present on current main)
- Provider:
openai-responses adapter, statelessResponses: true (DeepSeek endpoint)
- Model:
deepseek-v4-flash, provider-level codexToolMode: "shell"
Evidence
normalizeDeclaredToolName in src/types/tools.ts short-circuits with if (!declared || !declared.has("exec")) return name; — the exec_command/shell_command → exec mapping is the only direction supported.
- A single response state dump (
responses-state-spill) shows the model mixing both conventions: "exec" ×72, "exec_command" ×17, "apply_patch" ×15.
- Failures are intermittent because the model alternates between the two conventions; a turn is only aborted when it emits a bare
exec.
Suggested fix
Add the reverse normalization: when the catalog declares a legacy shell bridge name (exec_command/shell_command) and does not declare exec, map a bare exec call to the declared shell name:
export function normalizeDeclaredToolName(
name: string,
declared: ReadonlySet<string> | undefined,
): string {
if (!declared) return name;
if (declared.has("exec")) {
if (declared.has(name)) return name;
// When the catalog explicitly declares any legacy shell bridge name, the
// environment genuinely exposes that tool — turn normalization off so a
// call is never mis-routed to `exec`.
if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) {
return name;
}
return (LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(name) ? "exec" : name;
}
// Mirror direction: shell mode declares exec_command/shell_command, not exec.
// Accept a bare `exec` call as the declared shell tool.
if (name === "exec") {
for (const legacy of LEGACY_SHELL_BRIDGE_TOOL_NAMES) {
if (declared.has(legacy)) return legacy;
}
}
return name;
}
Notes:
- Only normalize bare
exec. Namespaced calls must keep the existing full-wire-name rule in responses-undeclared-tool-guard.ts (never legacy-normalize namespaced calls), so an MCP server that legitimately exposes exec under its own namespace is unaffected.
- Keep the existing rule that normalization stays off when the catalog itself declares a legacy name, to avoid mis-routing a genuinely exposed tool.
- This should be covered by the same fail-closed guard so the semantics stay consistent across the passthrough and bridged paths.
Impact
Any routed model that mixes exec / exec_command conventions while running in shell tool mode sees random stream aborts (error code undeclared_tool_call, 502 upstream_error) with no automatic retry. With codexToolMode now configurable per provider/model, this affects exactly the setups that #2112 was added to support.
Summary
When a routed model is configured for shell tool mode (
codexToolMode: "shell", i.e.tool_modeunset), the outbound request catalog declaresexec_command(andapply_patch), but notexec. Models such as DeepSeek V4 Flash frequently emit the code-mode tool nameexec— sometimes in the same turn asexec_command. BecausenormalizeDeclaredToolNameonly maps legacy shell names →execwhenexecIS declared, bareexeccalls stay undeclared in shell mode, and both the Responses passthrough guard (src/server/responses-undeclared-tool-guard.ts) andsrc/bridge.tsfail the stream:This is the mirror image of #2112: that fix lets models that emit top-level
exec_commandrun in shell mode, but the same models also emitexec, which shell mode does not declare.Environment
openai-responsesadapter,statelessResponses: true(DeepSeek endpoint)deepseek-v4-flash, provider-levelcodexToolMode: "shell"Evidence
normalizeDeclaredToolNameinsrc/types/tools.tsshort-circuits withif (!declared || !declared.has("exec")) return name;— theexec_command/shell_command→execmapping is the only direction supported.responses-state-spill) shows the model mixing both conventions:"exec"×72,"exec_command"×17,"apply_patch"×15.exec.Suggested fix
Add the reverse normalization: when the catalog declares a legacy shell bridge name (
exec_command/shell_command) and does not declareexec, map a bareexeccall to the declared shell name:Notes:
exec. Namespaced calls must keep the existing full-wire-name rule inresponses-undeclared-tool-guard.ts(never legacy-normalize namespaced calls), so an MCP server that legitimately exposesexecunder its own namespace is unaffected.Impact
Any routed model that mixes
exec/exec_commandconventions while running in shell tool mode sees random stream aborts (error code undeclared_tool_call, 502 upstream_error) with no automatic retry. WithcodexToolModenow configurable per provider/model, this affects exactly the setups that #2112 was added to support.