Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 69 additions & 62 deletions open-sse/translator/helpers/openaiHelper.js
Original file line number Diff line number Diff line change
@@ -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"];
Expand All @@ -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) {
Expand All @@ -79,16 +85,16 @@ 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)) {
return {
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)
}
};
}
Expand All @@ -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
Expand All @@ -124,4 +132,3 @@ export function filterToOpenAIFormat(body) {

return body;
}

97 changes: 97 additions & 0 deletions open-sse/translator/helpers/toolSchemaCompat.js
Original file line number Diff line number Diff line change
@@ -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);
})
};
}
7 changes: 7 additions & 0 deletions open-sse/translator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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
Expand All @@ -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;
}

Expand Down
12 changes: 8 additions & 4 deletions open-sse/translator/request/claude-to-openai.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
}
}));
}
Expand All @@ -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
Expand Down Expand Up @@ -229,4 +234,3 @@ function convertToolChoice(choice) {

// Register
register(FORMATS.CLAUDE, FORMATS.OPENAI, claudeToOpenAIRequest, null);

Loading