diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index 375364c725..67186eaa69 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -178,7 +178,9 @@ export function filterToOpenAIFormat(body) { type: "function", function: { name: tool.name, - description: tool.description || "", + // Coerce: strict upstream validators (NVIDIA NIM, Codex) reject + // non-string descriptions. Ports decolua/9router#397. + description: String(tool.description ?? ""), parameters: tool.input_schema || { type: "object", properties: {} }, }, }; @@ -190,7 +192,7 @@ export function filterToOpenAIFormat(body) { type: "function", function: { name: fn.name, - description: fn.description || "", + description: String(fn.description ?? ""), parameters: fn.parameters || { type: "object", properties: {} }, }, })); diff --git a/tests/unit/translator-helper-tool-description-coercion.test.ts b/tests/unit/translator-helper-tool-description-coercion.test.ts new file mode 100644 index 0000000000..12e47f12ad --- /dev/null +++ b/tests/unit/translator-helper-tool-description-coercion.test.ts @@ -0,0 +1,58 @@ +// Regression: tool descriptions must always be strings for strict upstream +// validation (e.g., NVIDIA NIM, Codex). Ports upstream PR decolua/9router#397 +// (Ibrahim Ryan), restricted to the gap that still exists in OmniRoute — +// the two non-string-tolerant paths in openaiHelper.filterToOpenAIFormat +// (Claude-style and Gemini-style tool normalization). The other locations +// upstream-patched (claude-to-openai.ts, openai-responses.ts) already coerce +// via String/toString in OmniRoute. +import test from "node:test"; +import assert from "node:assert/strict"; + +const openaiHelper = await import( + "../../open-sse/translator/helpers/openaiHelper.ts" +); + +test("filterToOpenAIFormat coerces non-string Claude-tool description to string", () => { + const result = openaiHelper.filterToOpenAIFormat({ + messages: [{ role: "user", content: "hi" }], + tools: [ + { + name: "claude-tool", + // Non-string truthy value — must NOT leak through to upstream. + description: { invalid: "object" } as unknown as string, + input_schema: { type: "object" }, + }, + ], + }); + assert.equal(typeof result.tools[0].function.description, "string"); +}); + +test("filterToOpenAIFormat coerces null/undefined Claude-tool description to empty string", () => { + const result = openaiHelper.filterToOpenAIFormat({ + messages: [{ role: "user", content: "hi" }], + tools: [ + { name: "t1", description: null as unknown as string, input_schema: {} }, + { name: "t2", description: undefined, input_schema: {} }, + ], + }); + assert.equal(result.tools[0].function.description, ""); + assert.equal(result.tools[1].function.description, ""); +}); + +test("filterToOpenAIFormat coerces non-string Gemini functionDeclarations description", () => { + const result = openaiHelper.filterToOpenAIFormat({ + messages: [{ role: "user", content: "hi" }], + tools: [ + { + functionDeclarations: [ + { + name: "gemini-tool", + description: 12345 as unknown as string, + parameters: { type: "object" }, + }, + ], + }, + ], + }); + assert.equal(typeof result.tools[0].function.description, "string"); +});