From 6d2a4a4791017ab838f89d90fe483654a38b5267 Mon Sep 17 00:00:00 2001 From: share121 Date: Thu, 1 Oct 2026 16:54:08 +0800 Subject: [PATCH 1/2] fix(ai): force tool calls so tag generation stops returning prose The OpenAI chat-completion provider hard-coded tool_choice: "auto", which lets a model answer a tool-requesting prompt in plain text. deepseek-flash and similar models do exactly that: on the tag-migration prompt the assistant reply is a comma-separated list with no tool_calls, so executeToolCall returns success:false and handleRunTagMigrationBatch silently skips the tag write. The migration then reports success while memories stay untagged and the modal reappears on every load (#303). Default tool_choice to "required" so the model must emit the tool call, with an opt-out (forceToolChoice:false) for providers that reject it. Also align the migration prompt with the tool it is meant to call instead of asking for a comma-separated list. --- src/services/ai/providers/base-provider.ts | 8 ++ .../ai/providers/openai-chat-completion.ts | 4 +- src/services/api-handlers.ts | 2 +- tests/openai-chat-completion-provider.test.ts | 76 ++++++++++++++++++- tests/orcarouter-provider.test.ts | 2 +- 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/services/ai/providers/base-provider.ts b/src/services/ai/providers/base-provider.ts index 3d30a533..e01f7eba 100644 --- a/src/services/ai/providers/base-provider.ts +++ b/src/services/ai/providers/base-provider.ts @@ -14,6 +14,14 @@ export interface ProviderConfig { maxTokens?: number; memoryTemperature?: number | false; extraParams?: Record; + /** + * Force the model to emit a tool call instead of free text. Defaults to true + * on chat-completion providers: prompts already demand a tool call, and + * `tool_choice: "auto"` lets some models answer in prose, which silently + * drops the structured result (see tag migration). Set to false to opt out + * for providers that reject `tool_choice: "required"`. + */ + forceToolChoice?: boolean; } const PROTECTED_KEYS = new Set([ diff --git a/src/services/ai/providers/openai-chat-completion.ts b/src/services/ai/providers/openai-chat-completion.ts index ab4f2444..1f801e2e 100644 --- a/src/services/ai/providers/openai-chat-completion.ts +++ b/src/services/ai/providers/openai-chat-completion.ts @@ -38,7 +38,7 @@ type RequestBody = { model: string; messages: APIMessage[]; tools: ChatCompletionTool[]; - tool_choice: "auto"; + tool_choice: "auto" | "required"; temperature?: number; [key: string]: unknown; }; @@ -264,7 +264,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { model: this.resolveModel(), messages, tools: [toolSchema], - tool_choice: "auto", + tool_choice: this.config.forceToolChoice === false ? "auto" : "required", }; if (this.config.memoryTemperature !== false) { diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index 730d8353..aee9bebc 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -1495,7 +1495,7 @@ export async function handleRunTagMigrationBatch( : []; if (currentTags.length === 0) { - const prompt = `Generate 2-4 short technical tags for this memory content:\n\n${m.content}\n\nReturn ONLY a comma-separated list of tags.`; + const prompt = `Generate 2-4 short technical tags for this memory content. Call the save_tags tool with a "tags" array.\n\n${m.content}`; const result = await provider.executeToolCall( "You are a technical tagger.", prompt, diff --git a/tests/openai-chat-completion-provider.test.ts b/tests/openai-chat-completion-provider.test.ts index 1e3df309..692b1b5c 100644 --- a/tests/openai-chat-completion-provider.test.ts +++ b/tests/openai-chat-completion-provider.test.ts @@ -255,7 +255,7 @@ describe("OpenAIChatCompletionProvider", () => { expect(capturedBody?.model).toBe("gpt-4o-mini"); expect(Array.isArray(capturedBody?.messages)).toBe(true); expect(Array.isArray(capturedBody?.tools)).toBe(true); - expect(capturedBody?.tool_choice).toBe("auto"); + expect(capturedBody?.tool_choice).toBe("required"); }); it("includes temperature 0.3 by default", async () => { @@ -439,4 +439,78 @@ describe("OpenAIChatCompletionProvider", () => { expect(result.success).toBe(false); }); + + it("defaults to tool_choice=required so models cannot answer with prose", async () => { + let sentBody: any; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBody = JSON.parse(String(init?.body)); + return { + ok: true, + status: 200, + statusText: "OK", + text: async () => "", + json: async () => ({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "save_memories", arguments: "{}" }, + }, + ], + }, + }, + ], + }), + } as Response; + }) as typeof fetch; + + await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(sentBody.tool_choice).toBe("required"); + }); + + it("honors forceToolChoice=false to fall back to tool_choice=auto", async () => { + let sentBody: any; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBody = JSON.parse(String(init?.body)); + return { + ok: true, + status: 200, + statusText: "OK", + text: async () => "", + json: async () => ({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "save_memories", arguments: "{}" }, + }, + ], + }, + }, + ], + }), + } as Response; + }) as typeof fetch; + + await makeProvider({ + apiUrl: "https://api.openai.com/v1", + forceToolChoice: false, + }).executeToolCall("system", "user", toolSchema, "session-id"); + + expect(sentBody.tool_choice).toBe("auto"); + }); }); diff --git a/tests/orcarouter-provider.test.ts b/tests/orcarouter-provider.test.ts index 38de5bb9..6ea38a5a 100644 --- a/tests/orcarouter-provider.test.ts +++ b/tests/orcarouter-provider.test.ts @@ -157,7 +157,7 @@ describe("OrcaRouterProvider", () => { await provider.executeToolCall("system", "user", toolSchema, "session-id"); expect(capturedBody?.model).toBe(ORCAROUTER_DEFAULT_MODEL); - expect(capturedBody?.tool_choice).toBe("auto"); + expect(capturedBody?.tool_choice).toBe("required"); expect(Array.isArray(capturedBody?.messages)).toBe(true); expect(Array.isArray(capturedBody?.tools)).toBe(true); }); From 48a22aa464728e1c70b71f57ec9eb709f64c42dd Mon Sep 17 00:00:00 2001 From: EyJunge1 <149941075+EyJunge1@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:33:27 +0200 Subject: [PATCH 2/2] fix(ai): wire forceToolChoice into user config with 400 hint Make the tool_choice required opt-out reachable from opencode.json and surface a clear config hint when thinking models reject forced tool calls. --- README.md | 1 + src/config.ts | 13 +++++++++++++ src/services/ai/provider-config.ts | 2 ++ .../ai/providers/openai-chat-completion.ts | 9 +++++++++ tests/ai-provider-config.test.ts | 13 +++++++++++++ tests/openai-chat-completion-provider.test.ts | 19 +++++++++++++++++++ 6 files changed, 57 insertions(+) diff --git a/README.md b/README.md index 60fa86a3..f23b747d 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,7 @@ Troubleshooting: - If auto-capture reports that a provider is not connected, confirm the provider name with `opencode providers list` and configure that provider in opencode first. - If a proxy or custom provider returns plain text instead of structured/tool output, choose another model/provider or use one of the manual provider modes above. - For models that reject `temperature`, add `"memoryTemperature": false` when using manual API configuration. +- For models that reject forced tool calls (`tool_choice: "required"`, e.g. some thinking modes), add `"forceToolChoice": false` when using `openai-chat` / `orcarouter`. - **Unsupported platforms:** Intel Mac (`darwin/x64`) is not supported — `@tursodatabase/database` and fixed `onnxruntime-node` releases (pinned `1.30.0`) ship no x64 native binding. Use Apple Silicon, Linux, or Windows, or a remote embedding endpoint via `embeddingApiUrl` + `embeddingApiKey`. MLX is not supported. ## Public Subpath Exports diff --git a/src/config.ts b/src/config.ts index 32741aa7..5a9226d9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,6 +49,12 @@ interface OpenCodeMemConfig { memoryApiUrl?: string; memoryApiKey?: string; memoryTemperature?: number | false; + /** + * Force chat-completion providers to send `tool_choice: "required"`. + * Defaults to true when unset. Set to false for models that reject forced + * tool choice (e.g. some thinking/reasoning modes). + */ + forceToolChoice?: boolean; memoryExtraParams?: Record; opencodeProvider?: string; opencodeModel?: string; @@ -131,6 +137,7 @@ const DEFAULTS: Required< | "memoryApiKey" | "memoryProvider" | "memoryTemperature" + | "forceToolChoice" | "memoryExtraParams" | "opencodeProvider" | "opencodeModel" @@ -150,6 +157,7 @@ const DEFAULTS: Required< memoryApiKey?: string; memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter"; memoryTemperature?: number | false; + forceToolChoice?: boolean; memoryExtraParams?: Record; opencodeProvider?: string; opencodeModel?: string; @@ -487,6 +495,10 @@ const CONFIG_TEMPLATE = `{ // Set to false and add "memoryTemperature": false in config when using such models "memoryTemperature": 0.3, + // Force tool calls on openai-chat / orcarouter (tool_choice: "required"). Default true. + // Some thinking/reasoning models reject forced tool choice — set false to fall back to "auto": + // "forceToolChoice": false, + // Extra parameters to include in API request body // Useful for local inference servers (e.g. llama-server with --jinja) that support // additional parameters like disabling thinking/reasoning mode @@ -723,6 +735,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { memoryApiUrl: fileConfig.memoryApiUrl, memoryApiKey, memoryTemperature: fileConfig.memoryTemperature, + forceToolChoice: fileConfig.forceToolChoice, memoryExtraParams: fileConfig.memoryExtraParams, opencodeProvider: fileConfig.opencodeProvider, opencodeModel: fileConfig.opencodeModel, diff --git a/src/services/ai/provider-config.ts b/src/services/ai/provider-config.ts index 6563e77b..37c13aab 100644 --- a/src/services/ai/provider-config.ts +++ b/src/services/ai/provider-config.ts @@ -7,6 +7,7 @@ interface MemoryProviderRuntimeConfig { memoryApiUrl?: string; memoryApiKey?: string; memoryTemperature?: number | false; + forceToolChoice?: boolean; memoryExtraParams?: Record; autoCaptureMaxIterations?: number; autoCaptureIterationTimeout?: number; @@ -44,6 +45,7 @@ export function buildMemoryProviderConfig( apiUrl: memoryApiUrl || "", apiKey: memoryApiKey || "", memoryTemperature: config.memoryTemperature, + forceToolChoice: config.forceToolChoice, extraParams: config.memoryExtraParams, maxIterations: overrides.maxIterations ?? config.autoCaptureMaxIterations, iterationTimeout: overrides.iterationTimeout ?? config.autoCaptureIterationTimeout, diff --git a/src/services/ai/providers/openai-chat-completion.ts b/src/services/ai/providers/openai-chat-completion.ts index 1f801e2e..b06c9a13 100644 --- a/src/services/ai/providers/openai-chat-completion.ts +++ b/src/services/ai/providers/openai-chat-completion.ts @@ -311,6 +311,15 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { ) { errorMessage = 'Your model does not support the temperature parameter. Add "memoryTemperature": false to your config file to disable it.'; + } else if ( + response.status === 400 && + /tool_choice/i.test(errorText) && + (/Thinking mode does not support/i.test(errorText) || + /unsupported/i.test(errorText) || + /not support/i.test(errorText)) + ) { + errorMessage = + 'Your model does not support tool_choice "required". Add "forceToolChoice": false to your config file to fall back to "auto".'; } return { diff --git a/tests/ai-provider-config.test.ts b/tests/ai-provider-config.test.ts index 6b553104..bb69c49b 100644 --- a/tests/ai-provider-config.test.ts +++ b/tests/ai-provider-config.test.ts @@ -88,11 +88,23 @@ describe("AI provider config", () => { apiUrl: "https://api.openai.com/v1", apiKey: "sk-test", memoryTemperature: false, + forceToolChoice: undefined, maxIterations: 7, iterationTimeout: 1234, }); }); + it("builds provider config with forceToolChoice from runtime config", () => { + const providerConfig = buildMemoryProviderConfig({ + memoryModel: "deepseek-v4-flash", + memoryApiUrl: "https://openrouter.ai/api/v1", + memoryApiKey: "sk-test", + forceToolChoice: false, + }); + + expect(providerConfig.forceToolChoice).toBe(false); + }); + it("rejects placeholder API keys before a provider request is built", () => { expect(() => buildMemoryProviderConfig({ @@ -138,6 +150,7 @@ describe("AI provider config", () => { model: "", apiUrl: "", apiKey: "sk-orca-test", + forceToolChoice: undefined, maxIterations: undefined, iterationTimeout: undefined, }); diff --git a/tests/openai-chat-completion-provider.test.ts b/tests/openai-chat-completion-provider.test.ts index 692b1b5c..9fe38e16 100644 --- a/tests/openai-chat-completion-provider.test.ts +++ b/tests/openai-chat-completion-provider.test.ts @@ -322,6 +322,25 @@ describe("OpenAIChatCompletionProvider", () => { expect(result.error).toContain("memoryTemperature"); }); + it("returns friendly message when thinking mode rejects tool_choice required", async () => { + globalThis.fetch = makeFetch({ + ok: false, + status: 400, + body: "Thinking mode does not support this tool_choice", + }); + + const result = await makeProvider({ apiUrl: "https://api.openai.com/v1" }).executeToolCall( + "system", + "user", + toolSchema, + "session-id" + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("forceToolChoice"); + expect(result.error).toContain("false"); + }); + it("returns success: false when response has no choices", async () => { globalThis.fetch = makeFetch({ ok: true, body: { choices: [] } } as any);