Skip to content

Feature: per-provider opt-out of code_mode_only tool mode for routed models (deepseek-v4-flash: undeclared exec_command aborts stream, reconnect then fails reasoning replay with DeepSeek 400) #2106

Description

@nowhere1975

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

  1. ocx start, add deepseek provider (adapter openai-responses), ocx sync
  2. In Codex (app or CLI) select deepseek/deepseek-v4-flash
  3. Ask the model to run any shell command (or let it decide to use a tool)
  4. The model calls exec_command directly (not via the code-mode exec/tools.* helper)
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions