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.
This commit is contained in:
Anton
2026-06-20 16:03:45 +02:00
committed by GitHub
parent a56c76a330
commit ce8ff4aa75
3 changed files with 104 additions and 4 deletions

View File

@@ -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,

View File

@@ -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<string, unknown>).type === "any" ||
(tb.tool_choice as Record<string, unknown>).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) {

View File

@@ -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<string, unknown>) {
return { ...body };
}
}
const TOOLS = [
{ name: "message_user", description: "Send a message", input_schema: { type: "object" } },
];
async function captureUpstreamBody(
body: Record<string, unknown>
): Promise<Record<string, unknown>> {
const executor = new ClaudeLikeExecutor();
const originalFetch = globalThis.fetch;
let upstreamBody: Record<string, unknown> | 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"
);
});