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

Closed

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

Activity

  1. coderabbitai commented on Aug 19, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #304 - fix(kiro): restore hardening and complete text turns [open]
    #1361 - fix(responses): restore DeepSeek streaming and routed code-mode tools [merged]
    #1788 - fix(responses): fail closed when a routed provider calls an undeclared tool [merged]
    #1951 - fix(adapters): decide code mode from tool semantics, not from the name exec [merged]
    #1953 - fix(adapters): restore the un-namespaced requirement on the shell-bridge check [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. lidge-jun commented on Aug 19, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 52 / 80

    라우팅된 DeepSeek 모델이 Codex code-mode 계약을 무시하고 최상위 exec_command를 부른다. OpenCodex 가드가 스트림을 끊고, 앱이 재접속한 뒤 reasoning을 재전송하지 못해 DeepSeek가 400을 준다. 기본 native 경로가 아니라 라우티드 프로바이더 도구 호환 문제라 52다. 로컬에서 codexToolMode=shell로 우회가 검증됐고, 다른 라우티드 모델의 기본값은 그대로 두면 된다.

    원인은 src/codex/catalog/parsing.ts의 applyRoutedCodexToolMode()다. ROUTED_CODEX_TOOL_MODE = "code_mode_only"를 모든 라우티드 엔트리에 무조건 찍는다. src/codex/catalog/sync.ts가 카탈로그를 쓸 때 이 함수를 호출한다. opt-out 인자는 없다. code_mode_only 아래에서 codex-rs는 통합 exec(freeform)만 최상위로 선언하고, exec_command/shell_command는 tools.* 중첩 헬퍼로만 존재한다.

    모델이 최상위 exec_command를 내면 src/server/responses-undeclared-tool-guard.ts와 src/bridge.ts가 맞다. 가드 문구가 리포터 로그와 같다. routed provider emitted undeclared client tool "exec_command"; only request-declared tools may be called. 가드가 틀린 게 아니라, 카탈로그가 선언한 표면과 모델이 부르는 이름이 어긋난 것이다.

    src/adapters/tool-catalog-nudge.ts는 조언일 뿐이다. 주석대로 exec 옆에 납작한 exec_command가 있으면 브리지 모양이고, code mode면 await tools.exec_command(...)를 쓰라고 설명한다. 이 넛지는 openai-chat 경로에만 들어가고 openai-responses 패스스루에는 안 들어간다. 리포터도 두 경로 모두에서 모델이 무시했다고 한다. 넛지만으로는 이 버그를 못 닫는다.

    스트림이 끊긴 뒤의 DeepSeek 400(reasoning_text in the thinking mode must be passed back)은 두 번째 사고다. 가드가 턴을 중단하면 재접속 재생에 thinking 조각이 빠진다. tool-mode만 맞추면 400이 사라진다는 로컬 검증이 이 체인을 지지한다. 가드를 느슨히 풀어 undeclared exec_command를 실행하게 하는 건 클라이언트 계약을 깨므로 답이 아니다.

    해결방안

    applyRoutedCodexToolMode()가 providers.<name>.codexToolMode를 읽게 한다. "shell"이면 tool_mode를 비워 두어 codex-rs가 shell_type: "shell_command"로 exec_command를 최상위 선언하게 한다. 없거나 "code_mode_only"면 지금과 같다. 기본값은 모든 라우티드 모델에 대해 현재 동작 유지. 스키마를 강제하지 않아도 config는 패스스루라는 리포터 검증이 있다. 가능하면 모델 단위 키를 두고, DeepSeek flash만 shell로 두고 Kimi 등은 code_mode_only를 유지한다. openai-responses 패스스루에 tool-catalog 넛지를 넣는 것은 방어일 뿐 본 패치가 아니다. 가드 문구와 responses-undeclared-tool-guard.ts의 fail-closed는 그대로 둔다.

    이 댓글은 grok-bot이 작성했습니다

  3. added a commit that references this issue on Aug 19, 2026
  4. lidge-jun commented on Aug 19, 2026

    @lidge-jun
    Owner

    리뷰 · 종결

    dev에 이미 머지됨. #2112 (dbe260131) 가 codexToolMode 옵트아웃을 넣었음. origin/dev HEAD에 있음. 다음 배포 예정.

    이 이슈는 닫음. 재현되면 버전 찍고 다시 열면 됨.

    이 댓글은 grok-bot이 작성했습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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