diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index f1a9baae..9988c769 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -291,15 +291,35 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { return blocks; } -// Convert OpenAI tool choice to Claude format +// Convert OpenAI tool choice to Claude format. +// Claude only accepts tool_choice.type of "auto" | "any" | "tool" | "none"; +// anything else (e.g. OpenAI's "function") triggers a 400, so we never pass an +// unrecognized type through. +const CLAUDE_TOOL_CHOICE_TYPES = new Set(["auto", "any", "tool", "none"]); + function convertOpenAIToolChoice(choice) { if (!choice) return { type: "auto" }; - if (typeof choice === "object" && choice.type) return choice; - if (choice === "auto" || choice === "none") return { type: "auto" }; - if (choice === "required") return { type: "any" }; - if (typeof choice === "object" && choice.function) { - return { type: "tool", name: choice.function.name }; + + // OpenAI string forms: "auto" | "none" | "required" + if (typeof choice === "string") { + if (choice === "required") return { type: "any" }; + return { type: "auto" }; // "auto", "none", or anything unexpected } + + if (typeof choice === "object") { + // OpenAI forced tool: { type: "function", function: { name } }. + // Checked before the native pass-through below, because the OpenAI shape + // also carries a `.type` ("function") that Claude rejects. + if (choice.function?.name) { + return { type: "tool", name: choice.function.name }; + } + // Already Claude-native — only pass through types Claude actually accepts, + // so a malformed or unknown type can never leak into the upstream request. + if (CLAUDE_TOOL_CHOICE_TYPES.has(choice.type)) { + return choice; + } + } + return { type: "auto" }; } diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 2c9d1f26..688573ab 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -35,13 +35,16 @@ export function cloakClaudeTools(body) { const tools = body.tools; if (!tools || tools.length === 0) return { body, toolNameMap: null }; + const suffix = (name) => `${name}${CLAUDE_TOOL_SUFFIX}`; const toolNameMap = new Map(); + const clientToolNames = new Set(); const clientDeclarations = []; // All client tools get renamed with suffix for (const tool of tools) { - const suffixed = `${tool.name}${CLAUDE_TOOL_SUFFIX}`; + const suffixed = suffix(tool.name); toolNameMap.set(suffixed, tool.name); + clientToolNames.add(tool.name); clientDeclarations.push({ ...tool, name: suffixed }); } @@ -51,17 +54,27 @@ export function cloakClaudeTools(body) { // Rename tool_use in message history (all client tools get suffix) const renamedMessages = body.messages?.map(msg => { if (!Array.isArray(msg.content)) return msg; - const renamedContent = msg.content.map(block => { - if (block.type === "tool_use") { - return { ...block, name: `${block.name}${CLAUDE_TOOL_SUFFIX}` }; - } - return block; - }); + const renamedContent = msg.content.map(block => + block.type === "tool_use" ? { ...block, name: suffix(block.name) } : block + ); return { ...msg, content: renamedContent }; }); + const cloakedBody = { ...body, tools: allTools, messages: renamedMessages || body.messages }; + + // A forced tool_choice ({ type: "tool", name }) must point at the suffixed + // tool name, otherwise Claude rejects it: "Tool '' not found in provided tools". + // Only rewrite when the choice targets one of the client tools we actually + // renamed — never a decoy/built-in name (those are sent unsuffixed). + if ( + body.tool_choice?.type === "tool" && + clientToolNames.has(body.tool_choice.name) + ) { + cloakedBody.tool_choice = { ...body.tool_choice, name: suffix(body.tool_choice.name) }; + } + return { - body: { ...body, tools: allTools, messages: renamedMessages || body.messages }, + body: cloakedBody, toolNameMap: toolNameMap.size > 0 ? toolNameMap : null }; } diff --git a/tests/unit/claude-cloaking.test.js b/tests/unit/claude-cloaking.test.js new file mode 100644 index 00000000..6b6e5dc6 --- /dev/null +++ b/tests/unit/claude-cloaking.test.js @@ -0,0 +1,76 @@ +/** + * Unit tests for open-sse/utils/claudeCloaking.js + * + * Tests cover: + * - cloakClaudeTools() - tool renaming and forced tool_choice suffixing + */ + +import { describe, it, expect } from "vitest"; +import { cloakClaudeTools } from "../../open-sse/utils/claudeCloaking.js"; +import { CLAUDE_TOOL_SUFFIX } from "../../open-sse/config/appConstants.js"; + +describe("cloakClaudeTools", () => { + const baseBody = { + tools: [{ name: "todo_write", description: "write todos", input_schema: { type: "object", properties: {} } }], + messages: [{ role: "user", content: [{ type: "text", text: "add a todo" }] }] + }; + + it("suffixes client tool names and maps them back", () => { + const { body, toolNameMap } = cloakClaudeTools(baseBody); + const suffixed = `todo_write${CLAUDE_TOOL_SUFFIX}`; + expect(body.tools.find(t => t.name === suffixed)).toBeDefined(); + expect(toolNameMap.get(suffixed)).toBe("todo_write"); + }); + + it("suffixes a forced tool_choice to match the renamed tool", () => { + const { body } = cloakClaudeTools({ + ...baseBody, + tool_choice: { type: "tool", name: "todo_write" } + }); + // Without this, Claude rejects: "Tool 'todo_write' not found in provided tools". + expect(body.tool_choice).toEqual({ type: "tool", name: `todo_write${CLAUDE_TOOL_SUFFIX}` }); + }); + + it("suffixes only the chosen tool when several are present", () => { + const { body } = cloakClaudeTools({ + tools: [ + { name: "search", input_schema: { type: "object", properties: {} } }, + { name: "todo_write", input_schema: { type: "object", properties: {} } } + ], + tool_choice: { type: "tool", name: "todo_write" } + }); + expect(body.tool_choice).toEqual({ type: "tool", name: `todo_write${CLAUDE_TOOL_SUFFIX}` }); + }); + + it("leaves non-forced tool_choice untouched", () => { + const auto = cloakClaudeTools({ ...baseBody, tool_choice: { type: "auto" } }); + expect(auto.body.tool_choice).toEqual({ type: "auto" }); + + const none = cloakClaudeTools({ ...baseBody }); + expect(none.body.tool_choice).toBeUndefined(); + }); + + it("does not suffix a forced choice that targets a non-client (decoy/built-in) tool", () => { + // "Bash" is an injected decoy sent unsuffixed; forcing it must stay as-is. + const { body } = cloakClaudeTools({ ...baseBody, tool_choice: { type: "tool", name: "Bash" } }); + expect(body.tool_choice).toEqual({ type: "tool", name: "Bash" }); + }); + + it("renames tool_use names in message history", () => { + const { body } = cloakClaudeTools({ + ...baseBody, + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "todo_write", input: {} }] } + ] + }); + const block = body.messages[0].content[0]; + expect(block.name).toBe(`todo_write${CLAUDE_TOOL_SUFFIX}`); + }); + + it("returns the body unchanged when there are no tools", () => { + const input = { messages: [{ role: "user", content: "hi" }], tool_choice: { type: "tool", name: "x" } }; + const { body, toolNameMap } = cloakClaudeTools(input); + expect(body).toBe(input); + expect(toolNameMap).toBeNull(); + }); +}); diff --git a/tests/unit/openai-to-claude.test.js b/tests/unit/openai-to-claude.test.js index 136f6d00..45b67fb2 100644 --- a/tests/unit/openai-to-claude.test.js +++ b/tests/unit/openai-to-claude.test.js @@ -122,6 +122,50 @@ describe("openaiToClaudeRequest", () => { expect(systemText).toContain("You must respond with valid JSON"); }); }); + + describe("tool_choice handling", () => { + const baseBody = { + messages: [{ role: "user", content: "add a todo" }], + tools: [{ + type: "function", + function: { name: "todo_write", description: "write todos", parameters: { type: "object", properties: {} } } + }] + }; + + const choiceOf = (tc) => + openaiToClaudeRequest("claude-sonnet-4.5", { ...baseBody, tool_choice: tc }, false).tool_choice; + + it("converts OpenAI forced tool ({type:'function'}) to Claude {type:'tool'}", () => { + // Must NOT leak the OpenAI "function" type — Claude only accepts auto|any|tool|none. + expect(choiceOf({ type: "function", function: { name: "todo_write" } })) + .toEqual({ type: "tool", name: "todo_write" }); + }); + + it("maps string tool_choice values", () => { + expect(choiceOf("auto")).toEqual({ type: "auto" }); + expect(choiceOf("none")).toEqual({ type: "auto" }); + expect(choiceOf("required")).toEqual({ type: "any" }); + }); + + it("passes through Claude-native tool_choice objects unchanged", () => { + expect(choiceOf({ type: "tool", name: "todo_write" })).toEqual({ type: "tool", name: "todo_write" }); + expect(choiceOf({ type: "any" })).toEqual({ type: "any" }); + expect(choiceOf({ type: "none" })).toEqual({ type: "none" }); + }); + + it("never leaks an invalid type (falls back to auto)", () => { + // Malformed forced choice with no tool name, and unknown types, must not + // pass an invalid `type` through to Claude. + expect(choiceOf({ type: "function", function: {} })).toEqual({ type: "auto" }); + expect(choiceOf({ type: "function" })).toEqual({ type: "auto" }); + expect(choiceOf({ type: "bogus" })).toEqual({ type: "auto" }); + }); + + it("omits tool_choice entirely when the request has none", () => { + const result = openaiToClaudeRequest("claude-sonnet-4.5", baseBody, false); + expect(result.tool_choice).toBeUndefined(); + }); + }); }); describe("openaiToClaudeResponse", () => {