mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
87
tests/unit/claude-thinking-tool-choice-guard.test.ts
Normal file
87
tests/unit/claude-thinking-tool-choice-guard.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user