Files
9router/open-sse/translator/request/openai-to-claude.js
T
decoluaandCursor d3f61aac2f refactor(open-sse): translator DRY + schema enums, bug fixes, dead code cleanup
- Bug B1-B7: media UI m.kind||m.type, serviceKinds, gemini mediaPriority, schema kind, models/info lookup by kind
- Dead code D1-D6: safeParseJSON, drop PROVIDER_ENDPOINTS, orphan fetcher, GITHUB_CONFIG derive, getProviderConfig internal, legacy kiro file
- Translator concerns: toOpenAIUsage, toOpenAIFinish (gemini/kiro/ollama + fix kiro tool finish), thinking effort maps
- Reorg helpers/ → concerns/ (logic) + formats/ (per-format) + schema/ (pure enums: roles/blocks/finishReasons/defaults)
- Wire ~280 hardcoded role/block/finish/default literals to schema enums across 20+ files
- collapseTextParts + extractTextContent dedup
- Normalize translator fn names to openaiToXRequest / xToOpenAIResponse
- Golden tests lock behavior; 0 regression (byte-for-byte providers/alias, 26=26 known fails)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 18:49:38 +07:00

380 lines
13 KiB
JavaScript

import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.js";
import { adjustMaxTokens } from "../formats/maxTokens.js";
import { safeParseJSON } from "../concerns/json.js";
import { parseDataUri } from "../concerns/image.js";
import { effortToBudget } from "../concerns/thinking.js";
import { extractTextContent } from "../formats/gemini.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
// Empty prefix matches real Claude Code behavior (no tool name prefix).
// Previously "proxy_" was used but this is a detectable fingerprint difference.
const CLAUDE_OAUTH_TOOL_PREFIX = "";
// Convert OpenAI request to Claude format
export function openaiToClaudeRequest(model, body, stream) {
// Tool name mapping for Claude OAuth (capitalizedName → originalName)
const toolNameMap = new Map();
const result = {
model: model,
max_tokens: adjustMaxTokens(body),
stream: stream
};
// Temperature
if (body.temperature !== undefined) {
result.temperature = body.temperature;
}
// Messages
result.messages = [];
const systemParts = [];
if (body.messages && Array.isArray(body.messages)) {
// Extract system messages
for (const msg of body.messages) {
if (msg.role === ROLE.SYSTEM) {
systemParts.push(typeof msg.content === "string" ? msg.content : extractTextContent(msg.content, "\n"));
}
}
// Filter out system messages for separate processing
const nonSystemMessages = body.messages.filter(m => m.role !== ROLE.SYSTEM);
// Process messages with merging logic
// CRITICAL: tool_result must be in separate message immediately after tool_use
let currentRole = undefined;
let currentParts = [];
const flushCurrentMessage = () => {
if (currentRole && currentParts.length > 0) {
result.messages.push({ role: currentRole, content: currentParts });
currentParts = [];
}
};
for (const msg of nonSystemMessages) {
const newRole = (msg.role === ROLE.USER || msg.role === ROLE.TOOL) ? ROLE.USER : ROLE.ASSISTANT;
const blocks = getContentBlocksFromMessage(msg, toolNameMap);
const hasToolUse = blocks.some(b => b.type === CLAUDE_BLOCK.TOOL_USE);
const hasToolResult = blocks.some(b => b.type === CLAUDE_BLOCK.TOOL_RESULT);
// Separate tool_result from other content
if (hasToolResult) {
const toolResultBlocks = blocks.filter(b => b.type === CLAUDE_BLOCK.TOOL_RESULT);
const otherBlocks = blocks.filter(b => b.type !== CLAUDE_BLOCK.TOOL_RESULT);
flushCurrentMessage();
if (toolResultBlocks.length > 0) {
result.messages.push({ role: ROLE.USER, content: toolResultBlocks });
}
if (otherBlocks.length > 0) {
currentRole = newRole;
currentParts.push(...otherBlocks);
}
continue;
}
if (currentRole !== newRole) {
flushCurrentMessage();
currentRole = newRole;
}
currentParts.push(...blocks);
if (hasToolUse) {
flushCurrentMessage();
}
}
flushCurrentMessage();
// Add cache_control to last assistant message
for (let i = result.messages.length - 1; i >= 0; i--) {
const message = result.messages[i];
if (message.role === ROLE.ASSISTANT && Array.isArray(message.content) && message.content.length > 0) {
// Find the last block that can have cache_control (not thinking blocks)
const validBlockTypes = [CLAUDE_BLOCK.TEXT, CLAUDE_BLOCK.TOOL_USE, CLAUDE_BLOCK.TOOL_RESULT, CLAUDE_BLOCK.IMAGE];
for (let j = message.content.length - 1; j >= 0; j--) {
const block = message.content[j];
if (validBlockTypes.includes(block.type)) {
block.cache_control = { type: "ephemeral" };
break;
}
}
break;
}
}
}
// Handle response_format for JSON mode
if (body.response_format) {
const responseFormat = body.response_format;
if (responseFormat.type === "json_schema" && responseFormat.json_schema?.schema) {
const schemaJson = JSON.stringify(responseFormat.json_schema.schema, null, 2);
systemParts.push(`You must respond with valid JSON that strictly follows this JSON schema:
\`\`\`json
${schemaJson}
\`\`\`
Respond ONLY with the JSON object, no other text.`);
} else if (responseFormat.type === "json_object") {
systemParts.push("You must respond with valid JSON. Respond ONLY with a JSON object, no other text.");
}
}
// System with Claude Code prompt and cache_control
const claudeCodePrompt = { type: CLAUDE_BLOCK.TEXT, text: CLAUDE_SYSTEM_PROMPT };
if (systemParts.length > 0) {
const systemText = systemParts.join("\n");
result.system = [
claudeCodePrompt,
{ type: CLAUDE_BLOCK.TEXT, text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } }
];
} else {
result.system = [claudeCodePrompt];
}
// Tools - convert from OpenAI format to Claude format with prefix for OAuth
if (body.tools && Array.isArray(body.tools)) {
result.tools = [];
for (const tool of body.tools) {
// Pass-through built-in tools (e.g. web_search_20250305) without prefix or conversion
const toolType = tool.type;
if (toolType && toolType !== OPENAI_BLOCK.FUNCTION) {
result.tools.push(tool);
continue;
}
const toolData = toolType === OPENAI_BLOCK.FUNCTION && tool.function ? tool.function : tool;
const originalName = toolData.name;
// Claude OAuth requires prefixed tool names to avoid conflicts
const toolName = CLAUDE_OAUTH_TOOL_PREFIX + originalName;
// Store mapping for response translation (prefixed → original)
toolNameMap.set(toolName, originalName);
result.tools.push({
name: toolName,
description: toolData.description || "",
input_schema: toolData.parameters || toolData.input_schema || { type: "object", properties: {}, required: [] }
});
}
if (result.tools.length > 0) {
result.tools[result.tools.length - 1].cache_control = { type: "ephemeral", ttl: "1h" };
}
}
// Tool choice
if (body.tool_choice) {
result.tool_choice = convertOpenAIToolChoice(body.tool_choice);
}
// Thinking configuration
if (body.thinking) {
result.thinking = {
type: body.thinking.type || "enabled",
...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }),
...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens })
};
}
// Map OpenAI reasoning_effort → Claude thinking.budget_tokens
// When client sends reasoning_effort (OpenAI format) but no explicit thinking block,
// translate to Claude's native format.
if (body.reasoning_effort && !result.thinking) {
const budget = effortToBudget(body.reasoning_effort);
if (budget) {
result.thinking = { type: "enabled", budget_tokens: budget };
}
// budget === 0 (none) or undefined (unknown) → no thinking
}
// Attach toolNameMap to result for response translation
if (toolNameMap.size > 0) {
result._toolNameMap = toolNameMap;
}
return result;
}
// Get content blocks from single message
function getContentBlocksFromMessage(msg, toolNameMap = new Map()) {
const blocks = [];
if (msg.role === ROLE.TOOL) {
blocks.push({
type: CLAUDE_BLOCK.TOOL_RESULT,
tool_use_id: msg.tool_call_id,
content: msg.content
});
} else if (msg.role === ROLE.USER) {
if (typeof msg.content === "string") {
if (msg.content) {
blocks.push({ type: CLAUDE_BLOCK.TEXT, text: msg.content });
}
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === OPENAI_BLOCK.TEXT && part.text) {
blocks.push({ type: CLAUDE_BLOCK.TEXT, text: part.text });
} else if (part.type === CLAUDE_BLOCK.TOOL_RESULT) {
blocks.push({
type: CLAUDE_BLOCK.TOOL_RESULT,
tool_use_id: part.tool_use_id,
content: part.content,
...(part.is_error && { is_error: part.is_error })
});
} else if (part.type === OPENAI_BLOCK.IMAGE_URL) {
const url = part.image_url.url;
const parsed = parseDataUri(url);
if (parsed) {
blocks.push({
type: CLAUDE_BLOCK.IMAGE,
source: { type: "base64", media_type: parsed.mimeType, data: parsed.base64 }
});
} else if (url.startsWith("http://") || url.startsWith("https://")) {
blocks.push({
type: CLAUDE_BLOCK.IMAGE,
source: { type: "url", url }
});
}
} else if (part.type === OPENAI_BLOCK.IMAGE && part.source) {
blocks.push({ type: CLAUDE_BLOCK.IMAGE, source: part.source });
}
}
}
} else if (msg.role === ROLE.ASSISTANT) {
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === OPENAI_BLOCK.TEXT && part.text) {
blocks.push({ type: CLAUDE_BLOCK.TEXT, text: part.text });
} else if (part.type === CLAUDE_BLOCK.TOOL_USE) {
// Tool name already has prefix from tool declarations, keep as-is
blocks.push({ type: CLAUDE_BLOCK.TOOL_USE, id: part.id, name: part.name, input: part.input });
} else if (part.type === CLAUDE_BLOCK.THINKING) {
// Include thinking block but strip cache_control (not allowed on thinking blocks)
const { cache_control, ...thinkingBlock } = part;
blocks.push(thinkingBlock);
}
}
} else if (msg.content) {
const text = typeof msg.content === "string" ? msg.content : extractTextContent(msg.content, "\n");
if (text) {
blocks.push({ type: CLAUDE_BLOCK.TEXT, text });
}
}
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (tc.type === OPENAI_BLOCK.FUNCTION) {
// Apply prefix to tool name
const toolName = CLAUDE_OAUTH_TOOL_PREFIX + tc.function.name;
blocks.push({
type: CLAUDE_BLOCK.TOOL_USE,
id: tc.id,
name: toolName,
input: safeParseJSON(tc.function.arguments, tc.function.arguments)
});
}
}
}
}
return blocks;
}
// Convert OpenAI tool choice to Claude format.
// Claude only accepts tool_choice.type of "auto" | "any" | "tool" | "none";
// anything else (e.g. OpenAI's "function") triggers a 400, so we never pass an
// unrecognized type through.
const CLAUDE_TOOL_CHOICE_TYPES = new Set(["auto", "any", "tool", "none"]);
function convertOpenAIToolChoice(choice) {
if (!choice) return { type: "auto" };
// OpenAI string forms: "auto" | "none" | "required"
if (typeof choice === "string") {
if (choice === "required") return { type: "any" };
return { type: "auto" }; // "auto", "none", or anything unexpected
}
if (typeof choice === "object") {
// OpenAI forced tool: { type: "function", function: { name } }.
// Checked before the native pass-through below, because the OpenAI shape
// also carries a `.type` ("function") that Claude rejects.
if (choice.function?.name) {
return { type: "tool", name: choice.function.name };
}
// Already Claude-native — only pass through types Claude actually accepts,
// so a malformed or unknown type can never leak into the upstream request.
if (CLAUDE_TOOL_CHOICE_TYPES.has(choice.type)) {
return choice;
}
}
return { type: "auto" };
}
// OpenAI -> Claude format for Antigravity (without system prompt modifications)
function openaiToClaudeRequestForAntigravity(model, body, stream) {
const result = openaiToClaudeRequest(model, body, stream);
// Remove Claude Code system prompt, keep only user's system messages
if (result.system && Array.isArray(result.system)) {
result.system = result.system.filter(block =>
!block.text || !block.text.includes("You are Claude Code")
);
if (result.system.length === 0) {
delete result.system;
}
}
// Strip prefix from tool names for Antigravity (doesn't use Claude OAuth)
if (result.tools && Array.isArray(result.tools)) {
result.tools = result.tools.map(tool => {
if (tool.name && tool.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) {
return {
...tool,
name: tool.name.slice(CLAUDE_OAUTH_TOOL_PREFIX.length)
};
}
return tool;
});
}
// Strip prefix from tool_use in messages
if (result.messages && Array.isArray(result.messages)) {
result.messages = result.messages.map(msg => {
if (!msg.content || !Array.isArray(msg.content)) {
return msg;
}
const updatedContent = msg.content.map(block => {
if (block.type === CLAUDE_BLOCK.TOOL_USE && block.name && block.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) {
return {
...block,
name: block.name.slice(CLAUDE_OAUTH_TOOL_PREFIX.length)
};
}
return block;
});
return { ...msg, content: updatedContent };
});
}
return result;
}
// Export for use in other translators
export { openaiToClaudeRequestForAntigravity };
// Register
register(FORMATS.OPENAI, FORMATS.CLAUDE, openaiToClaudeRequest, null);