diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 202943a2f..63e0a6f70 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 5635512ce..19408c706 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 2a778fd64..3492ef77a 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 c09ee8628..5f0d24419 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 5215b094a..1185863bc 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 ff9ded4d1..e3abeffd2 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 6302a951d..b89caf7c7 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 5e4fd8906..f2ba12309 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 d0a82a5b0..0e8749bac 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