Repository navigation
Description
What version of the Codex App are you using (From “About Codex” dialog)?
26.901.51231 (macOS). Bundled Codex core: codex-cli 0.153.4.
What subscription do you have?
No ChatGPT subscription for inference on this profile — the app runs against a third-party Responses-compatible provider: DeepSeek (api.deepseek.com, wire API = responses), model deepseek-flash, API key auth.
What platform is your computer?
macOS (Apple silicon), Darwin 25.x.
What issue are you seeing?
tool_search is unusable on this provider configuration: as soon as the model emits a tool_search call, the follow-up request is rejected with a strict-schema 400, so the turn (and every retry of it) fails before any tool result is produced.
{"error":{"message":"Failed to deserialize the JSON body into the target type: input: missing field `call_id` at line 1 column 73159","type":"invalid_request_error","param":null,"code":"invalid_request_error"}}
Four independent reproductions, all failing in well under one second, with a near-constant offset in the serialized body (which suggests the malformed item sits at a fixed position near the tool_search_call entry):
| # | prompt | reported column |
|---|---|---|
| 1 | ask for two tool_search calls in one turn |
73278 |
| 2 | ask for two tool_search calls in one turn (no plugin mention) |
73159 |
| 3 | ask for a single tool_search call |
72894 |
| 4 | ask for a single tool_search call, explicitly no other tool use |
73061 |
The malformed item never reaches the local rollout: the thread's persisted history contains the user/developer messages and the task_started/task_complete events only, i.e. the item with the missing call_id exists on the wire request that the provider rejects. This matches the client-side nature of the other call_id reports listed below.
This is distinct from the existing call_id issues: all of the ones I could find involve function_call_output injected by automations, or normal-thread resume:
- Automations (heartbeat & cron) inject a
function_call_outputwithoutcall_id, permanently breaking the target session (400 missing field call_id) #44723, [Windows] Thread heartbeat automation injects a function_call_output without call_id and permanently breaks the thread on strict Responses API providers #44519, Codex Desktop automation + DeepSeek Responses: standalone automation_update output lacks call_id (HTTP 400) #41690, Missing \call_id\ on injected standalone \unction_call_output\ also breaks normal thread resume / cross-thread send under third-party Responses providers #42067, responses: function_call_output can be emitted without call_id and fails strict upstreams (400) #42088, Codex Desktop + Kimi provider: all injected inputs (heartbeat/cron automations, thread delegation) fail with "tool_call_id is not found" and permanently poison the thread #43515 — automations / standalonefunction_call_output/ resume paths - tool_search aborts with 'unsupported payload' when using non-native API endpoints (Ollama/Bifrost) #20574 —
tool_searchon non-native endpoints fails differently (unsupported payload, Ollama/Bifrost) - Codex CLI persists malformed tool_search_call arguments, causing 400 property_name_above_max_length on resume #26379 — malformed
tool_search_callarguments on resume (different field)
I could not find one that covers "model calls tool_search normally, no automation involved, on a custom Responses provider".
What steps can reproduce the bug?
- Configure a custom provider that validates the Responses schema strictly, e.g. DeepSeek with
wire_api = "responses". - Start a fresh thread (project or projectless) and send a minimal prompt that leads the model to call
tool_search, e.g.:
Call tool_search once with query "libtv doctor target_project list_projects" and report the namespace you get back. Do not use any other tool. - The first sampling request completes, the model returns the
tool_searchcall, and the follow-up request immediately fails with the error above. - Retrying the turn reproduces it identically.
Session ids (sanitized): 01a08fb4-e274-7d21-aa86-6c2e349a083a, 01a08fb5-0b4d-7371-9d92-c8fc2866d9b3, 01a08fb5-3196-7e70-abf0-44ef611bcce4, 01a08fb7-1b7d-7cd1-85fa-3ebcf65ad83a.
What is the expected behavior?
The tool_search call is serialized with a call_id (as required by the Responses schema), the search result is returned to the model, and the turn proceeds. Alternatively, if tool_search cannot be supported on this provider configuration, the feature should be disabled/omitted instead of emitting a request that can never be accepted.
What is the actual behavior?
Every thread that calls tool_search dies on the first follow-up request with missing field call_id. Because the failure happens during request construction, no tool_search_output is ever produced and no workaround exists inside the thread; starting a new thread only helps until a tool_search call happens again.
Additional information
- Same Core version with a different provider (
deepseek-flashreplaced by a ChatGPT-side model in the same app) servedtool_searchsuccessfully earlier the same day in this install, so the item shape that strict providers reject appears to be provider-specific. - Local feature flags report
tool_searchandtool_search_always_defer_mcp_toolsasremovedwhile both are clearly still active in this build, in case that is relevant to routing this report. - Happy to attach the four rollout JSONL files (sanitized) or run an instrumented build if that helps isolate where the
call_idis dropped.
Activity
argszero commentedon Sep 11, 2026
Thanks for the careful write-up — the observation that the item is on the wire but never in the rollout is the useful part. One code-level detail narrows this considerably, and I think it changes which item you should be looking at.
In this build, exactly one item type can serialize with the
call_idkey absent.ResponseItemis a plain internally-tagged serde enum (codex-rs/protocol/src/models.rs:978,#[serde(tag = "type", rename_all = "snake_case")]at:977), so every field is emitted unless it carriesskip_serializing_if. Checking each variant that has acall_id, in the version you report (codex-cli 0.153.4):call_iddeclarationfunction_call_outputOption<String>(:1086)#[serde(default, skip_serializing_if = "Option::is_none")](:1085)tool_search_callOption<String>(:1064)tool_search_outputOption<String>(:1140)custom_tool_call_outputString(:1125)function_call,custom_tool_callStringCurrent
mainis the same shape, shifted (FunctionCallOutputblock at:1101, skip attribute oncall_idat:1106;ToolSearchCall.call_idat:1085,ToolSearchOutput.call_idat:1161, both without attributes).Two consequences:
tool_search_callortool_search_outputwithout acall_idserializes as"call_id": null— the key is present. Your provider's error ismissing field \call_id`, which a serde deserializer raises only when the key is absent; a present-but-null value producesinvalid type: null` instead. So neither tool_search item can produce this exact message.function_call_outputis the only variant that omits the key entirely, and only when it has nocall_id.Although the reported column sits near your
tool_search_callentry, the item that can actually yieldmissing field \call_id`is almost certainly a **function_call_output** — the shape reported in the standalone-output family (#42088, #42067, #41690, #44723, #44519):{"type":"function_call_output","id":"fco_…","name":…,"namespace":…,"output":…}with nocall_id`. The column is a weak locator; the payload is the thing to identify.Why such an item is never repaired
The only production path that builds a
FunctionCallOutputwithcall_id: Noneis standalone-output injection —turn/startwithtoolOutput(codex-rs/app-server/src/request_processors/turn_processor.rs:573-582;:593-602onmain) andthread/inject_items. Both are intentional and documented. Nothing downstream repairs them:Prompt::get_formatted_input_for_request(codex-rs/core/src/client_common.rs:56-65) only strips image details.ResponseItem::from(ResponseInputItem::FunctionCallOutput { .. })always setscall_id: Some(call_id)(models.rs:1838), so this shape can only originate from the injection path.build_responses_request's provider sanitization (codex-rs/core/src/client.rs:976-987;:833onmain) only clearsinternal_chat_message_metadata_passthroughandencrypted_function_args. Andis_openai()is literallyname == OPENAI_PROVIDER_NAME(codex-rs/model-provider-info/src/lib.rs:468-470;:546-548onmain) — so a customwire_api = "responses"provider receives the unmatchable item verbatim and rejects the whole request.Worth noting: the repo's own mock deliberately permits this shape —
validate_request_body_invariantsaccepts afunction_call_outputwith an emptycall_idas long as it has a non-emptyname(codex-rs/core/tests/common/responses.rs:1666,gather_output_idsat:1712-1731;:1506and:1552-1568onmain). Mock and strict upstreams disagree about the contract, which is how this survives the test suite.The decisive datum, if you can get it
Capture the outgoing body once (point
base_urlat a local echo proxy, or enable request-body trace logging) and inspect theinput[]entry at the failing column:function_call_outputwith nocall_idkey, this is the mechanism above, and the fix belongs at request assembly (drop or repair unmatched standalone outputs when the provider requires pairing) or at the injection path — not intool_search.tool_search_call/tool_search_output, then the above is incomplete and I'd want to see the raw item, because nothing in the tool_search path can emit an absentcall_idkey. The tool_search item codex emits is paired by construction:ToolSearchOutput::to_response_item(call_id, …)takes the id from the call it answers (codex-rs/core/src/tools/context.rs:206-221;:216-231onmain).Since your follow-up request is rejected before any output is produced, the rollout containing only the user/developer messages and
task_started/task_completeis consistent with a failure during request assembly, before the assistant's items are persisted — so the rollout won't tell us which item it was. The outgoing body will.I can't reproduce it locally (no strict
/responsesupstream or DeepSeek key here), so the raw item is the missing piece. If you'd rather not attach a full body, the singleinput[]entry at the reported column is enough.