Repository navigation
Description
Summary
On the oa-compat (chat/completions) path of POST https://opencode.ai/zen/go/v1/responses, the
Responses-only tool types do not survive the conversion: the model never sees tool_search or
web_search, and the client gets a 200 with no signal that its tool-discovery layer is gone.
Function tools are unaffected. This is about the non-function types a Responses client declares
alongside them:
[
{"type": "function", "name": "exec_command", "description": "…", "parameters": {…}},
{"type": "tool_search", "execution": "client", "description": "# Tool discovery …", "parameters": {…}},
{"type": "web_search", "external_web_access": false}
]Same bug as #42090, which was closed unfixed
#42090 (2026-08-12 → closed 2026-10-15) reported the identical mechanism with a visible signature:
400 tools[N].function: missing field 'name' — "web_search tool rejected". Two PRs proposed the same
remedy, both closed without merging:
- fix(console): emit complete Responses-API SSE lifecycle for converted streams #40210 —
.filter(tool => tool.type === "function")before the mapping, plustool.name→
tool.function?.name. - fix(console): drop non-function tools when converting Responses to chat completions #42231 (
fix(console): drop non-function tools when converting Responses to chat completions) —
same filter, closed the same day it was opened.
Their patch comments describe the failure precisely:
Chat-completions upstreams only accept function tools. Responses-API requests also carry non-function
tools (e.g. web_search) that have no chat-completions equivalent; mapping them into a function tool
yields an undefinedfunction.name, which upstream serde rejects with
tools[N].function: missing field "name"and 400s the whole request
What changed since August is the observability, not the mechanism: the same breakage no longer 400s.
Today the compat path returns 200 and the client has no way to tell that its tools were dropped — which is
why the symptom outlived the closed issue.
Impact
tool_search is how a Codex client loads every deferred MCP tool: the client sends the namespaces as
deferred placeholders and expects the model to call tool_search so the real definitions are available on
the next turn. With it missing on this path:
- all deferred MCP servers become unreachable (local file search, GitHub, document control, …),
web_searchis gone as well, so the model falls back to probing the shell
(which curl wget …,env | grep -i search|api|key) and burns turns on it.
Evidence: identical body, two upstreams
One request body, model: "deepseek-flash", tools as above, stream: false, prompt "You must use the
available web search tool to look up the latest news about OpenAI Codex, then report one headline. Do not
answer from memory." The only difference is which upstream serves the id — store: false lands on the
compat upstream, store: true triggers the gateway's failover to the Responses-native provider (mechanism
in #53120):
| served by | output items |
search calls | shell fallback |
|---|---|---|---|
oa-compat upstream (store: false) |
reasoning + message + function_call(exec_command) |
0 | 1–2 |
native Responses upstream (store: true) |
reasoning + message + tool_search_call + tool_search_call |
2 | 0 |
Repeated: compat 2/2 with zero search calls, native 2/2 with tool_search_call. With an id that is always
on the native path, the model calls web_search directly — deepseek-v4-pro, store: false:
reasoning + web_search_call + message (2/2, plus a third run returning
reasoning + web_search_call + reasoning + message).
The client side is not the problem
Capturing Codex's outgoing request (by pointing its base_url at a local endpoint) shows tools ending
with {"type":"tool_search","execution":"client",…} and
{"type":"web_search","external_web_access":false} — declared and sent; nothing on the client drops them.
On the code reference
I originally cited zen/util/provider/openai-compatible.ts and the toOaCompatibleRequest mapping; that
was an abbreviated path and stale. Correcting it:
- the historical path is
packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
(alsoopenai.ts,provider.ts); all three 404 ondevtoday, - the current public route is
packages/console/app/src/routes/zen/go/v1/responses.ts, 6 lines ending in
proxyInference(input.request), - so the conversion is not verifiable from this repo, and the mapping I quoted is the pre-fix version —
it is visible in the diffs of fix(console): emit complete Responses-API SSE lifecycle for converted streams #40210 and fix(console): drop non-function tools when converting Responses to chat completions #42231, not necessarily in what runs today.
Treat the code as corroboration of the mechanism; the assertions in this issue are behavioural.
Reproduction
curl -sS https://opencode.ai/zen/go/v1/responses \
-H "Authorization: Bearer $GO_KEY" -H 'Content-Type: application/json' \
-H 'originator: codex_cli_rs' -H 'x-opencode-session: repro-tools-1' \
-d '{
"model": "deepseek-flash",
"store": false,
"stream": false,
"tool_choice": "auto",
"tools": [
{"type":"tool_search","execution":"client","description":"# Tool discovery",
"parameters":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"number"}},"required":["query"]}},
{"type":"web_search","external_web_access":false},
{"type":"function","name":"exec_command","description":"Run a shell command.",
"strict":false,"parameters":{"type":"object","properties":{"cmd":{"type":"string"}},"required":["cmd"]}}
],
"input": [{"role":"user","content":[{"type":"input_text",
"text":"You must use the available web search tool to look up the latest news about OpenAI Codex, then report one headline. Do not answer from memory."}]}]
}'With "store": false there is no tool_search_call / web_search_call in output; flip it to true
(identical body) and tool_search_call shows up.
Ask
Restated so it does not depend on reading the service code: on the oa-compat path, non-function tool
types should either survive as themselves or be dropped with a client-visible signal. Today the client
gets a successful response and no way to tell that its discovery layer is gone.
If an id cannot be served by a Responses-capable upstream, routing Responses clients to one that can would
also resolve it — for deepseek-flash / deepseek-v4.1-flash the native provider is still reachable via
failover, it is just not the default.
Happy to run any A/B against the two upstreams; the store-based failover in #53120 is a reliable way to
hit each one with an identical body.
Activity
opencode-agent commentedon Oct 20, 2026
Thanks for the detailed report and the A/B comparison. I couldn't reproduce this here: the Responses-to-chat-completions conversion happens in the hosted Go inference service, whose code isn't in this repository. On
dev,/zen/go/v1/responsesjust forwards the request to that service unchanged. Your curl also needs a real Go API key, which this sandbox doesn't have, so a maintainer with access to the inference service will need to confirm it.Related issues, most closely related first:
name). Back then it showed up as a 400tools[N].function: missing field 'name'. It was closed after 60 days without activity, not fixed.store-dependent failover you used to compare the two paths./zen/go/v1/responses):deepseek-flashnever returnsphaseon assistant messages, which breaks Codex desktop's turn collapsing #57372:phaselost on the same compat path.rngt713-art commentedon Oct 20, 2026
Thanks — and yes, #42090 is the same root cause, so I've rewritten the issue body to lead with it. Three corrections, all now reflected there:
1. My file paths were stale. The conversion code I quoted lives historically at
packages/console/app/src/routes/zen/util/provider/openai-compatible.ts(I abbreviated the prefix). Allthree files (
openai-compatible.ts,openai.ts,provider.ts) now 404 ondev, and the current publicroute is just
packages/console/app/src/routes/zen/go/v1/responses.ts(6 lines,proxyInference). Soyou're right that today's conversion isn't verifiable from this repo. The function name is
toOaCompatibleRequest, and the snippet I quoted is the pre-fix version — visible in the diffs of #40210and #42231.
2. Same root cause as #42090, which was closed unfixed. #42090 (Aug 12 → closed Oct 15) reported the
identical mechanism with a visible signature:
tools[N].function: missing field 'name', because thenameless rewrite was rejected upstream. #40210 and #42231 both proposed the same remedy
(
filter(tool.type === "function")before mapping); #42231 was closed the same day. What changed sinceAugust is the observability, not the mechanism: the same breakage no longer 400s, it silently removes the
client's tool-discovery layer.
3. The client side, reproduced. Capturing Codex's outgoing request (base_url pointed at a local
endpoint) shows
toolsending with{"type":"tool_search","execution":"client",…}and{"type":"web_search","external_web_access":false}— declared and sent; nothing on the client drops them.Ask, restated so it doesn't depend on reading the service code: on the
oa-compatpath, non-functiontool types should either survive as themselves or be dropped with a client-visible signal. Today the client
gets a successful response and no way to tell that its discovery layer is gone.
On the key: agreed that the curl needs a real Go key. The A/B in the body was run with a working Go
subscription, so if it would help, I can re-run it on request and paste raw
outputarrays for both paths— the
store-based failover in #53120 gives a reliable way to hit each upstream with an identical body.rngt713-art commentedon Oct 20, 2026
Relevant upstream development: DeepSeek published
deepseek-ai/deepseek-recipe0.2.0 on2026-10-20 — the official multi-protocol conversion library ("uniformly convert API requests in
different formats into the Conversation format", covering Messages / Chat Completions / Responses,
with
server-rsandserver-pyreference servers). So a chat-only upstream is now a temporary statefor DeepSeek models: anyone serving them can speak Responses without hand-rolling an adapter like the
oa-compatone.Two things in it bear directly on this issue:
V4.1 supports deferred tool loading in Messages and client-executed
tool_searchin Responses.ToolDefinitiongainsdefer_loading…" — andsrc/protocol/openai/responses/request/tools.rsparses
ResponsesTool::ToolSearch { execution: Client }together with theToolSearchOutputreplay side.
WebSearchBehavior { Drop (default), Reject }insrc/request/options.rsdecides the fate ofweb_searchdeclarations (and the tool choices naming them); the Python reference server defaultsto dropping Responses
web_searchand rejecting Messagesweb_search, and it is configurable inboth directions. So the canonical implementation's answer to "what should a converter do with
these" is a documented, configurable policy — which is what the Ask above is after (survive, or be
dropped with a signal).
Scoping caveat, so this isn't over-read: the library is DeepSeek-specific in the model dimension
(
ModelVersionis onlyDeepseekV4Pro/DeepseekV41Flash, each with its own prompt encoder). Itstandardises the protocol layer for the DeepSeek family, not for arbitrary models.
Suggested shortest path, if it helps: this looks like a routing change rather than a conversion
change. For
deepseek-flash/deepseek-v4.1-flasha Responses-capable provider is still reachable(the
store-based failover in #53120 lands on one), so pointing the provider entry'sformatat theResponses target — or re-ordering so it is the top priority again — resolves this without touching the
conversion layer. Adopting the library would remove the class of problem entirely.