From 93b5f5c229eed6ef2966ec870488a74d3065f572 Mon Sep 17 00:00:00 2001 From: starptech Date: Thu, 21 May 2026 11:05:47 +0200 Subject: [PATCH 1/4] Add tool-choice capability support --- packages/core/src/models-dev.ts | 1 + packages/opencode/src/config/provider.ts | 1 + packages/opencode/src/provider/provider.ts | 3 + packages/opencode/src/session/prompt.ts | 7 ++- .../opencode/test/provider/provider.test.ts | 40 ++++++++++++ packages/opencode/test/session/prompt.test.ts | 61 +++++++++++++++++++ .../test/tool/fixtures/models-api.json | 1 + packages/sdk/js/src/gen/types.gen.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 + 9 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 202943a2f2..63e0a6f70f 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -49,6 +49,7 @@ export const Model = Schema.Struct({ reasoning: Schema.Boolean, temperature: Schema.Boolean, tool_call: Schema.Boolean, + tool_choice_required: Schema.optional(Schema.Boolean), interleaved: Schema.optional( Schema.Union([ Schema.Literal(true), diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 5635512ced..19408c706d 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -11,6 +11,7 @@ export const Model = Schema.Struct({ reasoning: Schema.optional(Schema.Boolean), temperature: Schema.optional(Schema.Boolean), tool_call: Schema.optional(Schema.Boolean), + tool_choice_required: Schema.optional(Schema.Boolean), interleaved: Schema.optional( Schema.Union([ Schema.Literal(true), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2a778fd644..3492ef77a7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -867,6 +867,7 @@ const ProviderCapabilities = Schema.Struct({ reasoning: Schema.Boolean, attachment: Schema.Boolean, toolcall: Schema.Boolean, + toolChoiceRequired: Schema.optional(Schema.Boolean), input: ProviderModalities, output: ProviderModalities, interleaved: ProviderInterleaved, @@ -1068,6 +1069,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model reasoning: model.reasoning ?? false, attachment: model.attachment ?? false, toolcall: model.tool_call ?? true, + toolChoiceRequired: model.tool_choice_required ?? true, input: { text: model.modalities?.input?.includes("text") ?? false, audio: model.modalities?.input?.includes("audio") ?? false, @@ -1296,6 +1298,7 @@ export const layer = Layer.effect( reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, + toolChoiceRequired: model.tool_choice_required ?? existingModel?.capabilities.toolChoiceRequired ?? true, input: { text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c09ee86284..5f0d24419a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1436,7 +1436,12 @@ export const layer = Layer.effect( messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], tools, model, - toolChoice: format.type === "json_schema" ? "required" : undefined, + toolChoice: + format.type === "json_schema" + ? model.capabilities.toolChoiceRequired === false + ? "auto" + : "required" + : undefined, }) if (structured !== undefined) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 5215b094ab..1185863bcd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -757,6 +757,7 @@ test("model inherits properties from existing database model", async () => { const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.toolChoiceRequired).toBe(true) expect(model.capabilities.attachment).toBe(true) expect(model.limit.context).toBeGreaterThan(0) }, @@ -1376,6 +1377,44 @@ test("model defaults tool_call to true when not specified", async () => { }) }) +test("model can disable required tool_choice separately from tool_call", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "tool-choice": { + name: "Tool Choice Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + model: { + name: "Model", + tool_call: true, + tool_choice_required: false, + limit: { context: 4000, output: 1000 }, + }, + }, + options: { apiKey: "test" }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const providers = await list(ctx) + const model = providers[ProviderID.make("tool-choice")].models.model + expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.toolChoiceRequired).toBe(false) + }, + }) +}) + test("model headers are preserved", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -2018,6 +2057,7 @@ test("models.dev normalization fills required response fields", () => { expect(model.capabilities.reasoning).toBe(false) expect(model.capabilities.attachment).toBe(false) expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.toolChoiceRequired).toBe(true) expect(model.release_date).toBe("") }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ff9ded4d19..e3abeffd27 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -294,6 +294,30 @@ function providerCfg(url: string) { } } +function toolChoiceRequiredDisabledProviderCfg(url: string) { + return { + ...providerCfg(url), + provider: { + test: { + ...cfg.provider.test, + models: { + ...cfg.provider.test.models, + "tool-choice-disabled": { + ...cfg.provider.test.models["test-model"], + id: "tool-choice-disabled", + name: "Tool Choice Disabled", + tool_choice_required: false, + }, + }, + options: { + ...cfg.provider.test.options, + baseURL: url, + }, + }, + }, + } +} + const writeText = Effect.fn("test.writeText")(function* (file: string, text: string) { const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(file, text) @@ -482,6 +506,43 @@ it.instance("loop calls LLM and returns assistant message", () => }), ) +it.instance("structured output uses auto tool choice when model disables required tool choice", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(toolChoiceRequiredDisabledProviderCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.text("plain text") + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("tool-choice-disabled") }, + parts: [{ type: "text", text: "say hello" }], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + }, + }, + }) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(1) + expect(inputs[0].tool_choice).toBe("auto") + expect(inputs[0].tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ function: expect.objectContaining({ name: "StructuredOutput" }) }), + ]), + ) + }), +) + noLLMServer.instance( "prompt emits v2 prompted and synthetic events", () => diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 6302a951dd..b89caf7c74 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -58320,6 +58320,7 @@ "attachment": false, "reasoning": true, "tool_call": true, + "tool_choice_required": false, "interleaved": { "field": "reasoning_content" }, diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5e4fd89061..f2ba12309c 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1044,6 +1044,7 @@ export type ProviderConfig = { reasoning?: boolean temperature?: boolean tool_call?: boolean + tool_choice_required?: boolean cost?: { input: number output: number diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d0a82a5b0f..0e8749bacd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1049,6 +1049,7 @@ export type ProviderConfig = { reasoning?: boolean temperature?: boolean tool_call?: boolean + tool_choice_required?: boolean interleaved?: | true | { @@ -1313,6 +1314,7 @@ export type Model = { reasoning: boolean attachment: boolean toolcall: boolean + toolChoiceRequired?: boolean input: { text: boolean audio: boolean From 935163cf076ecd421591cf350b2c0ed61a7be617 Mon Sep 17 00:00:00 2001 From: starptech Date: Thu, 21 May 2026 11:17:44 +0200 Subject: [PATCH 2/4] Rename test provider helper --- packages/opencode/test/session/prompt.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index e3abeffd27..4a51d4fd36 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -294,7 +294,7 @@ function providerCfg(url: string) { } } -function toolChoiceRequiredDisabledProviderCfg(url: string) { +function providerCfgWithoutRequiredToolChoice(url: string) { return { ...providerCfg(url), provider: { @@ -508,7 +508,7 @@ it.instance("loop calls LLM and returns assistant message", () => it.instance("structured output uses auto tool choice when model disables required tool choice", () => Effect.gen(function* () { - const { llm } = yield* useServerConfig(toolChoiceRequiredDisabledProviderCfg) + const { llm } = yield* useServerConfig(providerCfgWithoutRequiredToolChoice) const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service const chat = yield* sessions.create({ From f11ff4022c78b625c720cc9b94bb953dc3d877ff Mon Sep 17 00:00:00 2001 From: starptech Date: Thu, 21 May 2026 11:29:50 +0200 Subject: [PATCH 3/4] Handle StructuredOutput tool refusal errors --- packages/opencode/test/session/prompt.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 4a51d4fd36..a106d5080a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -506,7 +506,7 @@ it.instance("loop calls LLM and returns assistant message", () => }), ) -it.instance("structured output uses auto tool choice when model disables required tool choice", () => +it.instance("structured output uses auto tool choice and errors when the model ignores the tool", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfgWithoutRequiredToolChoice) const prompt = yield* SessionPrompt.Service @@ -517,7 +517,7 @@ it.instance("structured output uses auto tool choice when model disables require }) yield* llm.text("plain text") - yield* prompt.prompt({ + const result = yield* prompt.prompt({ sessionID: chat.id, agent: "build", model: { providerID: ProviderID.make("test"), modelID: ModelID.make("tool-choice-disabled") }, @@ -540,6 +540,11 @@ it.instance("structured output uses auto tool choice when model disables require expect.objectContaining({ function: expect.objectContaining({ name: "StructuredOutput" }) }), ]), ) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.error?.name).toBe("StructuredOutputError") + expect(result.info.structured).toBeUndefined() + } }), ) From dcca3f76b411d3cc11df28b84f926e8464dde1f5 Mon Sep 17 00:00:00 2001 From: starptech Date: Thu, 21 May 2026 12:37:09 +0200 Subject: [PATCH 4/4] Enable required tool choice for structured output tests --- .../opencode/test/provider/provider.test.ts | 16 ++++++++++ packages/opencode/test/session/prompt.test.ts | 32 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 1185863bcd..982ffd3de6 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2048,16 +2048,32 @@ test("models.dev normalization fills required response fields", () => { output: 128_000, }, }, + "deepseek-reasoner": { + id: "deepseek-reasoner", + name: "DeepSeek Reasoner", + family: "deepseek", + tool_choice_required: false, + cost: { + input: 0.5, + output: 2, + }, + limit: { + context: 64_000, + output: 8_000, + }, + }, }, } as unknown as ModelsDev.Provider const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"] + const reasoner = Provider.fromModelsDevProvider(provider).models["deepseek-reasoner"] expect(model.api.url).toBe("") expect(model.capabilities.temperature).toBe(false) expect(model.capabilities.reasoning).toBe(false) expect(model.capabilities.attachment).toBe(false) expect(model.capabilities.toolcall).toBe(true) expect(model.capabilities.toolChoiceRequired).toBe(true) + expect(reasoner.capabilities.toolChoiceRequired).toBe(false) expect(model.release_date).toBe("") }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index a106d5080a..2d8e4b59a6 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -548,6 +548,38 @@ it.instance("structured output uses auto tool choice and errors when the model i }), ) +it.instance("structured output uses required tool choice by default", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.text("plain text") + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") }, + parts: [{ type: "text", text: "say hello" }], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + }, + }, + }) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(1) + expect(inputs[0].tool_choice).toBe("required") + }), +) + noLLMServer.instance( "prompt emits v2 prompted and synthetic events", () =>