Client or integration
Codex App + Codex CLI 0.137.0
Area
Proxy and routing (catalog / tool mode)
Summary
Routed models are unconditionally stamped tool_mode = "code_mode_only", but some models (observed: deepseek/deepseek-v4-flash) ignore the code-mode nested-helper contract and call exec_command at the top level. The undeclared-tool guard then aborts the stream, the Codex app reconnects, and the replayed turn fails reasoning replay with an upstream 400:
stream disconnected before completion: routed provider emitted undeclared client tool "exec_command"; only request-declared tools may be called
The `reasoning_text` in the thinking mode must be passed back to the API.
Reproduction
ocx start, add deepseek provider (adapter openai-responses), ocx sync
- In Codex (app or CLI) select
deepseek/deepseek-v4-flash
- Ask the model to run any shell command (or let it decide to use a tool)
- The model calls
exec_command directly (not via the code-mode exec/tools.* helper)
- Stream aborts, client reconnects, reasoning replay fails with the DeepSeek 400 above
Root cause analysis
applyRoutedCodexToolMode (src/codex/catalog/parsing.ts) sets tool_mode = "code_mode_only" for every routed model with no opt-out.
- Under
code_mode_only, codex-rs declares the unified exec (freeform) tool; exec_command/shell_command exist only as nested tools.* helpers inside it and are not top-level declared tools.
- The undeclared-tool guard (src/server/responses-undeclared-tool-guard.ts, src/bridge.ts) correctly refuses a top-level
exec_command call because the codex client cannot execute an undeclared call.
- The tool-catalog nudge (src/adapters/tool-catalog-nudge.ts) is advisory and is only injected on the openai-chat adapter path; the openai-responses passthrough never injects it, and the model ignored it in both paths anyway.
- After the guard aborts the stream, the Codex app reconnects and replays the interrupted turn; the reasoning part of that turn is then missing/incomplete on replay, which DeepSeek's thinking mode rejects with
The reasoning_text in the thinking mode must be passed back to the API. The two errors are one chain: fix the tool-mode mismatch and the 400 disappears (verified).
Suggested fix
Add a per-provider (ideally per-model) config field so operators can keep the flat shell tool mode for models that do not honor code mode:
# config.json: providers.<name>
"codexToolMode": "shell" # "code_mode_only" (default) | "shell"
applyRoutedCodexToolMode would honor it: when "shell", leave tool_mode unset so codex-rs declares exec_command per shell_type: "shell_command", making the model's calls declared and executable. Default stays code_mode_only for all other routed models.
Optionally, also inject the tool-catalog nudge on the openai-responses passthrough path as defense-in-depth.
Local workaround (verified)
Patched applyRoutedCodexToolMode locally to honor providers.<name>.codexToolMode === "shell" (config schema is passthrough, so no schema change needed). Verified on opencodex 2.26.0:
deepseek/deepseek-v4-flash: exec_command calls now execute (zsh -lc 'echo ...' returns output), no stream abort, no reconnect, no 400
- Other routed models (e.g.
kimi/*) keep tool_mode = code_mode_only and are unaffected
- Two-turn continuation with reasoning replay works on both
openai-chat and openai-responses adapters
Version
2.26.0
Operating system
macOS
Client or integration
Codex App + Codex CLI 0.137.0
Area
Proxy and routing (catalog / tool mode)
Summary
Routed models are unconditionally stamped
tool_mode = "code_mode_only", but some models (observed:deepseek/deepseek-v4-flash) ignore the code-mode nested-helper contract and callexec_commandat the top level. The undeclared-tool guard then aborts the stream, the Codex app reconnects, and the replayed turn fails reasoning replay with an upstream 400:Reproduction
ocx start, add deepseek provider (adapteropenai-responses),ocx syncdeepseek/deepseek-v4-flashexec_commanddirectly (not via the code-modeexec/tools.*helper)Root cause analysis
applyRoutedCodexToolMode(src/codex/catalog/parsing.ts) setstool_mode = "code_mode_only"for every routed model with no opt-out.code_mode_only, codex-rs declares the unifiedexec(freeform) tool;exec_command/shell_commandexist only as nestedtools.*helpers inside it and are not top-level declared tools.exec_commandcall because the codex client cannot execute an undeclared call.The reasoning_text in the thinking mode must be passed back to the API.The two errors are one chain: fix the tool-mode mismatch and the 400 disappears (verified).Suggested fix
Add a per-provider (ideally per-model) config field so operators can keep the flat shell tool mode for models that do not honor code mode:
applyRoutedCodexToolModewould honor it: when"shell", leavetool_modeunset so codex-rs declaresexec_commandpershell_type: "shell_command", making the model's calls declared and executable. Default stayscode_mode_onlyfor all other routed models.Optionally, also inject the tool-catalog nudge on the openai-responses passthrough path as defense-in-depth.
Local workaround (verified)
Patched
applyRoutedCodexToolModelocally to honorproviders.<name>.codexToolMode === "shell"(config schema is passthrough, so no schema change needed). Verified on opencodex 2.26.0:deepseek/deepseek-v4-flash:exec_commandcalls now execute (zsh -lc 'echo ...'returns output), no stream abort, no reconnect, no 400kimi/*) keeptool_mode = code_mode_onlyand are unaffectedopenai-chatandopenai-responsesadaptersVersion
2.26.0
Operating system
macOS