diff --git a/open-sse/translator/helpers/openaiHelper.js b/open-sse/translator/helpers/openaiHelper.js index 90879b93..d0b972d0 100644 --- a/open-sse/translator/helpers/openaiHelper.js +++ b/open-sse/translator/helpers/openaiHelper.js @@ -1,4 +1,10 @@ // OpenAI helper functions for translator +import { + normalizeToolDescription, + sanitizeJsonSchemaForOpenAI, + sanitizeOpenAIChatTool, + sanitizeRequestTools +} from "./toolSchemaCompat.js"; // Valid OpenAI content block types export const VALID_OPENAI_CONTENT_TYPES = ["text", "image_url", "image"]; @@ -7,68 +13,68 @@ export const VALID_OPENAI_MESSAGE_TYPES = ["text", "image_url", "image", "tool_c // Filter messages to OpenAI standard format // Remove: thinking, redacted_thinking, signature, and other non-OpenAI blocks export function filterToOpenAIFormat(body) { - if (!body.messages || !Array.isArray(body.messages)) return body; - - body.messages = body.messages.map(msg => { - // Keep tool messages as-is (OpenAI format) - if (msg.role === "tool") return msg; - - // Keep assistant messages with tool_calls as-is - if (msg.role === "assistant" && msg.tool_calls) return msg; - - // Handle string content - if (typeof msg.content === "string") return msg; - - // Handle array content - if (Array.isArray(msg.content)) { - const filteredContent = []; + if (body.messages && Array.isArray(body.messages)) { + body.messages = body.messages.map(msg => { + // Keep tool messages as-is (OpenAI format) + if (msg.role === "tool") return msg; + + // Keep assistant messages with tool_calls as-is + if (msg.role === "assistant" && msg.tool_calls) return msg; + + // Handle string content + if (typeof msg.content === "string") return msg; - for (const block of msg.content) { - // Skip thinking blocks - if (block.type === "thinking" || block.type === "redacted_thinking") continue; + // Handle array content + if (Array.isArray(msg.content)) { + const filteredContent = []; - // Only keep valid OpenAI content types - if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) { - // Remove signature field if exists - const { signature, cache_control, ...cleanBlock } = block; - filteredContent.push(cleanBlock); - } else if (block.type === "tool_use") { - // Convert tool_use to tool_calls format (handled separately) - continue; - } else if (block.type === "tool_result") { - // Keep tool_result but clean it - const { signature, cache_control, ...cleanBlock } = block; - filteredContent.push(cleanBlock); + for (const block of msg.content) { + // Skip thinking blocks + if (block.type === "thinking" || block.type === "redacted_thinking") continue; + + // Only keep valid OpenAI content types + if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) { + // Remove signature field if exists + const { signature, cache_control, ...cleanBlock } = block; + filteredContent.push(cleanBlock); + } else if (block.type === "tool_use") { + // Convert tool_use to tool_calls format (handled separately) + continue; + } else if (block.type === "tool_result") { + // Keep tool_result but clean it + const { signature, cache_control, ...cleanBlock } = block; + filteredContent.push(cleanBlock); + } } + + // If all content was filtered, add empty text + if (filteredContent.length === 0) { + filteredContent.push({ type: "text", text: "" }); + } + + return { ...msg, content: filteredContent }; } - // If all content was filtered, add empty text - if (filteredContent.length === 0) { - filteredContent.push({ type: "text", text: "" }); - } - - return { ...msg, content: filteredContent }; - } - - return msg; - }); - - // Filter out messages with only empty text (but NEVER filter tool messages) - body.messages = body.messages.filter(msg => { - // Always keep tool messages - if (msg.role === "tool") return true; - // Always keep assistant messages with tool_calls - if (msg.role === "assistant" && msg.tool_calls) return true; + return msg; + }); - if (typeof msg.content === "string") return msg.content.trim() !== ""; - if (Array.isArray(msg.content)) { - return msg.content.some(b => - (b.type === "text" && b.text?.trim()) || - b.type !== "text" - ); - } - return true; - }); + // Filter out messages with only empty text (but NEVER filter tool messages) + body.messages = body.messages.filter(msg => { + // Always keep tool messages + if (msg.role === "tool") return true; + // Always keep assistant messages with tool_calls + if (msg.role === "assistant" && msg.tool_calls) return true; + + if (typeof msg.content === "string") return msg.content.trim() !== ""; + if (Array.isArray(msg.content)) { + return msg.content.some(b => + (b.type === "text" && b.text?.trim()) || + b.type !== "text" + ); + } + return true; + }); + } // Remove empty tools array (some providers like QWEN reject it) if (body.tools && Array.isArray(body.tools) && body.tools.length === 0) { @@ -79,7 +85,7 @@ export function filterToOpenAIFormat(body) { if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { body.tools = body.tools.map(tool => { // Already OpenAI format - if (tool.type === "function" && tool.function) return tool; + if (tool.type === "function" && tool.function) return sanitizeOpenAIChatTool(tool); // Claude format: {name, description, input_schema} if (tool.name && (tool.input_schema || tool.description)) { @@ -87,8 +93,8 @@ export function filterToOpenAIFormat(body) { type: "function", function: { name: tool.name, - description: tool.description || "", - parameters: tool.input_schema || { type: "object", properties: {} } + description: normalizeToolDescription(tool.description), + parameters: sanitizeJsonSchemaForOpenAI(tool.input_schema) } }; } @@ -99,14 +105,16 @@ export function filterToOpenAIFormat(body) { type: "function", function: { name: fn.name, - description: fn.description || "", - parameters: fn.parameters || { type: "object", properties: {} } + description: normalizeToolDescription(fn.description), + parameters: sanitizeJsonSchemaForOpenAI(fn.parameters) } })); } return tool; }).flat(); + + body = sanitizeRequestTools(body); } // Normalize tool_choice to OpenAI format @@ -124,4 +132,3 @@ export function filterToOpenAIFormat(body) { return body; } - diff --git a/open-sse/translator/helpers/toolSchemaCompat.js b/open-sse/translator/helpers/toolSchemaCompat.js new file mode 100644 index 00000000..89e40533 --- /dev/null +++ b/open-sse/translator/helpers/toolSchemaCompat.js @@ -0,0 +1,97 @@ +function isPlainObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export function normalizeToolDescription(desc) { + if (typeof desc === "string") return desc; + if (desc === null || desc === undefined) return ""; + if (typeof desc === "object") { + try { + return JSON.stringify(desc); + } catch { + return String(desc); + } + } + return String(desc); +} + +export function sanitizeJsonSchemaForOpenAI(schema) { + if (!isPlainObject(schema)) { + return { type: "object", properties: {} }; + } + + const sanitized = { ...schema }; + + if (sanitized.type === "object") { + const rawProperties = sanitized.properties; + const nextProperties = {}; + + if (isPlainObject(rawProperties)) { + for (const [key, value] of Object.entries(rawProperties)) { + nextProperties[key] = sanitizeJsonSchemaForOpenAI(value); + } + } + + sanitized.properties = nextProperties; + } + + if (sanitized.type === "array" && isPlainObject(sanitized.items)) { + sanitized.items = sanitizeJsonSchemaForOpenAI(sanitized.items); + } + + for (const combiner of ["oneOf", "anyOf", "allOf"]) { + if (Array.isArray(sanitized[combiner])) { + sanitized[combiner] = sanitized[combiner].map(item => sanitizeJsonSchemaForOpenAI(item)); + } + } + + if (Object.prototype.hasOwnProperty.call(sanitized, "not")) { + sanitized.not = sanitizeJsonSchemaForOpenAI(sanitized.not); + } + + return sanitized; +} + +export function sanitizeOpenAIChatTool(tool) { + if (!isPlainObject(tool) || tool.type !== "function" || !isPlainObject(tool.function)) { + return tool; + } + + return { + ...tool, + function: { + ...tool.function, + description: normalizeToolDescription(tool.function.description), + parameters: sanitizeJsonSchemaForOpenAI(tool.function.parameters) + } + }; +} + +export function sanitizeOpenAIResponsesTool(tool) { + if (!isPlainObject(tool) || tool.type !== "function") { + return tool; + } + + return { + ...tool, + description: normalizeToolDescription(tool.description), + parameters: sanitizeJsonSchemaForOpenAI(tool.parameters) + }; +} + +export function sanitizeRequestTools(body) { + if (!isPlainObject(body) || !Array.isArray(body.tools)) { + return body; + } + + return { + ...body, + tools: body.tools.map(tool => { + if (!isPlainObject(tool) || tool.type !== "function") return tool; + if (isPlainObject(tool.function)) return sanitizeOpenAIChatTool(tool); + return sanitizeOpenAIResponsesTool(tool); + }) + }; +} diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index fe622ff9..3aedfce1 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -2,6 +2,7 @@ import { FORMATS } from "./formats.js"; import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.js"; import { prepareClaudeRequest } from "./helpers/claudeHelper.js"; import { filterToOpenAIFormat } from "./helpers/openaiHelper.js"; +import { sanitizeRequestTools } from "./helpers/toolSchemaCompat.js"; import { normalizeThinkingConfig } from "../services/provider.js"; // Registry for translators @@ -70,6 +71,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`); if (toOpenAI) { result = toOpenAI(model, result, stream, credentials); + result = sanitizeRequestTools(result); // Log OpenAI intermediate format reqLogger?.logOpenAIRequest?.(result); } @@ -88,6 +90,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // This handles hybrid requests (e.g., OpenAI messages + Claude tools) if (targetFormat === FORMATS.OPENAI) { result = filterToOpenAIFormat(result); + result = sanitizeRequestTools(result); } // Final step: prepare request for Claude format endpoints @@ -96,6 +99,10 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream result = prepareClaudeRequest(result, provider, apiKey); } + if (targetFormat === FORMATS.OPENAI_RESPONSES) { + result = sanitizeRequestTools(result); + } + return result; } diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index d9545933..fff27746 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -1,6 +1,11 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; +import { + normalizeToolDescription, + sanitizeJsonSchemaForOpenAI, + sanitizeRequestTools +} from "../helpers/toolSchemaCompat.js"; // Convert Claude request to OpenAI format export function claudeToOpenAIRequest(model, body, stream) { @@ -59,8 +64,8 @@ export function claudeToOpenAIRequest(model, body, stream) { type: "function", function: { name: tool.name, - description: tool.description, - parameters: tool.input_schema || { type: "object", properties: {} } + description: normalizeToolDescription(tool.description), + parameters: sanitizeJsonSchemaForOpenAI(tool.input_schema) } })); } @@ -70,7 +75,7 @@ export function claudeToOpenAIRequest(model, body, stream) { result.tool_choice = convertToolChoice(body.tool_choice); } - return result; + return sanitizeRequestTools(result); } // Fix missing tool responses - add empty responses for tool_calls without responses @@ -229,4 +234,3 @@ function convertToolChoice(choice) { // Register register(FORMATS.CLAUDE, FORMATS.OPENAI, claudeToOpenAIRequest, null); - diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 8067c8fc..20f5c821 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -7,12 +7,18 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { normalizeResponsesInput } from "../helpers/responsesApiHelper.js"; +import { + normalizeToolDescription, + sanitizeJsonSchemaForOpenAI, + sanitizeOpenAIChatTool, + sanitizeRequestTools +} from "../helpers/toolSchemaCompat.js"; /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) { - if (!body.input) return body; + if (!body.input) return sanitizeRequestTools(body); const result = { ...body }; result.messages = []; @@ -125,20 +131,20 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) result.tools = body.tools .map(tool => { // Already in Chat Completions format: { type: "function", function: { name, ... } } - if (tool.function) return tool; + if (tool.function) return sanitizeOpenAIChatTool(tool); // Responses API function tool: { type: "function", name, description, parameters } // Only convert when a non-empty name is present; skip hosted tools without one. const name = tool.name; if (!name || typeof name !== "string" || name.trim() === "") return null; - return { + return sanitizeOpenAIChatTool({ type: "function", function: { name, - description: tool.description, - parameters: tool.parameters, + description: normalizeToolDescription(tool.description), + parameters: sanitizeJsonSchemaForOpenAI(tool.parameters), strict: tool.strict } - }; + }); }) .filter(Boolean); } @@ -151,7 +157,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) delete result.store; delete result.reasoning; - return result; + return sanitizeRequestTools(result); } /** @@ -159,7 +165,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) */ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) { // Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[]) - if (body.input) return { ...body, model, stream: true }; + if (body.input) return sanitizeRequestTools({ ...body, model, stream: true }); const result = { model, @@ -251,13 +257,14 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // Convert tools format if (body.tools && Array.isArray(body.tools)) { result.tools = body.tools.map(tool => { - if (tool.type === "function") { + if (tool.type === "function" && tool.function) { + const normalized = sanitizeOpenAIChatTool(tool); return { type: "function", - name: tool.function.name, - description: tool.function.description, - parameters: tool.function.parameters, - strict: tool.function.strict + name: normalized.function.name, + description: normalizeToolDescription(normalized.function.description), + parameters: sanitizeJsonSchemaForOpenAI(normalized.function.parameters), + strict: normalized.function.strict }; } return tool; @@ -269,7 +276,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens; if (body.top_p !== undefined) result.top_p = body.top_p; - return result; + return sanitizeRequestTools(result); } // Register both directions diff --git a/package.json b/package.json index e815d195..289f8bad 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "next dev --webpack --port 20128", "build": "NODE_ENV=production next build --webpack", + "test:tool-schema": "node tests/translator/tool-schema-compat.test.mjs", "start": "NODE_ENV=production next start", "dev:bun": "bun --bun next dev --webpack --port 20128", "build:bun": "NODE_ENV=production bun --bun next build --webpack", diff --git a/tests/translator/tool-schema-compat.test.mjs b/tests/translator/tool-schema-compat.test.mjs new file mode 100644 index 00000000..53fbc65f --- /dev/null +++ b/tests/translator/tool-schema-compat.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { + normalizeToolDescription, + sanitizeJsonSchemaForOpenAI, + sanitizeOpenAIChatTool, + sanitizeOpenAIResponsesTool, + sanitizeRequestTools +} from "../../open-sse/translator/helpers/toolSchemaCompat.js"; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + console.error(error); + process.exitCode = 1; + } +} + +test("object schema without properties gets patched", () => { + const out = sanitizeJsonSchemaForOpenAI({ type: "object" }); + assert.deepEqual(out, { type: "object", properties: {} }); +}); + +test("object schema with properties stays unchanged", () => { + const schema = { + type: "object", + properties: { + q: { type: "string" } + }, + required: ["q"] + }; + const out = sanitizeJsonSchemaForOpenAI(schema); + assert.deepEqual(out, schema); + assert.notStrictEqual(out, schema); +}); + +test("nested object schema is sanitized recursively", () => { + const schema = { + type: "object", + properties: { + config: { type: "object" }, + list: { + type: "array", + items: { + type: "object" + } + } + } + }; + const out = sanitizeJsonSchemaForOpenAI(schema); + assert.deepEqual(out, { + type: "object", + properties: { + config: { type: "object", properties: {} }, + list: { + type: "array", + items: { type: "object", properties: {} } + } + } + }); +}); + +test("combiners and not are sanitized recursively", () => { + const schema = { + oneOf: [{ type: "object" }], + not: { type: "object" } + }; + const out = sanitizeJsonSchemaForOpenAI(schema); + assert.deepEqual(out, { + oneOf: [{ type: "object", properties: {} }], + not: { type: "object", properties: {} } + }); +}); + +test("chat tool format is sanitized", () => { + const tool = { + type: "function", + function: { + name: "mcp__vibe_kanban__list_repos", + description: null, + parameters: { type: "object" } + } + }; + const out = sanitizeOpenAIChatTool(tool); + assert.deepEqual(out, { + type: "function", + function: { + name: "mcp__vibe_kanban__list_repos", + description: "", + parameters: { type: "object", properties: {} } + } + }); +}); + +test("responses tool format is sanitized", () => { + const tool = { + type: "function", + name: "mcp__vibe_kanban__list_organizations", + description: ["List", "organizations"], + parameters: { type: "object" }, + strict: true + }; + const out = sanitizeOpenAIResponsesTool(tool); + assert.deepEqual(out, { + type: "function", + name: "mcp__vibe_kanban__list_organizations", + description: "[\"List\",\"organizations\"]", + parameters: { type: "object", properties: {} }, + strict: true + }); +}); + +test("description normalization covers null/object/primitive", () => { + assert.equal(normalizeToolDescription(null), ""); + assert.equal(normalizeToolDescription({ a: 1 }), "{\"a\":1}"); + assert.equal(normalizeToolDescription(123), "123"); +}); + +test("sanitizeRequestTools is idempotent", () => { + const body = { + tools: [ + { + type: "function", + function: { + name: "chat_tool", + description: { title: "tool" }, + parameters: { type: "object" } + } + }, + { + type: "function", + name: "responses_tool", + description: undefined, + parameters: { type: "object" } + } + ] + }; + const once = sanitizeRequestTools(body); + const twice = sanitizeRequestTools(once); + assert.deepEqual(twice, once); +}); + +if (process.exitCode) { + process.exit(process.exitCode); +} + +console.log("all tool schema compat tests passed");