fix(sse): unwrap bare {function:{…}} tools in openai→claude translation (#6704)

* 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 <me@samirabis.com>
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 <me@samirabis.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-10 19:39:04 -03:00
committed by GitHub
parent 0840722826
commit b45d10ceea
3 changed files with 75 additions and 1 deletions

View File

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

View File

@@ -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) {

View File

@@ -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$/);
});