anomalyco / anomalyco/opencode
Zen gateway: forced named tool_choice not converted to Responses API shape (400 on gpt-* cross-family, silently dropped on ingress)
@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 flattool_choicedoesn't match, falls through toundefined, 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/messageswith agpt-*model andtool_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/responseswith aclaude-*model and the correct flattool_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.fromOaCompatibleRequestpassesbody.tool_choicethrough, 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.