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_responses → openai_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.
What happens
A Codex CLI session through a route that translates the wire format (
openai_responsesin,openai_chatupstream) 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_responses→openai_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 anopenai_chatclient.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-*.jsonlfor that session contains nofunction_call,custom_tool_call, or*_outputitem at all.Root cause
Two request-decoding gaps compose. Both are in
crates/switchyard-translation/src/codecs/responses/buffered.rs.1.
additional_toolsinput items are not decoded.Codex 0.146 declares its whole toolset in an input item, not in the request's
toolsarray. The captured inbound request has notoolskey at all — onlytool_choice: "auto"— andinput[0]is:{"type": "additional_tools", "role": "developer", "tools": [ ... ~24 KB ... ]}decode_responses_inputhas no arm for that type, so it reaches the catch-all and becomes aContentBlock::Unknowninside 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.
customtools are dropped.The item's
toolsarray holds ordinary Responses tool specs, and one of them is:{"type": "custom", "name": "exec", "description": "...", "format": {"type": "text"}}decode_responses_toolshas arms fornamespace,function, and a barename; everything else falls through topush_responses_id_tool, which requires anid, returnsfalse, and has its return value discarded. So acustomtool disappears with no diagnostic. The stringcustomappears nowhere undercrates/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:
mainfunction_call+function_call_outputThe efficient tier answered both turns (
DeepSeek-V4-Pro), so this is not an escalation artefact.Suggested fix
Decode the
additional_toolsitem intoLlmRequest::toolsand consume it from the input; add acustomarm that advertises the tool to a chat upstream as a function taking one string, and turn the call back into acustom_tool_callon the response path (buffered and streaming). Both mappings can ride in the request'sProviderExtensionsunder prefixed keys, the waycodex_namespacesalready does for #384, so no provider-neutral type grows a Codex-specific field and encoding back to Responses stays lossless.PR follows.