Skip to content

[bug] Codex tools never reach a translated upstream: additional_tools items and custom tools are dropped #515

Description

@fedecia

What happens

A Codex CLI session through a route that translates the wire format (openai_responses in, openai_chat upstream) runs to completion and executes nothing. The model prints its tool call as prose and the turn ends. Codex reports success, so the failure is silent — exit status is 0 and the rollout looks like a normal short turn.

Same session, same prompt, on a passthrough (openai_responsesopenai_responses) route: the tool runs.

Reproduction

Codex 0.146.1, provider wire_api = "responses" pointed at Switchyard, a two-tier route whose efficient tier is an openai_chat client.

codex exec --dangerously-bypass-approvals-and-sandbox \
  'Run exactly this shell command and show me its output: echo CONTROL-PROBE'

The reply is the tool call, as text:

{
  "tool": "exec",
  "arguments": {
    "cmd": "echo CONTROL-PROBE && echo '---' && date -u",
    "max_output_tokens": 5000,
    "timeout": 15000
  }
}

~/.codex/sessions/**/rollout-*.jsonl for that session contains no function_call, custom_tool_call, or *_output item at all.

Root cause

Two request-decoding gaps compose. Both are in crates/switchyard-translation/src/codecs/responses/buffered.rs.

1. additional_tools input items are not decoded.

Codex 0.146 declares its whole toolset in an input item, not in the request's tools array. The captured inbound request has no tools key at all — only tool_choice: "auto" — and input[0] is:

{"type": "additional_tools", "role": "developer", "tools": [ ... ~24 KB ... ]}

decode_responses_input has no arm for that type, so it reaches the catch-all and becomes a ContentBlock::Unknown inside a user message. The upstream is therefore offered zero tools, while Codex's own prompt still tells the model the tools exist. Hence the prose call.

2. custom tools are dropped.

The item's tools array holds ordinary Responses tool specs, and one of them is:

{"type": "custom", "name": "exec", "description": "...", "format": {"type": "text"}}

decode_responses_tools has arms for namespace, function, and a bare name; everything else falls through to push_responses_id_tool, which requires an id, returns false, and has its return value discarded. So a custom tool disappears with no diagnostic. The string custom appears nowhere under crates/switchyard-translation/src/codecs/.

Fixing only (2) is not sufficient: with (1) unfixed the tool specs are never read in the first place.

Why a passthrough route is unaffected

Responses → Responses keeps the item as sent, so the upstream sees the declaration. That is why this looks like a model or a prompt problem until the inbound and outbound bodies are compared.

Evidence

Same config, same prompt, two servers differing only by the fix:

server rollout tool items command ran
current main none no
patched function_call + function_call_output yes

The efficient tier answered both turns (DeepSeek-V4-Pro), so this is not an escalation artefact.

Suggested fix

Decode the additional_tools item into LlmRequest::tools and consume it from the input; add a custom arm that advertises the tool to a chat upstream as a function taking one string, and turn the call back into a custom_tool_call on the response path (buffered and streaming). Both mappings can ride in the request's ProviderExtensions under prefixed keys, the way codex_namespaces already does for #384, so no provider-neutral type grows a Codex-specific field and encoding back to Responses stays lossless.

PR follows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions