diff --git a/changelog.d/fixes/6704-unwrap-bare-function-tool.md b/changelog.d/fixes/6704-unwrap-bare-function-tool.md new file mode 100644 index 0000000000..85b21b7c5b --- /dev/null +++ b/changelog.d/fixes/6704-unwrap-bare-function-tool.md @@ -0,0 +1 @@ +- **fix(sse):** unwrap bare `{function:{…}}` tools so OpenAI-shape clients no longer have tools silently dropped in Claude translation. (thanks @samir-abis) diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index ec75bb5834..ff5dc4b6d5 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -363,7 +363,15 @@ export function openaiToClaudeRequest(model, body, stream) { if (body.tools && Array.isArray(body.tools)) { result.tools = body.tools .map((tool) => { - const toolData = tool.type === "function" && tool.function ? tool.function : tool; + // Function-shaped tools arrive in two flavors from real clients: + // (a) openai-spec: { type: "function", function: { name, ... } } + // (b) bare/loose: { function: { name, ... } } (no parent `type`) + // Unwrap `tool.function` whenever it is present, regardless of the + // parent `type` field — some OpenAI-shape clients omit the wrapper's + // `type: "function"` entirely. Previously that bare shape fell + // through to `toolData = tool` (the wrapper itself, with no `.name`), + // producing an empty `originalName` and silently dropping the tool. + const toolData = tool.function ?? tool; const originalName = typeof toolData.name === "string" ? toolData.name.trim() : ""; if (!originalName) { diff --git a/tests/unit/openai-to-claude-bare-tool.test.ts b/tests/unit/openai-to-claude-bare-tool.test.ts new file mode 100644 index 0000000000..848d0c75e5 --- /dev/null +++ b/tests/unit/openai-to-claude-bare-tool.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = await import( + "../../open-sse/translator/request/openai-to-claude.ts" +); + +// Regression: some OpenAI-shape clients send a tool as a BARE +// `{ function: { name, description, parameters } }` object, omitting the +// spec-required `type: "function"` parent wrapper. Before this fix, the +// tools-mapping in openai-to-claude.ts only unwrapped `tool.function` when +// `tool.type === "function"` was ALSO true, so a bare-function tool fell +// through with `toolData === tool` (the wrapper itself, which has no +// `.name`) — `originalName` came out empty and the tool was silently +// dropped from the translated request (worse than a 400: the caller has no +// idea the tool never made it upstream). + +test("openaiToClaudeRequest: bare {function:{...}} tool (no parent type) is NOT dropped", () => { + const request = { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }; + + const translated = openaiToClaudeRequest("claude-sonnet-4", request, false); + + assert.ok(Array.isArray(translated.tools), "expected translated.tools to be an array"); + assert.equal(translated.tools.length, 1, "expected the bare-function tool to survive translation"); + + const tool = translated.tools[0]; + assert.match(tool.name, /get_weather$/, "expected the original tool name to be preserved (prefixed)"); + assert.equal(tool.description, "Get the current weather"); + assert.deepEqual(tool.input_schema, { + type: "object", + properties: { city: { type: "string" } }, + }); +}); + +test("openaiToClaudeRequest: spec-shape {type:'function', function:{...}} tool still converts (no regression)", () => { + const request = { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }; + + const translated = openaiToClaudeRequest("claude-sonnet-4", request, false); + + assert.equal(translated.tools.length, 1); + assert.match(translated.tools[0].name, /get_weather$/); +});