Skip to content

Go /v1/responses: the oa-compat path turns tool_search / web_search into nameless function tools, so deferred MCP tools and web search are unreachable #57397

Open

Description

@rngt713-art

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:

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 undefined function.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_search is 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:

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

  1. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    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/responses just 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:

  2. rngt713-art commented on Oct 20, 2026

    @rngt713-art
    Author

    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). All
    three files (openai-compatible.ts, openai.ts, provider.ts) now 404 on dev, and the current public
    route is just packages/console/app/src/routes/zen/go/v1/responses.ts (6 lines, proxyInference). So
    you'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 #40210
    and #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 the
    nameless 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 since
    August 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 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.

    Ask, restated so it doesn't 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.

    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 output arrays for both paths
    — the store-based failover in #53120 gives a reliable way to hit each upstream with an identical body.

  3. rngt713-art commented on Oct 20, 2026

    @rngt713-art
    Author

    Relevant upstream development: DeepSeek published deepseek-ai/deepseek-recipe 0.2.0 on
    2026-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-rs and server-py reference servers). So a chat-only upstream is now a temporary state
    for DeepSeek models: anyone serving them can speak Responses without hand-rolling an adapter like the
    oa-compat one.

    Two things in it bear directly on this issue:

    • 0.2.0 adds exactly this feature. From the changelog: "Deferred tool loading and tool search.
      V4.1 supports deferred tool loading in Messages and client-executed tool_search in Responses.
      ToolDefinition gains defer_loading…"
      — and src/protocol/openai/responses/request/tools.rs
      parses ResponsesTool::ToolSearch { execution: Client } together with the ToolSearchOutput
      replay side.
    • Non-function tool types are treated as a first-class decision, not an accident.
      WebSearchBehavior { Drop (default), Reject } in src/request/options.rs decides the fate of
      web_search declarations (and the tool choices naming them); the Python reference server defaults
      to dropping Responses web_search and rejecting Messages web_search, and it is configurable in
      both 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
    (ModelVersion is only DeepseekV4Pro / DeepseekV41Flash, each with its own prompt encoder). It
    standardises 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-flash a Responses-capable provider is still reachable
    (the store-based failover in #53120 lands on one), so pointing the provider entry's format at the
    Responses 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.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions