anomalyco / anomalyco/opencode

Zen gateway: forced named tool_choice not converted to Responses API shape (400 on gpt-* cross-family, silently dropped on ingress)

Open
#39,695 0 comments 0 reactions 1 assignee View on GitHub

@StarpTech is already working on this.

Since Jul 30, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

The Zen gateway's OpenAI Responses API converter (packages/console/app/src/routes/zen/util/provider/openai.ts) uses the Chat Completions shape for a forced named tool_choice on both ingress and egress. The Responses API uses a flat { "type": "function", "name": "..." }, not the nested { "type": "function", "function": { "name": "..." } }.

Two symptoms, depending on direction:

  • Egress (toOpenaiRequest): the nested form is sent to OpenAI, which rejects it → HTTP 400.
  • Ingress (fromOpenaiRequest): a correctly-formed flat tool_choice doesn't match, falls through to undefined, and is silently dropped.

This only affects cross-family requests, because createBodyConverter short-circuits with if (from === to) return body (provider.ts:184). Same-family requests pass through untouched and are fine.

Reproduction

Empirically confirmed:

curl https://opencode.ai/zen/v1/chat/completions \
  -H "Authorization: Bearer $OPENCODE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.5",
    "messages": [{"role":"user","content":"Extract the name: Ada Lovelace"}],
    "tools": [{"type":"function","function":{"name":"extract","parameters":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}],
    "tool_choice": {"type":"function","function":{"name":"extract"}}
  }'

→ HTTP 400 with an empty assistant message in a chat.completion-shaped body.

The same request with "tool_choice": "auto" or "required" succeeds, because those are bare strings and are valid in both dialects. The same request against claude-sonnet-5 also succeeds, because the Anthropic converter maps the forced form correctly.

Two further affected paths, derived from the code but not separately tested:

  • POST /zen/v1/messages with a gpt-* model and tool_choice: {"type":"tool","name":"x"} → same 400. This is the Claude-Code-against-Zen path, probably the most common real-world hit.
  • POST /zen/v1/responses with a claude-* model and the correct flat tool_choice: {"type":"function","name":"x"} → silently dropped, so the model is free to not call the tool.
Root cause

The canonical internal shape is the Chat Completions nested form (provider.ts:114):

tool_choice?: "auto" | "required" | { type: "function"; function: { name: string } }

Every other converter honours that contract:

  • fromAnthropicRequest (anthropic.ts:293-300) normalizes {type:"tool",name} → nested. Correct.
  • toAnthropicRequest (anthropic.ts:437-444) denormalizes nested → {type:"tool",name}. Correct.
  • fromOaCompatibleRequest passes body.tool_choice through, already nested. Correct.

Only the Responses converter is wrong, in both directions.

toOpenaiRequest (openai.ts:286-294) emits the nested form into a Responses body:

const tool_choice = (() => {
  if (!tcIn) return undefined
  if (tcIn === "auto") return "auto"
  if (tcIn === "required") return "required"
  if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
    return { type: "function", function: { name: (tcIn as any).function.name } }  // <-- Chat Completions shape
  return undefined
})()

fromOpenaiRequest (openai.ts:169-178) reads the nested form out of a Responses body, where it can never appear:

if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
  return { type: "function" as const, function: { name: (tcIn as any).function.name } }
return undefined  // <-- a correct flat tool_choice lands here

That this is an oversight rather than intentional is clear from the lines immediately below the egress case: in the same function, the tools array is correctly flattened to Responses shape (openai.ts:296-310):

return {
  type: "function",
  name: tool.function?.name,          // flattened
  description: tool.function?.description,
  parameters: tool.function?.parameters,
  strict: tool.function?.strict,
}

tools got flattened; tool_choice did not.

Suggested fix

In toOpenaiRequest, emit the flat form:

if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
  return { type: "function", name: (tcIn as any).function.name }

In fromOpenaiRequest, accept the flat form and normalize it to the canonical nested form:

if ((tcIn as any).type === "function" && typeof (tcIn as any).name === "string")
  return { type: "function" as const, function: { name: (tcIn as any).name } }

Accepting both shapes on ingress would be harmless and more forgiving, if you'd prefer.

Happy to open a PR if useful.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.