245 lines
9.1 KiB
TypeScript
245 lines
9.1 KiB
TypeScript
// OpenAI helper functions for translator
|
|
|
|
// Valid OpenAI content block types
|
|
// `input_audio` / `audio_url` ported from upstream decolua/9router#913 so audio
|
|
// parts survive `filterToOpenAIFormat()` on OpenAI-target passthrough routes.
|
|
export const VALID_OPENAI_CONTENT_TYPES = [
|
|
"text",
|
|
"image_url",
|
|
"image",
|
|
"file_url",
|
|
"file",
|
|
"document",
|
|
"input_audio",
|
|
"audio_url",
|
|
];
|
|
export const VALID_OPENAI_MESSAGE_TYPES = [
|
|
"text",
|
|
"image_url",
|
|
"image",
|
|
"file_url",
|
|
"file",
|
|
"document",
|
|
"image",
|
|
"input_audio",
|
|
"audio_url",
|
|
"tool_calls",
|
|
"tool_result",
|
|
];
|
|
const CLAUDE_TOOL_CHOICE_REQUIRED = "an" + "y";
|
|
|
|
// Filter messages to OpenAI standard format
|
|
// Remove: redacted_thinking, and other non-OpenAI blocks
|
|
// Convert: thinking blocks → reasoning_content on the message
|
|
export function filterToOpenAIFormat(body, opts = {}) {
|
|
// #2069 — when the routed provider honors OpenAI-format cache_control
|
|
// breakpoints (DashScope/alibaba, Xiaomi MiMo, etc.) and preservation was
|
|
// requested upstream, keep the `cache_control` field on each content block
|
|
// instead of destructuring it away. `signature` is always stripped.
|
|
const preserveCacheControl = opts?.preserveCacheControl === true;
|
|
// #4849 strips reasoning_content from tool-call assistant turns to stop O(n^2)
|
|
// context growth — but reasoning-replay providers (DeepSeek V4, Kimi K2, etc.)
|
|
// REQUIRE the client's reasoning_content to be passed back, so keep it for them
|
|
// (the caller sets this when the routed model needs reasoning replay).
|
|
const preserveReasoningContent = opts?.preserveReasoningContent === true;
|
|
if (!body.messages || !Array.isArray(body.messages)) return body;
|
|
|
|
body.messages = body.messages.map((msg) => {
|
|
// Normalize OpenAI Responses-style `developer` role to `system` — many
|
|
// OpenAI-compatible providers reject `developer` (ported from
|
|
// decolua/9router#1011).
|
|
if (msg.role === "developer") msg = { ...msg, role: "system" };
|
|
|
|
// Keep tool messages as-is (OpenAI format)
|
|
if (msg.role === "tool") return msg;
|
|
|
|
// Keep assistant messages with tool_calls, but strip reasoning_content —
|
|
// reasoning blobs inflate context on every subsequent agentic turn (O(n^2)).
|
|
// Exception: reasoning-replay providers must keep client-provided
|
|
// reasoning_content (they 400 without it), so preserve it when requested.
|
|
if (msg.role === "assistant" && msg.tool_calls) {
|
|
if (!preserveReasoningContent && msg.reasoning_content !== undefined) {
|
|
const { reasoning_content, ...cleanMsg } = msg;
|
|
return cleanMsg;
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
// Handle string content
|
|
if (typeof msg.content === "string") return msg;
|
|
|
|
// Handle array content
|
|
if (Array.isArray(msg.content)) {
|
|
const filteredContent = [];
|
|
let thinkingText = null;
|
|
|
|
for (const block of msg.content) {
|
|
// Extract thinking blocks as reasoning_content (OpenAI extended thinking)
|
|
if (block.type === "thinking") {
|
|
thinkingText = block.thinking || block.text || "";
|
|
continue;
|
|
}
|
|
// Skip redacted thinking
|
|
if (block.type === "redacted_thinking") continue;
|
|
|
|
// Only keep valid OpenAI content types
|
|
if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) {
|
|
// Strip `signature` always; strip `cache_control` unless the provider
|
|
// honors OpenAI-format cache breakpoints and preservation was requested (#2069).
|
|
const { signature, cache_control, ...rest } = block;
|
|
const cleanBlock =
|
|
preserveCacheControl && cache_control !== undefined
|
|
? { ...rest, cache_control }
|
|
: rest;
|
|
if (
|
|
cleanBlock.type === "text" &&
|
|
typeof cleanBlock.text === "string" &&
|
|
cleanBlock.text.length === 0
|
|
) {
|
|
continue;
|
|
}
|
|
const fileData = cleanBlock.file_url ?? cleanBlock.file ?? cleanBlock.document;
|
|
if (
|
|
(cleanBlock.type === "file" || cleanBlock.type === "document") &&
|
|
!fileData?.url &&
|
|
!fileData?.data
|
|
) {
|
|
const fileContent =
|
|
cleanBlock.file?.content ??
|
|
cleanBlock.file?.text ??
|
|
cleanBlock.content ??
|
|
cleanBlock.text;
|
|
const fileName = cleanBlock.file?.name ?? cleanBlock.name ?? "attachment";
|
|
if (typeof fileContent === "string" && fileContent.length > 0) {
|
|
filteredContent.push({ type: "text", text: `[${fileName}]\n${fileContent}` });
|
|
continue;
|
|
}
|
|
}
|
|
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") {
|
|
const resultContent = block.content ?? block.text ?? block.output ?? "";
|
|
const resultText =
|
|
typeof resultContent === "string"
|
|
? resultContent
|
|
: Array.isArray(resultContent)
|
|
? resultContent
|
|
.filter((c) => c?.type === "text")
|
|
.map((c) => c.text)
|
|
.join("\n")
|
|
: JSON.stringify(resultContent);
|
|
if (typeof resultText === "string" && resultText.length > 0) {
|
|
filteredContent.push({
|
|
type: "text",
|
|
text: `[Tool Result: ${block.tool_use_id ?? block.id ?? "unknown"}]\n${resultText}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// If all content was filtered, add empty text
|
|
if (filteredContent.length === 0) {
|
|
filteredContent.push({ type: "text", text: "" });
|
|
}
|
|
|
|
const result = { ...msg, content: filteredContent };
|
|
// Attach thinking as reasoning_content for OpenAI extended thinking format
|
|
if (thinkingText && msg.role === "assistant") {
|
|
result.reasoning_content = thinkingText;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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;
|
|
|
|
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) {
|
|
delete body.tools;
|
|
}
|
|
|
|
// Strip Claude-specific fields that OpenAI-compatible providers reject
|
|
delete body.metadata;
|
|
delete body.anthropic_version;
|
|
// Codex clients send a top-level `client_metadata` object; OpenAI rejects it
|
|
// with 400 "Unknown parameter: 'client_metadata'" (9router#1157).
|
|
delete body.client_metadata;
|
|
|
|
// Map max_output_tokens (from Vercel AI SDK) to max_tokens logic
|
|
if (body.max_output_tokens !== undefined) {
|
|
if (body.max_tokens === undefined) {
|
|
body.max_tokens = body.max_output_tokens;
|
|
}
|
|
delete body.max_output_tokens;
|
|
}
|
|
|
|
// Normalize tools to OpenAI format (from Claude, Gemini, etc.)
|
|
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;
|
|
|
|
// Claude format: {name, description, input_schema}
|
|
if (tool.name && (tool.input_schema || tool.description)) {
|
|
return {
|
|
type: "function",
|
|
function: {
|
|
name: tool.name,
|
|
// 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: {} },
|
|
},
|
|
};
|
|
}
|
|
|
|
// Gemini format: {functionDeclarations: [{name, description, parameters}]}
|
|
if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) {
|
|
return tool.functionDeclarations.map((fn) => ({
|
|
type: "function",
|
|
function: {
|
|
name: fn.name,
|
|
description: String(fn.description ?? ""),
|
|
parameters: fn.parameters || { type: "object", properties: {} },
|
|
},
|
|
}));
|
|
}
|
|
|
|
return tool;
|
|
})
|
|
.flat();
|
|
}
|
|
|
|
// Normalize tool_choice to OpenAI format
|
|
if (body.tool_choice && typeof body.tool_choice === "object") {
|
|
const choice = body.tool_choice;
|
|
// Claude format: {type: "auto|required-tool|tool", name?: "..."}
|
|
if (choice.type === "auto") {
|
|
body.tool_choice = "auto";
|
|
} else if (choice.type === CLAUDE_TOOL_CHOICE_REQUIRED) {
|
|
body.tool_choice = "required";
|
|
} else if (choice.type === "tool" && choice.name) {
|
|
body.tool_choice = { type: "function", function: { name: choice.name } };
|
|
}
|
|
}
|
|
|
|
return body;
|
|
}
|