From b45d10ceeac2fb946499192859cc8fc4e4047b7b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:39:04 -0300 Subject: [PATCH] =?UTF-8?q?fix(sse):=20unwrap=20bare=20{function:{?= =?UTF-8?q?=E2=80=A6}}=20tools=20in=20openai=E2=86=92claude=20translation?= =?UTF-8?q?=20(#6704)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): unwrap bare {function:{…}} tools in openai→claude translation Some OpenAI-shape clients send a tool as a bare `{ function: {...} }` object, omitting the spec-required `type: "function"` parent wrapper. The tools-mapping in openai-to-claude.ts (~line 366) only unwrapped `tool.function` when `tool.type === "function"` was ALSO true, so a bare-function tool fell through to `toolData = tool` (the wrapper itself, with no `.name`), producing an empty `originalName` and silently dropping the tool from the translated request — worse than a 400, since the caller has no signal the tool never made it upstream. Unwrap `tool.function` whenever present, independent of the parent `type` field. Regression guard: tests/unit/openai-to-claude-bare-tool.test.ts. Co-authored-by: Samir Abis Inspired-by: https://github.com/decolua/9router/pull/2473 * chore(6704): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: Samir Abis --- .../fixes/6704-unwrap-bare-function-tool.md | 1 + .../translator/request/openai-to-claude.ts | 10 ++- tests/unit/openai-to-claude-bare-tool.test.ts | 65 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/6704-unwrap-bare-function-tool.md create mode 100644 tests/unit/openai-to-claude-bare-tool.test.ts 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$/); +});