From ce8ff4aa75197d7ee464bae6acf6ef6bc166c9b9 Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:03:45 +0200 Subject: [PATCH] fix(executors): don't inject thinking when tool_choice forces a tool (native Claude) (#4389) Forced tool_choice now strips the adaptive thinking injection to avoid Anthropic 400. Thanks @NomenAK. --- config/quality/file-size-baseline.json | 3 +- open-sse/executors/base.ts | 18 +++- .../claude-thinking-tool-choice-guard.test.ts | 87 +++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 tests/unit/claude-thinking-tool-choice-guard.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index f9b676b6ed..1255745c96 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -77,12 +77,13 @@ "_rebaseline_2026_06_20_1447_disabled_conn_error": "Re-baseline #1447 (show a disabled connection's last error): ConnectionRow.tsx 941->942 (+1), a single import line for the extracted shouldShowConnectionLastError helper. Minimal, not extractable.", "_rebaseline_2026_06_20_1449_1444_test_route": "Re-baseline providers test route.ts 842->887: combined growth of sibling fixes #1449 (bound OAuth connection-test probe with a timeout) + #1444 (label a deactivated account distinctly from a revoked token), both at the same connection-test chokepoint. Cohesive route handler; not extractable without hiding the test flow.", "_rebaseline_2026_06_20_1409_1294_models": "Re-baseline src/lib/db/models.ts 1184->1221: combined growth of sibling fixes #1409 (cascade-delete orphaned model aliases when a provider is removed) + #1294 (persist max_input_tokens/max_output_tokens on custom models), both adding CRUD at the existing models domain module. Cohesive db module; not extractable.", + "_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.", "cap": 800, "frozen": { "open-sse/translator/request/openai-to-kiro.ts": 807, "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1680, - "open-sse/executors/base.ts": 1387, + "open-sse/executors/base.ts": 1399, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 513ff1c45b..0c44fce6a6 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -938,7 +938,19 @@ export class BaseExecutor { } } - if (headerThinking === "adaptive") { + // Anthropic rejects `thinking` (enabled/adaptive) when tool_choice forces a + // specific tool ({type:"any"|"tool"}): "Thinking may not be enabled when + // tool_choice forces tool use". Treat forced tool_choice as an implicit + // `thinking: off` so neither the explicit-adaptive branch nor the default CC + // injection below produces the invalid combination (incl. client-sent thinking). + const toolChoiceForced = + tb.tool_choice === "any" || + (typeof tb.tool_choice === "object" && + tb.tool_choice !== null && + ((tb.tool_choice as Record).type === "any" || + (tb.tool_choice as Record).type === "tool")); + const effThinking = toolChoiceForced ? "off" : headerThinking; + if (effThinking === "adaptive") { if (tb.thinking === undefined) { tb.thinking = { type: "adaptive" }; appliedThinking = "adaptive"; @@ -948,11 +960,11 @@ export class BaseExecutor { edits: [{ type: "clear_thinking_20251015", keep: "all" }], }; } - } else if (headerThinking === "off") { + } else if (effThinking === "off") { delete tb.thinking; delete tb.context_management; appliedThinking = "off"; - } else if (!headerThinking && !headerEffort) { + } else if (!effThinking && !headerEffort) { // Default CC logic when no override headers are present const isHaiku = typeof tb.model === "string" && tb.model.includes("haiku"); if (isHaiku) { diff --git a/tests/unit/claude-thinking-tool-choice-guard.test.ts b/tests/unit/claude-thinking-tool-choice-guard.test.ts new file mode 100644 index 0000000000..8ef086f937 --- /dev/null +++ b/tests/unit/claude-thinking-tool-choice-guard.test.ts @@ -0,0 +1,87 @@ +/** + * Claude native OAuth — `thinking` must not be injected when tool_choice forces a tool. + * + * The Claude Code wire-image emulation in base.ts injects `thinking:{type:"adaptive"}` + * for non-Haiku Claude models. Anthropic rejects `thinking` (enabled/adaptive) when + * `tool_choice` forces a specific tool (`{type:"any"|"tool"}`) with: + * 400 "Thinking may not be enabled when tool_choice forces tool use." + * So Opus/Sonnet calls that force a tool (e.g. Claude Code's `message_user`) 400'd. + * + * Fix: treat forced tool_choice as an implicit `thinking: off` — strip thinking only + * when forced, preserve the adaptive injection otherwise. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; + +// Minimal claude executor: passthrough transformRequest, no refresh — exercises +// exactly base.ts's claude-OAuth wire-image path (same harness as #4307). +class ClaudeLikeExecutor extends BaseExecutor { + constructor() { + super("claude", { baseUrls: ["https://api.anthropic.com/v1/messages"] }); + } + needsRefresh() { + return false; + } + async transformRequest(_model: string, body: Record) { + return { ...body }; + } +} + +const TOOLS = [ + { name: "message_user", description: "Send a message", input_schema: { type: "object" } }, +]; + +async function captureUpstreamBody( + body: Record +): Promise> { + const executor = new ClaudeLikeExecutor(); + const originalFetch = globalThis.fetch; + let upstreamBody: Record | null = null; + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + upstreamBody = JSON.parse(String(init.body)); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await executor.execute({ + model: "claude-opus-4-8", + body, + stream: false, + // OAuth token (sk-ant-oat…) with NO apiKey => wire-image path fires. + credentials: { accessToken: "sk-ant-oat-test-thinkguard" }, + }); + } finally { + globalThis.fetch = originalFetch; + } + assert.ok(upstreamBody, "fetch must have been called"); + return upstreamBody!; +} + +test("forced tool_choice strips injected thinking (avoids Anthropic 400)", async () => { + const upstream = await captureUpstreamBody({ + messages: [{ role: "user", content: "hi" }], + tools: TOOLS, + tool_choice: { type: "tool", name: "message_user" }, + }); + assert.equal( + upstream.thinking, + undefined, + "thinking must NOT be present when tool_choice forces a tool" + ); +}); + +test("non-forced call still injects adaptive thinking (behavior preserved)", async () => { + const upstream = await captureUpstreamBody({ + messages: [{ role: "user", content: "hi" }], + tools: TOOLS, + // no tool_choice → not forced → adaptive thinking still injected + }); + assert.deepEqual( + upstream.thinking, + { type: "adaptive" }, + "adaptive thinking must still be injected when tool_choice does not force a tool" + ); +});