From 162b098a1e33990ad253164d97458f81731690c5 Mon Sep 17 00:00:00 2001
From: anandlo <157297269+anandlo@users.noreply.github.com>
Date: Sun, 9 Aug 2026 04:25:31 -0400
Subject: [PATCH] fix(session): don't force tool_choice required for reasoning
models
Structured-output steps (json_schema format) forced toolChoice: "required",
which DeepSeek and other thinking-mode providers reject with HTTP 400
("Thinking mode does not support this tool_choice"). Reasoning-capable
models now use the provider default tool choice; the StructuredOutput tool
plus autoRetry still drive extraction. Non-reasoning models keep the forced
"required" behavior.
---
packages/opencode/src/session/prompt.ts | 4 +-
.../opencode/test/lib/scripted-llm-server.ts | 9 +-
.../test/session/tool-choice-thinking.test.ts | 134 ++++++++++++++++++
3 files changed, 143 insertions(+), 4 deletions(-)
create mode 100644 packages/opencode/test/session/tool-choice-thinking.test.ts
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 049b1ad0e..123ad8098 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -3845,7 +3845,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
tools,
activeTools,
model,
- toolChoice: isLastStep ? "none" : format.type === "json_schema" ? "required" : undefined,
+ toolChoice: isLastStep ? "none" : format.type === "json_schema" && !model.capabilities.reasoning ? "required" : undefined,
agentID: lastUser.agentID,
})
.pipe(
@@ -4010,7 +4010,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
tools,
activeTools,
model,
- toolChoice: isLastStep ? ("none" as const) : format.type === "json_schema" ? ("required" as const) : undefined,
+ toolChoice: isLastStep ? ("none" as const) : format.type === "json_schema" && !model.capabilities.reasoning ? ("required" as const) : undefined,
agentID: lastUser.agentID,
}
diff --git a/packages/opencode/test/lib/scripted-llm-server.ts b/packages/opencode/test/lib/scripted-llm-server.ts
index ebc989054..5ccfd7645 100644
--- a/packages/opencode/test/lib/scripted-llm-server.ts
+++ b/packages/opencode/test/lib/scripted-llm-server.ts
@@ -15,6 +15,8 @@
export interface LLMCapture {
/** Raw messages array from the OpenAI-compatible request body */
messages: Array<{ role: string; content: unknown }>
+ /** Wire-level tool_choice: "required" | "auto" | "none" | object | undefined */
+ tool_choice?: string | { type: string; function?: { name: string } } | undefined
}
type ScriptedResponse = {
@@ -223,8 +225,11 @@ export function startScriptedLLMServer(responses: ScriptedResponse[]): ScriptedL
return new Response("not found", { status: 404 })
}
- const body = (await req.json()) as { messages: Array<{ role: string; content: unknown }> }
- captures.push({ messages: body.messages })
+ const body = (await req.json()) as {
+ messages: Array<{ role: string; content: unknown }>
+ tool_choice?: string | { type: string; function?: { name: string } } | undefined
+ }
+ captures.push({ messages: body.messages, tool_choice: body.tool_choice })
const response = responses[Math.min(callIdx, responses.length - 1)]
callIdx++
diff --git a/packages/opencode/test/session/tool-choice-thinking.test.ts b/packages/opencode/test/session/tool-choice-thinking.test.ts
new file mode 100644
index 000000000..679fbc984
--- /dev/null
+++ b/packages/opencode/test/session/tool-choice-thinking.test.ts
@@ -0,0 +1,134 @@
+/**
+ * Integration tests: thinking/reasoning models (config `reasoning: true`,
+ * e.g. DeepSeek v4 via zen-free) reject `tool_choice: "required"` with a
+ * provider 400 ("Thinking mode does not support this tool_choice"). Under
+ * `json_schema` output the loop must therefore NOT force "required" for
+ * reasoning-capable models; non-thinking models keep the forced "required"
+ * that makes structured extraction deterministic.
+ *
+ * Driven through a real Session.prompt(...) against the scripted HTTP LLM stub.
+ */
+
+import path from "path"
+import { afterEach, describe, expect, test } from "bun:test"
+import { Effect, Layer } from "effect"
+import { Instance } from "../../src/project/instance"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { Log } from "../../src/util"
+import { tmpdir } from "../fixture/fixture"
+import { startScriptedLLMServer, toolCallResponse } from "../lib/scripted-llm-server"
+
+void Log.init({ print: false })
+
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
+function run(fx: Effect.Effect) {
+ return Effect.runPromise(
+ fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
+ )
+}
+
+function writeConfig(dir: string, origin: string, model: string) {
+ return Bun.write(
+ path.join(dir, "mimocode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ enabled_providers: ["alibaba"],
+ provider: {
+ alibaba: {
+ options: { apiKey: "test-key", baseURL: `${origin}/v1` },
+ models: {
+ "qa-thinking": { reasoning: true },
+ "qa-plain": {},
+ },
+ },
+ },
+ agent: { build: { model: `alibaba/${model}` } },
+ }),
+ )
+}
+
+const schema = {
+ type: "object",
+ properties: { answer: { type: "number" } },
+ required: ["answer"],
+}
+
+const structuredResponse = toolCallResponse({
+ id: "call_1",
+ name: "StructuredOutput",
+ args: JSON.stringify({ answer: 4 }),
+})
+
+describe("tool_choice with thinking models (integration)", () => {
+ test("thinking model: json_schema format must not force tool_choice required", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const stub = startScriptedLLMServer([{ lines: structuredResponse }])
+ try {
+ await writeConfig(tmp.path, stub.origin, "qa-thinking")
+ await Instance.provide({
+ directory: tmp.path,
+ fn: () =>
+ run(
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const prompt = yield* SessionPrompt.Service
+ const session = yield* sessions.create({ title: "tool-choice-thinking" })
+ const result = yield* prompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ parts: [{ type: "text", text: "What is 2 + 2?" }],
+ format: { type: "json_schema", schema, retryCount: 0 },
+ })
+ expect(stub.captures.length).toBe(1)
+ expect(result.info.role).toBe("assistant")
+ if (result.info.role === "assistant") {
+ expect(result.info.error).toBeUndefined()
+ expect((result.info.structured as any).answer).toBe(4)
+ }
+ expect(stub.captures[0].tool_choice).not.toBe("required")
+ }),
+ ),
+ })
+ } finally {
+ await stub.stop()
+ }
+ })
+
+ test("non-thinking model keeps tool_choice required", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const stub = startScriptedLLMServer([{ lines: structuredResponse }])
+ try {
+ await writeConfig(tmp.path, stub.origin, "qa-plain")
+ await Instance.provide({
+ directory: tmp.path,
+ fn: () =>
+ run(
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const prompt = yield* SessionPrompt.Service
+ const session = yield* sessions.create({ title: "tool-choice-plain" })
+ const result = yield* prompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ parts: [{ type: "text", text: "What is 2 + 2?" }],
+ format: { type: "json_schema", schema, retryCount: 0 },
+ })
+ expect(stub.captures.length).toBe(1)
+ expect(result.info.role).toBe("assistant")
+ if (result.info.role === "assistant") {
+ expect(result.info.error).toBeUndefined()
+ expect((result.info.structured as any).answer).toBe(4)
+ }
+ expect(stub.captures[0].tool_choice).toBe("required")
+ }),
+ ),
+ })
+ } finally {
+ await stub.stop()
+ }
+ })
+})
\ No newline at end of file