Update jsconfig.json and package.json to correct open-sse path references from relative to local directory.
This commit is contained in:
12
open-sse/translator/formats.js
Normal file
12
open-sse/translator/formats.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// Format identifiers
|
||||
export const FORMATS = {
|
||||
OPENAI: "openai",
|
||||
OPENAI_RESPONSES: "openai-responses",
|
||||
OPENAI_RESPONSE: "openai-response",
|
||||
CLAUDE: "claude",
|
||||
GEMINI: "gemini",
|
||||
GEMINI_CLI: "gemini-cli",
|
||||
CODEX: "codex",
|
||||
ANTIGRAVITY: "antigravity"
|
||||
};
|
||||
|
||||
348
open-sse/translator/from-openai/claude.js
Normal file
348
open-sse/translator/from-openai/claude.js
Normal file
@@ -0,0 +1,348 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
|
||||
// Create OpenAI chunk helper
|
||||
function createChunk(state, delta, finishReason = null) {
|
||||
return {
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta,
|
||||
finish_reason: finishReason
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Convert Claude stream chunk to OpenAI format
|
||||
function claudeToOpenAIResponse(chunk, state) {
|
||||
if (!chunk) return null;
|
||||
|
||||
const results = [];
|
||||
const event = chunk.type;
|
||||
|
||||
switch (event) {
|
||||
case "message_start": {
|
||||
state.messageId = chunk.message?.id || `msg_${Date.now()}`;
|
||||
state.model = chunk.message?.model;
|
||||
state.toolCallIndex = 0; // Reset tool call counter for OpenAI format
|
||||
console.log("🔍 ----------- toolCallIndex", state.toolCallIndex);
|
||||
results.push(createChunk(state, { role: "assistant" }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "content_block_start": {
|
||||
const block = chunk.content_block;
|
||||
if (block?.type === "text") {
|
||||
state.textBlockStarted = true;
|
||||
} else if (block?.type === "thinking") {
|
||||
// console.log("🧠 Thinking block started");
|
||||
state.inThinkingBlock = true;
|
||||
state.currentBlockIndex = chunk.index;
|
||||
results.push(createChunk(state, { content: "<think>" }));
|
||||
} else if (block?.type === "tool_use") {
|
||||
// OpenAI format: tool_calls index must be independent and start from 0
|
||||
const toolCallIndex = state.toolCallIndex++;
|
||||
const toolCall = {
|
||||
index: toolCallIndex,
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: ""
|
||||
}
|
||||
};
|
||||
// Map Claude content_block index to OpenAI tool_call index
|
||||
state.toolCalls.set(chunk.index, toolCall);
|
||||
results.push(createChunk(state, { tool_calls: [toolCall] }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "content_block_delta": {
|
||||
const delta = chunk.delta;
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
results.push(createChunk(state, { content: delta.text }));
|
||||
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
// Stream thinking content
|
||||
results.push(createChunk(state, { content: delta.thinking }));
|
||||
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
|
||||
const toolCall = state.toolCalls.get(chunk.index);
|
||||
if (toolCall) {
|
||||
toolCall.function.arguments += delta.partial_json;
|
||||
// Include both index and id for better client compatibility
|
||||
results.push(createChunk(state, {
|
||||
tool_calls: [{
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
function: { arguments: delta.partial_json }
|
||||
}]
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "content_block_stop": {
|
||||
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
|
||||
// console.log("✅ Thinking block ended");
|
||||
results.push(createChunk(state, { content: "</think>" }));
|
||||
state.inThinkingBlock = false;
|
||||
}
|
||||
state.textBlockStarted = false;
|
||||
state.thinkingBlockStarted = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_delta": {
|
||||
if (chunk.delta?.stop_reason) {
|
||||
state.finishReason = convertStopReason(chunk.delta.stop_reason);
|
||||
|
||||
// Send the final chunk with finish_reason immediately
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: state.finishReason
|
||||
}]
|
||||
});
|
||||
state.finishReasonSent = true;
|
||||
}
|
||||
// Usage is now extracted in stream.js extractUsage()
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_stop": {
|
||||
// CLIProxyAPI and OpenAI standard: message_stop should send the final chunk with finish_reason
|
||||
// This ensures proper signaling to the client that the response is complete
|
||||
|
||||
// Only send a chunk if we haven't already sent the finish_reason in message_delta
|
||||
// In some cases, finish_reason might not have been sent yet
|
||||
if (!state.finishReasonSent) {
|
||||
const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop");
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: finishReason
|
||||
}],
|
||||
...(state.usage && {
|
||||
usage: {
|
||||
prompt_tokens: state.usage.input_tokens || 0,
|
||||
completion_tokens: state.usage.output_tokens || 0,
|
||||
total_tokens: (state.usage.input_tokens || 0) + (state.usage.output_tokens || 0)
|
||||
}
|
||||
})
|
||||
});
|
||||
state.finishReasonSent = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results.length > 0 ? results : null;
|
||||
}
|
||||
|
||||
// Helper: stop thinking block if started
|
||||
function stopThinkingBlock(state, results) {
|
||||
if (!state.thinkingBlockStarted) return;
|
||||
results.push({
|
||||
type: "content_block_stop",
|
||||
index: state.thinkingBlockIndex
|
||||
});
|
||||
state.thinkingBlockStarted = false;
|
||||
}
|
||||
|
||||
// Helper: stop text block if started
|
||||
function stopTextBlock(state, results) {
|
||||
if (!state.textBlockStarted || state.textBlockClosed) return;
|
||||
state.textBlockClosed = true;
|
||||
results.push({
|
||||
type: "content_block_stop",
|
||||
index: state.textBlockIndex
|
||||
});
|
||||
state.textBlockStarted = false;
|
||||
}
|
||||
|
||||
// Convert OpenAI stream chunk to Claude format
|
||||
function openaiToClaudeResponse(chunk, state) {
|
||||
if (!chunk || !chunk.choices?.[0]) return null;
|
||||
|
||||
const results = [];
|
||||
const choice = chunk.choices[0];
|
||||
const delta = choice.delta;
|
||||
|
||||
// First chunk - ALWAYS send message_start first
|
||||
if (!state.messageStartSent) {
|
||||
state.messageStartSent = true;
|
||||
state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
|
||||
if (!state.messageId || state.messageId === "chat" || state.messageId.length < 8) {
|
||||
state.messageId = chunk.extend_fields?.requestId ||
|
||||
chunk.extend_fields?.traceId ||
|
||||
`msg_${Date.now()}`;
|
||||
}
|
||||
state.model = chunk.model || "unknown";
|
||||
state.nextBlockIndex = 0;
|
||||
results.push({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: state.messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: state.model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle reasoning_content (thinking) - GLM, DeepSeek, etc.
|
||||
const reasoningContent = delta?.reasoning_content || delta?.reasoning;
|
||||
if (reasoningContent) {
|
||||
// Stop text block before thinking
|
||||
stopTextBlock(state, results);
|
||||
|
||||
// Start thinking block if needed
|
||||
if (!state.thinkingBlockStarted) {
|
||||
state.thinkingBlockIndex = state.nextBlockIndex++;
|
||||
state.thinkingBlockStarted = true;
|
||||
results.push({
|
||||
type: "content_block_start",
|
||||
index: state.thinkingBlockIndex,
|
||||
content_block: { type: "thinking", thinking: "" }
|
||||
});
|
||||
}
|
||||
|
||||
// Send thinking delta
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
index: state.thinkingBlockIndex,
|
||||
delta: { type: "thinking_delta", thinking: reasoningContent }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle regular content
|
||||
if (delta?.content) {
|
||||
// Stop thinking block before text
|
||||
stopThinkingBlock(state, results);
|
||||
|
||||
// Start text block if needed
|
||||
if (!state.textBlockStarted) {
|
||||
state.textBlockIndex = state.nextBlockIndex++;
|
||||
state.textBlockStarted = true;
|
||||
state.textBlockClosed = false;
|
||||
results.push({
|
||||
type: "content_block_start",
|
||||
index: state.textBlockIndex,
|
||||
content_block: { type: "text", text: "" }
|
||||
});
|
||||
}
|
||||
|
||||
// Send text delta
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
index: state.textBlockIndex,
|
||||
delta: { type: "text_delta", text: delta.content }
|
||||
});
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
|
||||
if (tc.id) {
|
||||
// Stop thinking and text blocks before tool use
|
||||
stopThinkingBlock(state, results);
|
||||
stopTextBlock(state, results);
|
||||
|
||||
// New tool call
|
||||
const toolBlockIndex = state.nextBlockIndex++;
|
||||
state.toolCalls.set(idx, { id: tc.id, name: tc.function?.name || "", blockIndex: toolBlockIndex });
|
||||
results.push({
|
||||
type: "content_block_start",
|
||||
index: toolBlockIndex,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: tc.id,
|
||||
name: tc.function?.name || "",
|
||||
input: {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (tc.function?.arguments) {
|
||||
const toolInfo = state.toolCalls.get(idx);
|
||||
if (toolInfo) {
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
index: toolInfo.blockIndex,
|
||||
delta: { type: "input_json_delta", partial_json: tc.function.arguments }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish
|
||||
if (choice.finish_reason) {
|
||||
// Stop all open blocks
|
||||
stopThinkingBlock(state, results);
|
||||
stopTextBlock(state, results);
|
||||
|
||||
// Close tool call blocks
|
||||
for (const [, toolInfo] of state.toolCalls) {
|
||||
results.push({
|
||||
type: "content_block_stop",
|
||||
index: toolInfo.blockIndex
|
||||
});
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: convertFinishReason(choice.finish_reason) },
|
||||
usage: { output_tokens: 0 }
|
||||
});
|
||||
results.push({ type: "message_stop" });
|
||||
}
|
||||
|
||||
return results.length > 0 ? results : null;
|
||||
}
|
||||
|
||||
// Convert Claude stop_reason to OpenAI finish_reason
|
||||
function convertStopReason(reason) {
|
||||
switch (reason) {
|
||||
case "end_turn": return "stop";
|
||||
case "max_tokens": return "length";
|
||||
case "tool_use": return "tool_calls";
|
||||
case "stop_sequence": return "stop";
|
||||
default: return "stop";
|
||||
}
|
||||
}
|
||||
|
||||
// Convert OpenAI finish_reason to Claude stop_reason
|
||||
function convertFinishReason(reason) {
|
||||
switch (reason) {
|
||||
case "stop": return "end_turn";
|
||||
case "length": return "max_tokens";
|
||||
case "tool_calls": return "tool_use";
|
||||
default: return "end_turn";
|
||||
}
|
||||
}
|
||||
|
||||
// Register
|
||||
register(FORMATS.CLAUDE, FORMATS.OPENAI, null, claudeToOpenAIResponse);
|
||||
register(FORMATS.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse);
|
||||
|
||||
469
open-sse/translator/from-openai/gemini.js
Normal file
469
open-sse/translator/from-openai/gemini.js
Normal file
@@ -0,0 +1,469 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||
import {
|
||||
UNSUPPORTED_SCHEMA_CONSTRAINTS,
|
||||
DEFAULT_SAFETY_SETTINGS,
|
||||
convertOpenAIContentToParts,
|
||||
extractTextContent,
|
||||
tryParseJSON,
|
||||
generateRequestId,
|
||||
generateSessionId,
|
||||
generateProjectId,
|
||||
cleanJSONSchemaForAntigravity
|
||||
} from "../helpers/geminiHelper.js";
|
||||
|
||||
// ============================================
|
||||
// REQUEST TRANSLATORS: OpenAI -> Gemini/GeminiCLI/Antigravity
|
||||
// ============================================
|
||||
|
||||
// Core: Convert OpenAI request to Gemini format (base for all variants)
|
||||
function openaiToGeminiBase(model, body, stream) {
|
||||
const result = {
|
||||
model: model,
|
||||
contents: [],
|
||||
generationConfig: {},
|
||||
safetySettings: DEFAULT_SAFETY_SETTINGS
|
||||
};
|
||||
|
||||
// Generation config
|
||||
if (body.temperature !== undefined) {
|
||||
result.generationConfig.temperature = body.temperature;
|
||||
}
|
||||
if (body.top_p !== undefined) {
|
||||
result.generationConfig.topP = body.top_p;
|
||||
}
|
||||
if (body.top_k !== undefined) {
|
||||
result.generationConfig.topK = body.top_k;
|
||||
}
|
||||
if (body.max_tokens !== undefined) {
|
||||
result.generationConfig.maxOutputTokens = body.max_tokens;
|
||||
}
|
||||
|
||||
// Build tool_call_id -> name map
|
||||
const tcID2Name = {};
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.type === "function" && tc.id && tc.function?.name) {
|
||||
tcID2Name[tc.id] = tc.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build tool responses cache
|
||||
const toolResponses = {};
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "tool" && msg.tool_call_id) {
|
||||
toolResponses[msg.tool_call_id] = msg.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
for (let i = 0; i < body.messages.length; i++) {
|
||||
const msg = body.messages[i];
|
||||
const role = msg.role;
|
||||
const content = msg.content;
|
||||
|
||||
if (role === "system" && body.messages.length > 1) {
|
||||
result.systemInstruction = {
|
||||
role: "user",
|
||||
parts: [{ text: typeof content === "string" ? content : extractTextContent(content) }]
|
||||
};
|
||||
} else if (role === "user" || (role === "system" && body.messages.length === 1)) {
|
||||
const parts = convertOpenAIContentToParts(content);
|
||||
if (parts.length > 0) {
|
||||
result.contents.push({ role: "user", parts });
|
||||
}
|
||||
} else if (role === "assistant") {
|
||||
const parts = [];
|
||||
|
||||
if (content) {
|
||||
const text = typeof content === "string" ? content : extractTextContent(content);
|
||||
if (text) {
|
||||
parts.push({ text });
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
const toolCallIds = [];
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.type !== "function") continue;
|
||||
|
||||
const args = tryParseJSON(tc.function?.arguments || "{}");
|
||||
parts.push({
|
||||
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
|
||||
functionCall: {
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
args: args
|
||||
}
|
||||
});
|
||||
toolCallIds.push(tc.id);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
result.contents.push({ role: "model", parts });
|
||||
}
|
||||
|
||||
// Append function responses - extract name from tool_call_id format "ToolName-timestamp-index"
|
||||
const toolParts = [];
|
||||
for (const fid of toolCallIds) {
|
||||
// Try to get name from tcID2Name map first, then extract from id format
|
||||
let name = tcID2Name[fid];
|
||||
if (!name) {
|
||||
// Extract name from id format: "ToolName-timestamp-index"
|
||||
const idParts = fid.split("-");
|
||||
if (idParts.length > 2) {
|
||||
name = idParts.slice(0, -2).join("-");
|
||||
} else {
|
||||
name = fid;
|
||||
}
|
||||
}
|
||||
|
||||
let resp = toolResponses[fid] || "{}";
|
||||
let parsedResp = tryParseJSON(resp);
|
||||
if (parsedResp === null) {
|
||||
parsedResp = { result: resp };
|
||||
} else if (typeof parsedResp !== "object") {
|
||||
parsedResp = { result: parsedResp };
|
||||
}
|
||||
|
||||
toolParts.push({
|
||||
functionResponse: {
|
||||
id: fid,
|
||||
name: name,
|
||||
response: { result: parsedResp }
|
||||
}
|
||||
});
|
||||
}
|
||||
if (toolParts.length > 0) {
|
||||
result.contents.push({ role: "user", parts: toolParts });
|
||||
}
|
||||
} else if (parts.length > 0) {
|
||||
result.contents.push({ role: "model", parts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
const functionDeclarations = [];
|
||||
for (const t of body.tools) {
|
||||
if (t.type === "function" && t.function) {
|
||||
const fn = t.function;
|
||||
functionDeclarations.push({
|
||||
name: fn.name,
|
||||
description: fn.description || "",
|
||||
parameters: fn.parameters || { type: "object", properties: {} }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (functionDeclarations.length > 0) {
|
||||
result.tools = [{ functionDeclarations }];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// OpenAI -> Gemini (standard API)
|
||||
function openaiToGemini(model, body, stream) {
|
||||
return openaiToGeminiBase(model, body, stream);
|
||||
}
|
||||
|
||||
// OpenAI -> Gemini CLI (Cloud Code Assist)
|
||||
function openaiToGeminiCLI(model, body, stream) {
|
||||
const gemini = openaiToGeminiBase(model, body, stream);
|
||||
const isClaude = model.toLowerCase().includes("claude");
|
||||
|
||||
// Add thinking config for CLI
|
||||
if (body.reasoning_effort) {
|
||||
const budgetMap = { low: 1024, medium: 8192, high: 32768 };
|
||||
const budget = budgetMap[body.reasoning_effort] || 8192;
|
||||
gemini.generationConfig.thinkingConfig = {
|
||||
thinkingBudget: budget,
|
||||
include_thoughts: true
|
||||
};
|
||||
}
|
||||
|
||||
// Thinking config from Claude format
|
||||
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) {
|
||||
gemini.generationConfig.thinkingConfig = {
|
||||
thinkingBudget: body.thinking.budget_tokens,
|
||||
include_thoughts: true
|
||||
};
|
||||
}
|
||||
|
||||
// Clean schema for tools
|
||||
// Claude models: use "parameters" (backend converts parametersJsonSchema -> parameters)
|
||||
// Gemini native: use "parametersJsonSchema" (backend expects this field)
|
||||
if (gemini.tools?.[0]?.functionDeclarations) {
|
||||
for (const fn of gemini.tools[0].functionDeclarations) {
|
||||
if (fn.parameters) {
|
||||
const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters);
|
||||
if (isClaude) {
|
||||
fn.parameters = cleanedSchema;
|
||||
} else {
|
||||
fn.parametersJsonSchema = cleanedSchema;
|
||||
delete fn.parameters;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gemini;
|
||||
}
|
||||
|
||||
// Wrap Gemini CLI format in Cloud Code wrapper
|
||||
function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null) {
|
||||
// Use real project ID if available, otherwise generate random
|
||||
const projectId = credentials?.projectId || generateProjectId();
|
||||
|
||||
return {
|
||||
project: projectId,
|
||||
model: model,
|
||||
userAgent: "gemini-cli",
|
||||
requestId: generateRequestId(),
|
||||
request: {
|
||||
sessionId: generateSessionId(),
|
||||
contents: geminiCLI.contents,
|
||||
systemInstruction: geminiCLI.systemInstruction,
|
||||
generationConfig: geminiCLI.generationConfig,
|
||||
safetySettings: geminiCLI.safetySettings,
|
||||
tools: geminiCLI.tools,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI -> Antigravity (Sandbox Cloud Code with wrapper)
|
||||
function openaiToAntigravity(model, body, stream, credentials = null) {
|
||||
const geminiCLI = openaiToGeminiCLI(model, body, stream);
|
||||
return wrapInCloudCodeEnvelope(model, geminiCLI, credentials);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RESPONSE TRANSLATORS: Gemini/GeminiCLI/Antigravity -> OpenAI
|
||||
// ============================================
|
||||
|
||||
// Core: Convert Gemini response chunk to OpenAI format
|
||||
function geminiToOpenAIResponse(chunk, state) {
|
||||
if (!chunk) return null;
|
||||
|
||||
// Handle Antigravity wrapper
|
||||
const response = chunk.response || chunk;
|
||||
if (!response || !response.candidates?.[0]) return null;
|
||||
|
||||
const results = [];
|
||||
const candidate = response.candidates[0];
|
||||
const content = candidate.content;
|
||||
|
||||
// Initialize state
|
||||
if (!state.messageId) {
|
||||
state.messageId = response.responseId || `msg_${Date.now()}`;
|
||||
state.model = response.modelVersion || "gemini";
|
||||
state.functionIndex = 0;
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: "assistant" },
|
||||
finish_reason: null
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Process parts
|
||||
if (content?.parts) {
|
||||
for (const part of content.parts) {
|
||||
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
||||
const isThought = part.thought === true;
|
||||
|
||||
// Handle thought signature (thinking mode)
|
||||
if (hasThoughtSig) {
|
||||
const hasTextContent = part.text !== undefined && part.text !== "";
|
||||
const hasFunctionCall = !!part.functionCall;
|
||||
// If there's text with thoughtSignature
|
||||
if (hasTextContent) {
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: isThought
|
||||
? { reasoning_content: part.text }
|
||||
: { content: part.text },
|
||||
finish_reason: null
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Process functionCall if exists, then skip to next part
|
||||
if (hasFunctionCall) {
|
||||
const fcName = part.functionCall.name;
|
||||
const fcArgs = part.functionCall.args || {};
|
||||
const toolCallIndex = state.functionIndex++;
|
||||
|
||||
const toolCall = {
|
||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
||||
index: toolCallIndex,
|
||||
type: "function",
|
||||
function: {
|
||||
name: fcName,
|
||||
arguments: JSON.stringify(fcArgs)
|
||||
}
|
||||
};
|
||||
|
||||
state.toolCalls.set(toolCallIndex, toolCall);
|
||||
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { tool_calls: [toolCall] },
|
||||
finish_reason: null
|
||||
}]
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text content (non-thinking) - skip empty text
|
||||
if (part.text !== undefined && part.text !== "") {
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: part.text },
|
||||
finish_reason: null
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Function call
|
||||
if (part.functionCall) {
|
||||
const fcName = part.functionCall.name;
|
||||
const fcArgs = part.functionCall.args || {};
|
||||
const toolCallIndex = state.functionIndex++;
|
||||
|
||||
const toolCall = {
|
||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
||||
index: toolCallIndex,
|
||||
type: "function",
|
||||
function: {
|
||||
name: fcName,
|
||||
arguments: JSON.stringify(fcArgs)
|
||||
}
|
||||
};
|
||||
|
||||
state.toolCalls.set(toolCallIndex, toolCall);
|
||||
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { tool_calls: [toolCall] },
|
||||
finish_reason: null
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Inline data (images)
|
||||
const inlineData = part.inlineData || part.inline_data;
|
||||
if (inlineData?.data) {
|
||||
const mimeType = inlineData.mimeType || inlineData.mime_type || "image/png";
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
images: [{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${mimeType};base64,${inlineData.data}` }
|
||||
}]
|
||||
},
|
||||
finish_reason: null
|
||||
}]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if (candidate.finishReason) {
|
||||
let finishReason = candidate.finishReason.toLowerCase();
|
||||
if (finishReason === "stop" && state.toolCalls.size > 0) {
|
||||
finishReason = "tool_calls";
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: finishReason
|
||||
}]
|
||||
});
|
||||
state.finishReason = finishReason;
|
||||
}
|
||||
|
||||
// Usage metadata
|
||||
const usage = response.usageMetadata || chunk.usageMetadata;
|
||||
if (usage) {
|
||||
const promptTokens = (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0);
|
||||
state.usage = {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: usage.candidatesTokenCount || 0,
|
||||
total_tokens: usage.totalTokenCount || 0
|
||||
};
|
||||
if (usage.thoughtsTokenCount > 0) {
|
||||
state.usage.completion_tokens_details = {
|
||||
reasoning_tokens: usage.thoughtsTokenCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results.length > 0 ? results : null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REGISTER ALL TRANSLATORS
|
||||
// ============================================
|
||||
|
||||
// Request: OpenAI -> Gemini variants
|
||||
register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGemini, null);
|
||||
register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, (model, body, stream, credentials) => wrapInCloudCodeEnvelope(model, openaiToGeminiCLI(model, body, stream), credentials), null);
|
||||
register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravity, null);
|
||||
|
||||
// Response: Gemini variants -> OpenAI (all use same handler)
|
||||
register(FORMATS.GEMINI, FORMATS.OPENAI, null, geminiToOpenAIResponse);
|
||||
register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, null, geminiToOpenAIResponse);
|
||||
register(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, null, geminiToOpenAIResponse);
|
||||
361
open-sse/translator/from-openai/openai-responses.js
Normal file
361
open-sse/translator/from-openai/openai-responses.js
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Translator: OpenAI Chat Completions → OpenAI Responses API (response)
|
||||
* Converts streaming chunks from Chat Completions to Responses API events
|
||||
*/
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
|
||||
/**
|
||||
* Translate OpenAI chunk to Responses API events
|
||||
* @returns {Array} Array of events with { event, data } structure
|
||||
*/
|
||||
function translateResponse(chunk, state) {
|
||||
if (!chunk) {
|
||||
// Flush remaining events
|
||||
return flushEvents(state);
|
||||
}
|
||||
|
||||
if (!chunk.choices?.length) return [];
|
||||
|
||||
const events = [];
|
||||
const nextSeq = () => ++state.seq;
|
||||
|
||||
const emit = (eventType, data) => {
|
||||
data.sequence_number = nextSeq();
|
||||
events.push({ event: eventType, data });
|
||||
};
|
||||
|
||||
const choice = chunk.choices[0];
|
||||
const idx = choice.index || 0;
|
||||
const delta = choice.delta || {};
|
||||
|
||||
// Emit initial events
|
||||
if (!state.started) {
|
||||
state.started = true;
|
||||
state.responseId = chunk.id ? `resp_${chunk.id}` : state.responseId;
|
||||
|
||||
emit("response.created", {
|
||||
type: "response.created",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "in_progress",
|
||||
background: false,
|
||||
error: null,
|
||||
output: []
|
||||
}
|
||||
});
|
||||
|
||||
emit("response.in_progress", {
|
||||
type: "response.in_progress",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "in_progress"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle reasoning_content
|
||||
if (delta.reasoning_content) {
|
||||
startReasoning(state, emit, idx);
|
||||
emitReasoningDelta(state, emit, delta.reasoning_content);
|
||||
}
|
||||
|
||||
// Handle text content
|
||||
if (delta.content) {
|
||||
let content = delta.content;
|
||||
|
||||
if (content.includes("<think>")) {
|
||||
state.inThinking = true;
|
||||
content = content.replace("<think>", "");
|
||||
startReasoning(state, emit, idx);
|
||||
}
|
||||
|
||||
if (content.includes("</think>")) {
|
||||
const parts = content.split("</think>");
|
||||
const thinkPart = parts[0];
|
||||
const textPart = parts.slice(1).join("</think>");
|
||||
if (thinkPart) emitReasoningDelta(state, emit, thinkPart);
|
||||
closeReasoning(state, emit);
|
||||
state.inThinking = false;
|
||||
content = textPart;
|
||||
}
|
||||
|
||||
if (state.inThinking && content) {
|
||||
emitReasoningDelta(state, emit, content);
|
||||
return events;
|
||||
}
|
||||
|
||||
if (content) {
|
||||
emitTextContent(state, emit, idx, content);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_calls
|
||||
if (delta.tool_calls) {
|
||||
closeMessage(state, emit, idx);
|
||||
for (const tc of delta.tool_calls) {
|
||||
emitToolCall(state, emit, tc);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish_reason
|
||||
if (choice.finish_reason) {
|
||||
for (const i in state.msgItemAdded) closeMessage(state, emit, i);
|
||||
closeReasoning(state, emit);
|
||||
for (const i in state.funcCallIds) closeToolCall(state, emit, i);
|
||||
sendCompleted(state, emit);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function startReasoning(state, emit, idx) {
|
||||
if (!state.reasoningId) {
|
||||
state.reasoningId = `rs_${state.responseId}_${idx}`;
|
||||
state.reasoningIndex = idx;
|
||||
|
||||
emit("response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: idx,
|
||||
item: { id: state.reasoningId, type: "reasoning", summary: [] }
|
||||
});
|
||||
|
||||
emit("response.reasoning_summary_part.added", {
|
||||
type: "response.reasoning_summary_part.added",
|
||||
item_id: state.reasoningId,
|
||||
output_index: idx,
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "" }
|
||||
});
|
||||
state.reasoningPartAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function emitReasoningDelta(state, emit, text) {
|
||||
if (!text) return;
|
||||
state.reasoningBuf += text;
|
||||
emit("response.reasoning_summary_text.delta", {
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: state.reasoningId,
|
||||
output_index: state.reasoningIndex,
|
||||
summary_index: 0,
|
||||
delta: text
|
||||
});
|
||||
}
|
||||
|
||||
function closeReasoning(state, emit) {
|
||||
if (state.reasoningId && !state.reasoningDone) {
|
||||
state.reasoningDone = true;
|
||||
|
||||
emit("response.reasoning_summary_text.done", {
|
||||
type: "response.reasoning_summary_text.done",
|
||||
item_id: state.reasoningId,
|
||||
output_index: state.reasoningIndex,
|
||||
summary_index: 0,
|
||||
text: state.reasoningBuf
|
||||
});
|
||||
|
||||
emit("response.reasoning_summary_part.done", {
|
||||
type: "response.reasoning_summary_part.done",
|
||||
item_id: state.reasoningId,
|
||||
output_index: state.reasoningIndex,
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: state.reasoningBuf }
|
||||
});
|
||||
|
||||
emit("response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
output_index: state.reasoningIndex,
|
||||
item: {
|
||||
id: state.reasoningId,
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: state.reasoningBuf }]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function emitTextContent(state, emit, idx, content) {
|
||||
if (!state.msgItemAdded[idx]) {
|
||||
state.msgItemAdded[idx] = true;
|
||||
const msgId = `msg_${state.responseId}_${idx}`;
|
||||
|
||||
emit("response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: idx,
|
||||
item: { id: msgId, type: "message", content: [], role: "assistant" }
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.msgContentAdded[idx]) {
|
||||
state.msgContentAdded[idx] = true;
|
||||
|
||||
emit("response.content_part.added", {
|
||||
type: "response.content_part.added",
|
||||
item_id: `msg_${state.responseId}_${idx}`,
|
||||
output_index: idx,
|
||||
content_index: 0,
|
||||
part: { type: "output_text", annotations: [], logprobs: [], text: "" }
|
||||
});
|
||||
}
|
||||
|
||||
emit("response.output_text.delta", {
|
||||
type: "response.output_text.delta",
|
||||
item_id: `msg_${state.responseId}_${idx}`,
|
||||
output_index: idx,
|
||||
content_index: 0,
|
||||
delta: content,
|
||||
logprobs: []
|
||||
});
|
||||
|
||||
if (!state.msgTextBuf[idx]) state.msgTextBuf[idx] = "";
|
||||
state.msgTextBuf[idx] += content;
|
||||
}
|
||||
|
||||
function closeMessage(state, emit, idx) {
|
||||
if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) {
|
||||
state.msgItemDone[idx] = true;
|
||||
const fullText = state.msgTextBuf[idx] || "";
|
||||
const msgId = `msg_${state.responseId}_${idx}`;
|
||||
|
||||
emit("response.output_text.done", {
|
||||
type: "response.output_text.done",
|
||||
item_id: msgId,
|
||||
output_index: parseInt(idx),
|
||||
content_index: 0,
|
||||
text: fullText,
|
||||
logprobs: []
|
||||
});
|
||||
|
||||
emit("response.content_part.done", {
|
||||
type: "response.content_part.done",
|
||||
item_id: msgId,
|
||||
output_index: parseInt(idx),
|
||||
content_index: 0,
|
||||
part: { type: "output_text", annotations: [], logprobs: [], text: fullText }
|
||||
});
|
||||
|
||||
emit("response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
output_index: parseInt(idx),
|
||||
item: {
|
||||
id: msgId,
|
||||
type: "message",
|
||||
content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }],
|
||||
role: "assistant"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function emitToolCall(state, emit, tc) {
|
||||
const tcIdx = tc.index ?? 0;
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
if (funcName) state.funcNames[tcIdx] = funcName;
|
||||
|
||||
if (!state.funcCallIds[tcIdx] && newCallId) {
|
||||
state.funcCallIds[tcIdx] = newCallId;
|
||||
|
||||
emit("response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: tcIdx,
|
||||
item: {
|
||||
id: `fc_${newCallId}`,
|
||||
type: "function_call",
|
||||
arguments: "",
|
||||
call_id: newCallId,
|
||||
name: state.funcNames[tcIdx] || ""
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = "";
|
||||
|
||||
if (tc.function?.arguments) {
|
||||
const refCallId = state.funcCallIds[tcIdx] || newCallId;
|
||||
if (refCallId) {
|
||||
emit("response.function_call_arguments.delta", {
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: `fc_${refCallId}`,
|
||||
output_index: tcIdx,
|
||||
delta: tc.function.arguments
|
||||
});
|
||||
}
|
||||
state.funcArgsBuf[tcIdx] += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
|
||||
function closeToolCall(state, emit, idx) {
|
||||
const callId = state.funcCallIds[idx];
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
const args = state.funcArgsBuf[idx] || "{}";
|
||||
|
||||
emit("response.function_call_arguments.done", {
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: `fc_${callId}`,
|
||||
output_index: parseInt(idx),
|
||||
arguments: args
|
||||
});
|
||||
|
||||
emit("response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
output_index: parseInt(idx),
|
||||
item: {
|
||||
id: `fc_${callId}`,
|
||||
type: "function_call",
|
||||
arguments: args,
|
||||
call_id: callId,
|
||||
name: state.funcNames[idx] || ""
|
||||
}
|
||||
});
|
||||
|
||||
state.funcItemDone[idx] = true;
|
||||
state.funcArgsDone[idx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function sendCompleted(state, emit) {
|
||||
if (!state.completedSent) {
|
||||
state.completedSent = true;
|
||||
emit("response.completed", {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "completed",
|
||||
background: false,
|
||||
error: null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function flushEvents(state) {
|
||||
if (state.completedSent) return [];
|
||||
|
||||
const events = [];
|
||||
const nextSeq = () => ++state.seq;
|
||||
const emit = (eventType, data) => {
|
||||
data.sequence_number = nextSeq();
|
||||
events.push({ event: eventType, data });
|
||||
};
|
||||
|
||||
for (const i in state.msgItemAdded) closeMessage(state, emit, i);
|
||||
closeReasoning(state, emit);
|
||||
for (const i in state.funcCallIds) closeToolCall(state, emit, i);
|
||||
sendCompleted(state, emit);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Register translator
|
||||
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, null, translateResponse);
|
||||
|
||||
179
open-sse/translator/helpers/claudeHelper.js
Normal file
179
open-sse/translator/helpers/claudeHelper.js
Normal file
@@ -0,0 +1,179 @@
|
||||
// Claude helper functions for translator
|
||||
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||
|
||||
// Check if message has valid non-empty content
|
||||
export function hasValidContent(msg) {
|
||||
if (typeof msg.content === "string" && msg.content.trim()) return true;
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content.some(block =>
|
||||
(block.type === "text" && block.text?.trim()) ||
|
||||
block.type === "tool_use" ||
|
||||
block.type === "tool_result"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fix tool_use/tool_result ordering for Claude API
|
||||
// 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow)
|
||||
// 2. Merge consecutive same-role messages
|
||||
export function fixToolUseOrdering(messages) {
|
||||
if (messages.length <= 1) return messages;
|
||||
|
||||
// Pass 1: Fix assistant messages with tool_use - remove text after tool_use
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
const hasToolUse = msg.content.some(b => b.type === "tool_use");
|
||||
if (hasToolUse) {
|
||||
// Keep only: thinking blocks + tool_use blocks (remove text blocks after tool_use)
|
||||
const newContent = [];
|
||||
let foundToolUse = false;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use") {
|
||||
foundToolUse = true;
|
||||
newContent.push(block);
|
||||
} else if (block.type === "thinking" || block.type === "redacted_thinking") {
|
||||
newContent.push(block);
|
||||
} else if (!foundToolUse) {
|
||||
// Keep text blocks BEFORE tool_use
|
||||
newContent.push(block);
|
||||
}
|
||||
// Skip text blocks AFTER tool_use
|
||||
}
|
||||
|
||||
msg.content = newContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Merge consecutive same-role messages
|
||||
const merged = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const last = merged[merged.length - 1];
|
||||
|
||||
if (last && last.role === msg.role) {
|
||||
// Merge content arrays
|
||||
const lastContent = Array.isArray(last.content) ? last.content : [{ type: "text", text: last.content }];
|
||||
const msgContent = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }];
|
||||
|
||||
// Put tool_result first, then other content
|
||||
const toolResults = [...lastContent.filter(b => b.type === "tool_result"), ...msgContent.filter(b => b.type === "tool_result")];
|
||||
const otherContent = [...lastContent.filter(b => b.type !== "tool_result"), ...msgContent.filter(b => b.type !== "tool_result")];
|
||||
|
||||
last.content = [...toolResults, ...otherContent];
|
||||
} else {
|
||||
// Ensure content is array
|
||||
const content = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }];
|
||||
merged.push({ role: msg.role, content: [...content] });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Prepare request for Claude format endpoints
|
||||
// - Cleanup cache_control
|
||||
// - Filter empty messages
|
||||
// - Add thinking block for Anthropic endpoint (provider === "claude")
|
||||
// - Fix tool_use/tool_result ordering
|
||||
export function prepareClaudeRequest(body, provider = null) {
|
||||
// 1. System: remove all cache_control, add only to last block with ttl 1h
|
||||
if (body.system && Array.isArray(body.system)) {
|
||||
body.system = body.system.map((block, i) => {
|
||||
const { cache_control, ...rest } = block;
|
||||
if (i === body.system.length - 1) {
|
||||
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Messages: process in optimized passes
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
const len = body.messages.length;
|
||||
let filtered = [];
|
||||
|
||||
// Pass 1: remove cache_control + filter empty messages
|
||||
for (let i = 0; i < len; i++) {
|
||||
const msg = body.messages[i];
|
||||
|
||||
// Remove cache_control from content blocks
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
delete block.cache_control;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep final assistant even if empty, otherwise check valid content
|
||||
const isFinalAssistant = i === len - 1 && msg.role === "assistant";
|
||||
if (isFinalAssistant || hasValidContent(msg)) {
|
||||
filtered.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1.5: Fix tool_use/tool_result ordering
|
||||
// Each tool_use must have tool_result in the NEXT message (not same message with other content)
|
||||
filtered = fixToolUseOrdering(filtered);
|
||||
|
||||
body.messages = filtered;
|
||||
|
||||
// Check if thinking is enabled AND last message is from user
|
||||
const lastMessage = filtered[filtered.length - 1];
|
||||
const lastMessageIsUser = lastMessage?.role === "user";
|
||||
const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser;
|
||||
|
||||
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
|
||||
let lastAssistantProcessed = false;
|
||||
for (let i = filtered.length - 1; i >= 0; i--) {
|
||||
const msg = filtered[i];
|
||||
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
// Add cache_control to last block of first (from end) assistant with content
|
||||
if (!lastAssistantProcessed && msg.content.length > 0) {
|
||||
msg.content[msg.content.length - 1].cache_control = { type: "ephemeral" };
|
||||
lastAssistantProcessed = true;
|
||||
}
|
||||
|
||||
// Handle thinking blocks for Anthropic endpoint only
|
||||
if (provider === "claude") {
|
||||
let hasToolUse = false;
|
||||
let hasThinking = false;
|
||||
|
||||
// Always replace signature for all thinking blocks
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
||||
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
|
||||
hasThinking = true;
|
||||
}
|
||||
if (block.type === "tool_use") hasToolUse = true;
|
||||
}
|
||||
|
||||
// Add thinking block if thinking enabled + has tool_use but no thinking
|
||||
if (thinkingEnabled && !hasThinking && hasToolUse) {
|
||||
msg.content.unshift({
|
||||
type: "thinking",
|
||||
thinking: ".",
|
||||
signature: DEFAULT_THINKING_CLAUDE_SIGNATURE
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tools: remove all cache_control, add only to last tool with ttl 1h
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
body.tools = body.tools.map((tool, i) => {
|
||||
const { cache_control, ...rest } = tool;
|
||||
if (i === body.tools.length - 1) {
|
||||
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
131
open-sse/translator/helpers/geminiHelper.js
Normal file
131
open-sse/translator/helpers/geminiHelper.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// Gemini helper functions for translator
|
||||
|
||||
// Unsupported JSON Schema constraints that should be removed for Antigravity
|
||||
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
|
||||
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
|
||||
"pattern", "minItems", "maxItems", "format",
|
||||
"default", "examples", "$schema", "const"
|
||||
];
|
||||
|
||||
// Default safety settings
|
||||
export const DEFAULT_SAFETY_SETTINGS = [
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" }
|
||||
];
|
||||
|
||||
// Convert OpenAI content to Gemini parts
|
||||
export function convertOpenAIContentToParts(content) {
|
||||
const parts = [];
|
||||
|
||||
if (typeof content === "string") {
|
||||
parts.push({ text: content });
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (item.type === "text") {
|
||||
parts.push({ text: item.text });
|
||||
} else if (item.type === "image_url" && item.image_url?.url?.startsWith("data:")) {
|
||||
const match = item.image_url.url.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
parts.push({
|
||||
inlineData: { mime_type: match[1], data: match[2] }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Extract text content from OpenAI content
|
||||
export function extractTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter(c => c.type === "text").map(c => c.text).join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Try parse JSON safely
|
||||
export function tryParseJSON(str) {
|
||||
if (typeof str !== "string") return str;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate request ID
|
||||
export function generateRequestId() {
|
||||
return `agent-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
export function generateSessionId() {
|
||||
return `-${Math.floor(Math.random() * 9000000000000000000)}`;
|
||||
}
|
||||
|
||||
// Generate project ID
|
||||
export function generateProjectId() {
|
||||
const adjectives = ["useful", "bright", "swift", "calm", "bold"];
|
||||
const nouns = ["fuze", "wave", "spark", "flow", "core"];
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
|
||||
}
|
||||
|
||||
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
|
||||
export function cleanJSONSchemaForAntigravity(schema) {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
|
||||
const cleaned = Array.isArray(schema) ? [] : {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (UNSUPPORTED_SCHEMA_CONSTRAINTS.includes(key)) continue;
|
||||
|
||||
// Handle type array like ["string", "null"] - Gemini only supports single type
|
||||
if (key === "type" && Array.isArray(value)) {
|
||||
const nonNullType = value.find(t => t !== "null") || "string";
|
||||
cleaned[key] = nonNullType;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
cleaned[key] = cleanJSONSchemaForAntigravity(value);
|
||||
} else {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup required fields - only keep fields that exist in properties
|
||||
if (cleaned.required && Array.isArray(cleaned.required) && cleaned.properties) {
|
||||
const validRequired = cleaned.required.filter(field =>
|
||||
Object.prototype.hasOwnProperty.call(cleaned.properties, field)
|
||||
);
|
||||
if (validRequired.length === 0) {
|
||||
delete cleaned.required;
|
||||
} else {
|
||||
cleaned.required = validRequired;
|
||||
}
|
||||
}
|
||||
|
||||
// Add placeholder for empty object schemas (Antigravity requirement)
|
||||
if (cleaned.type === "object") {
|
||||
if (!cleaned.properties || Object.keys(cleaned.properties).length === 0) {
|
||||
cleaned.properties = {
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Brief explanation of why you are calling this tool"
|
||||
}
|
||||
};
|
||||
cleaned.required = ["reason"];
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
22
open-sse/translator/helpers/maxTokensHelper.js
Normal file
22
open-sse/translator/helpers/maxTokensHelper.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/constants.js";
|
||||
|
||||
/**
|
||||
* Adjust max_tokens based on request context
|
||||
* @param {object} body - Request body
|
||||
* @returns {number} Adjusted max_tokens
|
||||
*/
|
||||
export function adjustMaxTokens(body) {
|
||||
let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS;
|
||||
|
||||
// Auto-increase for tool calling to prevent truncated arguments
|
||||
// Tool calls with large content (like writing files) need more tokens
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
if (maxTokens < DEFAULT_MIN_TOKENS) {
|
||||
console.log(`[AUTO-ADJUST] max_tokens: ${maxTokens} → ${DEFAULT_MIN_TOKENS} (tool calling detected)`);
|
||||
maxTokens = DEFAULT_MIN_TOKENS;
|
||||
}
|
||||
}
|
||||
|
||||
return maxTokens;
|
||||
}
|
||||
|
||||
80
open-sse/translator/helpers/openaiHelper.js
Normal file
80
open-sse/translator/helpers/openaiHelper.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// OpenAI helper functions for translator
|
||||
|
||||
// Valid OpenAI content block types
|
||||
export const VALID_OPENAI_CONTENT_TYPES = ["text", "image_url", "image"];
|
||||
export const VALID_OPENAI_MESSAGE_TYPES = ["text", "image_url", "image", "tool_calls", "tool_result"];
|
||||
|
||||
// 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 = [];
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
103
open-sse/translator/helpers/responsesApiHelper.js
Normal file
103
open-sse/translator/helpers/responsesApiHelper.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Convert OpenAI Responses API format to standard chat completions format
|
||||
* Responses API uses: { input: [...], instructions: "..." }
|
||||
* Chat API uses: { messages: [...] }
|
||||
*/
|
||||
export function convertResponsesApiFormat(body) {
|
||||
if (!body.input) return body;
|
||||
|
||||
const result = { ...body };
|
||||
result.messages = [];
|
||||
|
||||
// Convert instructions to system message
|
||||
if (body.instructions) {
|
||||
result.messages.push({ role: "system", content: body.instructions });
|
||||
}
|
||||
|
||||
// Group items by conversation turn
|
||||
let currentAssistantMsg = null;
|
||||
let pendingToolCalls = [];
|
||||
let pendingToolResults = [];
|
||||
|
||||
for (const item of body.input) {
|
||||
if (item.type === "message") {
|
||||
// Flush any pending assistant message with tool calls
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
// Flush pending tool results
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const tr of pendingToolResults) {
|
||||
result.messages.push(tr);
|
||||
}
|
||||
pendingToolResults = [];
|
||||
}
|
||||
|
||||
// Convert content: input_text → text, output_text → text
|
||||
const content = Array.isArray(item.content)
|
||||
? item.content.map(c => {
|
||||
if (c.type === "input_text") return { type: "text", text: c.text };
|
||||
if (c.type === "output_text") return { type: "text", text: c.text };
|
||||
return c;
|
||||
})
|
||||
: item.content;
|
||||
result.messages.push({ role: item.role, content });
|
||||
}
|
||||
else if (item.type === "function_call") {
|
||||
// Start or append to assistant message with tool_calls
|
||||
if (!currentAssistantMsg) {
|
||||
currentAssistantMsg = {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: []
|
||||
};
|
||||
}
|
||||
currentAssistantMsg.tool_calls.push({
|
||||
id: item.call_id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name,
|
||||
arguments: item.arguments
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (item.type === "function_call_output") {
|
||||
// Flush assistant message first if exists
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
// Add tool result
|
||||
pendingToolResults.push({
|
||||
role: "tool",
|
||||
tool_call_id: item.call_id,
|
||||
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
|
||||
});
|
||||
}
|
||||
else if (item.type === "reasoning") {
|
||||
// Skip reasoning items - they are for display only
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
}
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const tr of pendingToolResults) {
|
||||
result.messages.push(tr);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
delete result.include;
|
||||
delete result.prompt_cache_key;
|
||||
delete result.store;
|
||||
delete result.reasoning;
|
||||
|
||||
return result;
|
||||
}
|
||||
111
open-sse/translator/helpers/toolCallHelper.js
Normal file
111
open-sse/translator/helpers/toolCallHelper.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// Tool call helper functions for translator
|
||||
|
||||
// Generate unique tool call ID
|
||||
export function generateToolCallId() {
|
||||
return `call_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
// Ensure all tool_calls have id field and arguments is string (some providers require it)
|
||||
export function ensureToolCallIds(body) {
|
||||
if (!body.messages || !Array.isArray(body.messages)) return body;
|
||||
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "assistant" && msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (!tc.id) {
|
||||
tc.id = generateToolCallId();
|
||||
}
|
||||
if (!tc.type) {
|
||||
tc.type = "function";
|
||||
}
|
||||
// Ensure arguments is JSON string, not object
|
||||
if (tc.function?.arguments && typeof tc.function.arguments !== "string") {
|
||||
tc.function.arguments = JSON.stringify(tc.function.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// Get tool_call ids from assistant message (OpenAI format: tool_calls, Claude format: tool_use in content)
|
||||
export function getToolCallIds(msg) {
|
||||
if (msg.role !== "assistant") return [];
|
||||
|
||||
const ids = [];
|
||||
|
||||
// OpenAI format: tool_calls array
|
||||
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.id) ids.push(tc.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Claude format: tool_use blocks in content
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.id) {
|
||||
ids.push(block.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Check if user message has tool_result for given ids (OpenAI format: role=tool, Claude format: tool_result in content)
|
||||
export function hasToolResults(msg, toolCallIds) {
|
||||
if (!msg || !toolCallIds.length) return false;
|
||||
|
||||
// OpenAI format: role = "tool" with tool_call_id
|
||||
if (msg.role === "tool" && msg.tool_call_id) {
|
||||
return toolCallIds.includes(msg.tool_call_id);
|
||||
}
|
||||
|
||||
// Claude format: tool_result blocks in user message content
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_result" && toolCallIds.includes(block.tool_use_id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result
|
||||
export function fixMissingToolResponses(body) {
|
||||
if (!body.messages || !Array.isArray(body.messages)) return body;
|
||||
|
||||
const newMessages = [];
|
||||
|
||||
for (let i = 0; i < body.messages.length; i++) {
|
||||
const msg = body.messages[i];
|
||||
const nextMsg = body.messages[i + 1];
|
||||
|
||||
newMessages.push(msg);
|
||||
|
||||
// Check if this is assistant with tool_calls/tool_use
|
||||
const toolCallIds = getToolCallIds(msg);
|
||||
if (toolCallIds.length === 0) continue;
|
||||
|
||||
// Check if next message has tool_result
|
||||
if (nextMsg && !hasToolResults(nextMsg, toolCallIds)) {
|
||||
// Insert tool responses for each tool_call
|
||||
for (const id of toolCallIds) {
|
||||
// OpenAI format: role = "tool"
|
||||
newMessages.push({
|
||||
role: "tool",
|
||||
tool_call_id: id,
|
||||
content: ""
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.messages = newMessages;
|
||||
return body;
|
||||
}
|
||||
|
||||
167
open-sse/translator/index.js
Normal file
167
open-sse/translator/index.js
Normal file
@@ -0,0 +1,167 @@
|
||||
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 { normalizeThinkingConfig } from "../services/provider.js";
|
||||
|
||||
// Registry for translators
|
||||
const requestRegistry = new Map();
|
||||
const responseRegistry = new Map();
|
||||
|
||||
// Register translator
|
||||
export function register(from, to, requestFn, responseFn) {
|
||||
const key = `${from}:${to}`;
|
||||
if (requestFn) {
|
||||
requestRegistry.set(key, requestFn);
|
||||
}
|
||||
if (responseFn) {
|
||||
responseRegistry.set(key, responseFn);
|
||||
}
|
||||
}
|
||||
|
||||
// Translate request: source -> openai -> target
|
||||
export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null) {
|
||||
let result = body;
|
||||
|
||||
// Normalize thinking config: remove if lastMessage is not user
|
||||
normalizeThinkingConfig(result);
|
||||
|
||||
// Always ensure tool_calls have id (some providers require it)
|
||||
ensureToolCallIds(result);
|
||||
|
||||
// Fix missing tool responses (insert empty tool_result if needed)
|
||||
fixMissingToolResponses(result);
|
||||
|
||||
// If same format, skip translation steps
|
||||
if (sourceFormat !== targetFormat) {
|
||||
// Step 1: source -> openai (if source is not openai)
|
||||
if (sourceFormat !== FORMATS.OPENAI) {
|
||||
const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`);
|
||||
if (toOpenAI) {
|
||||
result = toOpenAI(model, result, stream, credentials);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1.5: Filter to clean OpenAI format (only when target is OpenAI)
|
||||
if (targetFormat === FORMATS.OPENAI) {
|
||||
result = filterToOpenAIFormat(result);
|
||||
}
|
||||
|
||||
// Step 2: openai -> target (if target is not openai)
|
||||
if (targetFormat !== FORMATS.OPENAI) {
|
||||
const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`);
|
||||
if (fromOpenAI) {
|
||||
result = fromOpenAI(model, result, stream, credentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final step: prepare request for Claude format endpoints
|
||||
if (targetFormat === FORMATS.CLAUDE) {
|
||||
result = prepareClaudeRequest(result, provider);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Translate response chunk: target -> openai -> source
|
||||
export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
||||
// If same format, return as-is
|
||||
if (sourceFormat === targetFormat) {
|
||||
return [chunk];
|
||||
}
|
||||
|
||||
let results = [chunk];
|
||||
|
||||
// Step 1: target -> openai (if target is not openai)
|
||||
if (targetFormat !== FORMATS.OPENAI) {
|
||||
const toOpenAI = responseRegistry.get(`${targetFormat}:${FORMATS.OPENAI}`);
|
||||
if (toOpenAI) {
|
||||
results = [];
|
||||
const converted = toOpenAI(chunk, state);
|
||||
if (converted) {
|
||||
results = Array.isArray(converted) ? converted : [converted];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: openai -> source (if source is not openai)
|
||||
if (sourceFormat !== FORMATS.OPENAI) {
|
||||
const fromOpenAI = responseRegistry.get(`${FORMATS.OPENAI}:${sourceFormat}`);
|
||||
if (fromOpenAI) {
|
||||
const finalResults = [];
|
||||
for (const r of results) {
|
||||
const converted = fromOpenAI(r, state);
|
||||
if (converted) {
|
||||
finalResults.push(...(Array.isArray(converted) ? converted : [converted]));
|
||||
}
|
||||
}
|
||||
results = finalResults;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Check if translation needed
|
||||
export function needsTranslation(sourceFormat, targetFormat) {
|
||||
return sourceFormat !== targetFormat;
|
||||
}
|
||||
|
||||
// Initialize state for streaming response based on format
|
||||
export function initState(sourceFormat) {
|
||||
// Base state for all formats
|
||||
const base = {
|
||||
messageId: null,
|
||||
model: null,
|
||||
textBlockStarted: false,
|
||||
thinkingBlockStarted: false,
|
||||
inThinkingBlock: false,
|
||||
currentBlockIndex: null,
|
||||
toolCalls: new Map(),
|
||||
finishReason: null,
|
||||
finishReasonSent: false,
|
||||
usage: null,
|
||||
contentBlockIndex: -1
|
||||
};
|
||||
|
||||
// Add openai-responses specific fields
|
||||
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
return {
|
||||
...base,
|
||||
seq: 0,
|
||||
responseId: `resp_${Date.now()}`,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
started: false,
|
||||
msgTextBuf: {},
|
||||
msgItemAdded: {},
|
||||
msgContentAdded: {},
|
||||
msgItemDone: {},
|
||||
reasoningId: "",
|
||||
reasoningIndex: -1,
|
||||
reasoningBuf: "",
|
||||
reasoningPartAdded: false,
|
||||
reasoningDone: false,
|
||||
inThinking: false,
|
||||
funcArgsBuf: {},
|
||||
funcNames: {},
|
||||
funcCallIds: {},
|
||||
funcArgsDone: {},
|
||||
funcItemDone: {},
|
||||
completedSent: false
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
// Initialize all translators
|
||||
export async function initTranslators() {
|
||||
await import("./to-openai/claude.js");
|
||||
await import("./to-openai/gemini.js");
|
||||
await import("./to-openai/openai.js");
|
||||
await import("./to-openai/openai-responses.js");
|
||||
await import("./from-openai/claude.js");
|
||||
await import("./from-openai/gemini.js");
|
||||
await import("./from-openai/openai-responses.js");
|
||||
}
|
||||
239
open-sse/translator/to-openai/claude.js
Normal file
239
open-sse/translator/to-openai/claude.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
|
||||
|
||||
// Convert Claude request to OpenAI format
|
||||
function claudeToOpenAI(model, body, stream) {
|
||||
const result = {
|
||||
model: model,
|
||||
messages: [],
|
||||
stream: stream
|
||||
};
|
||||
|
||||
// Max tokens
|
||||
if (body.max_tokens) {
|
||||
result.max_tokens = adjustMaxTokens(body);
|
||||
}
|
||||
|
||||
// Temperature
|
||||
if (body.temperature !== undefined) {
|
||||
result.temperature = body.temperature;
|
||||
}
|
||||
|
||||
// System message
|
||||
if (body.system) {
|
||||
const systemContent = Array.isArray(body.system)
|
||||
? body.system.map(s => s.text || "").join("\n")
|
||||
: body.system;
|
||||
|
||||
if (systemContent) {
|
||||
result.messages.push({
|
||||
role: "system",
|
||||
content: systemContent
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
for (let i = 0; i < body.messages.length; i++) {
|
||||
const msg = body.messages[i];
|
||||
const converted = convertClaudeMessage(msg);
|
||||
if (converted) {
|
||||
// Handle array of messages (multiple tool results)
|
||||
if (Array.isArray(converted)) {
|
||||
result.messages.push(...converted);
|
||||
} else {
|
||||
result.messages.push(converted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix missing tool responses - OpenAI requires every tool_call to have a response
|
||||
fixMissingToolResponses(result.messages);
|
||||
|
||||
// Tools
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools.map(tool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.input_schema || { type: "object", properties: {} }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Tool choice
|
||||
if (body.tool_choice) {
|
||||
result.tool_choice = convertToolChoice(body.tool_choice);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fix missing tool responses - add empty responses for tool_calls without responses
|
||||
function fixMissingToolResponses(messages) {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
const toolCallIds = msg.tool_calls.map(tc => tc.id);
|
||||
|
||||
// Collect all tool response IDs that IMMEDIATELY follow this assistant message
|
||||
// Stop at any non-tool message (user or assistant)
|
||||
const respondedIds = new Set();
|
||||
let insertPosition = i + 1;
|
||||
for (let j = i + 1; j < messages.length; j++) {
|
||||
const nextMsg = messages[j];
|
||||
if (nextMsg.role === "tool" && nextMsg.tool_call_id) {
|
||||
respondedIds.add(nextMsg.tool_call_id);
|
||||
insertPosition = j + 1;
|
||||
} else {
|
||||
// Stop at any non-tool message (user or assistant)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find missing responses and insert them
|
||||
const missingIds = toolCallIds.filter(id => !respondedIds.has(id));
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
const missingResponses = missingIds.map(id => ({
|
||||
role: "tool",
|
||||
tool_call_id: id,
|
||||
content: "[No response received]"
|
||||
}));
|
||||
// Insert missing responses at the correct position
|
||||
messages.splice(insertPosition, 0, ...missingResponses);
|
||||
// Adjust index to skip inserted messages
|
||||
i = insertPosition + missingResponses.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert single Claude message - returns single message or array of messages
|
||||
function convertClaudeMessage(msg) {
|
||||
const role = msg.role === "user" || msg.role === "tool" ? "user" : "assistant";
|
||||
|
||||
// Simple string content
|
||||
if (typeof msg.content === "string") {
|
||||
return { role, content: msg.content };
|
||||
}
|
||||
|
||||
// Array content
|
||||
if (Array.isArray(msg.content)) {
|
||||
const parts = [];
|
||||
const toolCalls = [];
|
||||
const toolResults = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
parts.push({ type: "text", text: block.text });
|
||||
break;
|
||||
|
||||
case "image":
|
||||
if (block.source?.type === "base64") {
|
||||
parts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${block.source.media_type};base64,${block.source.data}`
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_use":
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input || {})
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case "tool_result":
|
||||
// Extract actual content from tool_result
|
||||
let resultContent = "";
|
||||
if (typeof block.content === "string") {
|
||||
resultContent = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
// Claude tool_result content can be array of text blocks
|
||||
resultContent = block.content
|
||||
.filter(c => c.type === "text")
|
||||
.map(c => c.text)
|
||||
.join("\n") || JSON.stringify(block.content);
|
||||
} else if (block.content) {
|
||||
resultContent = JSON.stringify(block.content);
|
||||
}
|
||||
|
||||
toolResults.push({
|
||||
role: "tool",
|
||||
tool_call_id: block.tool_use_id,
|
||||
content: resultContent
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If has tool results, return array of tool messages
|
||||
if (toolResults.length > 0) {
|
||||
// Also include text parts as user message if any
|
||||
if (parts.length > 0) {
|
||||
const textContent = parts.length === 1 && parts[0].type === "text"
|
||||
? parts[0].text
|
||||
: parts;
|
||||
return [...toolResults, { role: "user", content: textContent }];
|
||||
}
|
||||
return toolResults;
|
||||
}
|
||||
|
||||
// If has tool calls, return assistant message with tool_calls
|
||||
if (toolCalls.length > 0) {
|
||||
const result = { role: "assistant" };
|
||||
if (parts.length > 0) {
|
||||
result.content = parts.length === 1 && parts[0].type === "text"
|
||||
? parts[0].text
|
||||
: parts;
|
||||
}
|
||||
result.tool_calls = toolCalls;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return content
|
||||
if (parts.length > 0) {
|
||||
return {
|
||||
role,
|
||||
content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts
|
||||
};
|
||||
}
|
||||
|
||||
// Empty content array - return empty string content to keep message in conversation
|
||||
if (msg.content.length === 0) {
|
||||
return { role, content: "" };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert tool choice
|
||||
function convertToolChoice(choice) {
|
||||
if (!choice) return "auto";
|
||||
if (typeof choice === "string") return choice;
|
||||
|
||||
switch (choice.type) {
|
||||
case "auto": return "auto";
|
||||
case "any": return "required";
|
||||
case "tool": return { type: "function", function: { name: choice.name } };
|
||||
default: return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
// Register
|
||||
register(FORMATS.CLAUDE, FORMATS.OPENAI, claudeToOpenAI, null);
|
||||
|
||||
154
open-sse/translator/to-openai/gemini.js
Normal file
154
open-sse/translator/to-openai/gemini.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
|
||||
|
||||
// Convert Gemini request to OpenAI format
|
||||
function geminiToOpenAI(model, body, stream) {
|
||||
const result = {
|
||||
model: model,
|
||||
messages: [],
|
||||
stream: stream
|
||||
};
|
||||
|
||||
// Generation config
|
||||
if (body.generationConfig) {
|
||||
const config = body.generationConfig;
|
||||
if (config.maxOutputTokens) {
|
||||
// Create temporary body object for adjustMaxTokens
|
||||
const tempBody = { max_tokens: config.maxOutputTokens, tools: body.tools };
|
||||
result.max_tokens = adjustMaxTokens(tempBody);
|
||||
}
|
||||
if (config.temperature !== undefined) {
|
||||
result.temperature = config.temperature;
|
||||
}
|
||||
if (config.topP !== undefined) {
|
||||
result.top_p = config.topP;
|
||||
}
|
||||
}
|
||||
|
||||
// System instruction
|
||||
if (body.systemInstruction) {
|
||||
const systemText = extractGeminiText(body.systemInstruction);
|
||||
if (systemText) {
|
||||
result.messages.push({
|
||||
role: "system",
|
||||
content: systemText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert contents to messages
|
||||
if (body.contents && Array.isArray(body.contents)) {
|
||||
for (const content of body.contents) {
|
||||
const converted = convertGeminiContent(content);
|
||||
if (converted) {
|
||||
result.messages.push(converted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tools
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = [];
|
||||
for (const tool of body.tools) {
|
||||
if (tool.functionDeclarations) {
|
||||
for (const func of tool.functionDeclarations) {
|
||||
result.tools.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name: func.name,
|
||||
description: func.description || "",
|
||||
parameters: func.parameters || { type: "object", properties: {} }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert Gemini content to OpenAI message
|
||||
function convertGeminiContent(content) {
|
||||
const role = content.role === "user" ? "user" : "assistant";
|
||||
|
||||
if (!content.parts || !Array.isArray(content.parts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
const toolCalls = [];
|
||||
|
||||
for (const part of content.parts) {
|
||||
// Text
|
||||
if (part.text !== undefined) {
|
||||
parts.push({ type: "text", text: part.text });
|
||||
}
|
||||
|
||||
// Image
|
||||
if (part.inlineData) {
|
||||
parts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function call
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.functionCall.name,
|
||||
arguments: JSON.stringify(part.functionCall.args || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function response - use id if available, fallback to name
|
||||
if (part.functionResponse) {
|
||||
return {
|
||||
role: "tool",
|
||||
tool_call_id: part.functionResponse.id || part.functionResponse.name,
|
||||
content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Has tool calls
|
||||
if (toolCalls.length > 0) {
|
||||
const result = { role: "assistant" };
|
||||
if (parts.length > 0) {
|
||||
result.content = parts.length === 1 ? parts[0].text : parts;
|
||||
}
|
||||
result.tool_calls = toolCalls;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Regular message
|
||||
if (parts.length > 0) {
|
||||
return {
|
||||
role,
|
||||
content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract text from Gemini content
|
||||
function extractGeminiText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (content.parts && Array.isArray(content.parts)) {
|
||||
return content.parts.map(p => p.text || "").join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Register
|
||||
register(FORMATS.GEMINI, FORMATS.OPENAI, geminiToOpenAI, null);
|
||||
register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, geminiToOpenAI, null);
|
||||
|
||||
140
open-sse/translator/to-openai/openai-responses.js
Normal file
140
open-sse/translator/to-openai/openai-responses.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Translator: OpenAI Responses API → OpenAI Chat Completions
|
||||
*
|
||||
* Responses API uses: { input: [...], instructions: "..." }
|
||||
* Chat API uses: { messages: [...] }
|
||||
*/
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
|
||||
/**
|
||||
* Convert OpenAI Responses API request to OpenAI Chat Completions format
|
||||
*/
|
||||
function translateRequest(model, body, stream, credentials) {
|
||||
if (!body.input) return body;
|
||||
|
||||
const result = { ...body };
|
||||
result.messages = [];
|
||||
|
||||
// Convert instructions to system message
|
||||
if (body.instructions) {
|
||||
result.messages.push({ role: "system", content: body.instructions });
|
||||
}
|
||||
|
||||
// Group items by conversation turn
|
||||
let currentAssistantMsg = null;
|
||||
let pendingToolResults = [];
|
||||
|
||||
for (const item of body.input) {
|
||||
if (item.type === "message") {
|
||||
// Flush any pending assistant message with tool calls
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
// Flush pending tool results
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const tr of pendingToolResults) {
|
||||
result.messages.push(tr);
|
||||
}
|
||||
pendingToolResults = [];
|
||||
}
|
||||
|
||||
// Convert content: input_text → text, output_text → text
|
||||
const content = Array.isArray(item.content)
|
||||
? item.content.map(c => {
|
||||
if (c.type === "input_text") return { type: "text", text: c.text };
|
||||
if (c.type === "output_text") return { type: "text", text: c.text };
|
||||
return c;
|
||||
})
|
||||
: item.content;
|
||||
result.messages.push({ role: item.role, content });
|
||||
}
|
||||
else if (item.type === "function_call") {
|
||||
// Start or append to assistant message with tool_calls
|
||||
if (!currentAssistantMsg) {
|
||||
currentAssistantMsg = {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: []
|
||||
};
|
||||
}
|
||||
currentAssistantMsg.tool_calls.push({
|
||||
id: item.call_id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name,
|
||||
arguments: item.arguments
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (item.type === "function_call_output") {
|
||||
// Flush assistant message first if exists
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
// Flush any pending tool results first
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const tr of pendingToolResults) {
|
||||
result.messages.push(tr);
|
||||
}
|
||||
pendingToolResults = [];
|
||||
}
|
||||
// Add tool result immediately (not pending)
|
||||
result.messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: item.call_id,
|
||||
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
|
||||
});
|
||||
}
|
||||
else if (item.type === "reasoning") {
|
||||
// Skip reasoning items - they are for display only
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
}
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const tr of pendingToolResults) {
|
||||
result.messages.push(tr);
|
||||
}
|
||||
}
|
||||
|
||||
// Tools are already in OpenAI format, just keep them
|
||||
// Responses API tools: { type: "function", name, description, parameters }
|
||||
// OpenAI tools: { type: "function", function: { name, description, parameters } }
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools.map(tool => {
|
||||
// Already has function wrapper
|
||||
if (tool.function) return tool;
|
||||
// Responses API format: flatten to OpenAI format
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
strict: tool.strict
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
delete result.include;
|
||||
delete result.prompt_cache_key;
|
||||
delete result.store;
|
||||
delete result.reasoning;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Register translator
|
||||
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, translateRequest, null);
|
||||
|
||||
372
open-sse/translator/to-openai/openai.js
Normal file
372
open-sse/translator/to-openai/openai.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { CLAUDE_SYSTEM_PROMPT } from "../../config/constants.js";
|
||||
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
|
||||
|
||||
// Convert OpenAI request to Claude format
|
||||
function openaiToClaude(model, body, stream) {
|
||||
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 === "system") {
|
||||
systemParts.push(typeof msg.content === "string" ? msg.content : extractTextContent(msg.content));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out system messages for separate processing
|
||||
const nonSystemMessages = body.messages.filter(m => m.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 === "user" || msg.role === "tool") ? "user" : "assistant";
|
||||
const blocks = getContentBlocksFromMessage(msg);
|
||||
const hasToolUse = blocks.some(b => b.type === "tool_use");
|
||||
const hasToolResult = blocks.some(b => b.type === "tool_result");
|
||||
|
||||
// Separate tool_result from other content
|
||||
if (hasToolResult) {
|
||||
const toolResultBlocks = blocks.filter(b => b.type === "tool_result");
|
||||
const otherBlocks = blocks.filter(b => b.type !== "tool_result");
|
||||
|
||||
// Flush current message first
|
||||
flushCurrentMessage();
|
||||
|
||||
// Add tool_result as separate user message
|
||||
if (toolResultBlocks.length > 0) {
|
||||
result.messages.push({ role: "user", content: toolResultBlocks });
|
||||
}
|
||||
|
||||
// Add other blocks to current parts for next message
|
||||
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 (like worker.old)
|
||||
for (let i = result.messages.length - 1; i >= 0; i--) {
|
||||
const message = result.messages[i];
|
||||
if (message.role === "assistant" && Array.isArray(message.content) && message.content.length > 0) {
|
||||
const lastBlock = message.content[message.content.length - 1];
|
||||
if (lastBlock) {
|
||||
lastBlock.cache_control = { type: "ephemeral" };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System with Claude Code prompt and cache_control
|
||||
const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT };
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
const systemText = systemParts.join("\n");
|
||||
result.system = [
|
||||
claudeCodePrompt,
|
||||
{ type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } }
|
||||
];
|
||||
} else {
|
||||
result.system = [claudeCodePrompt];
|
||||
}
|
||||
|
||||
// Tools - convert from OpenAI format to Claude format
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools.map(tool => {
|
||||
// Handle both OpenAI format {type: "function", function: {...}} and direct format
|
||||
const toolData = tool.type === "function" && tool.function ? tool.function : tool;
|
||||
return {
|
||||
name: toolData.name,
|
||||
description: toolData.description || "",
|
||||
input_schema: toolData.parameters || toolData.input_schema || { type: "object", properties: {}, required: [] }
|
||||
};
|
||||
});
|
||||
|
||||
// Add cache control to last tool (like worker.old)
|
||||
if (result.tools.length > 0) {
|
||||
result.tools[result.tools.length - 1].cache_control = { type: "ephemeral", ttl: "1h" };
|
||||
}
|
||||
|
||||
// console.log("[CLAUDE TOOLS DEBUG] Converted tools:", result.tools.map(t => t.name));
|
||||
}
|
||||
|
||||
// Tool choice
|
||||
if (body.tool_choice) {
|
||||
result.tool_choice = convertOpenAIToolChoice(body.tool_choice);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert OpenAI request to Gemini format
|
||||
function openaiToGemini(model, body, stream) {
|
||||
const result = {
|
||||
contents: [],
|
||||
generationConfig: {}
|
||||
};
|
||||
|
||||
// Generation config
|
||||
if (body.max_tokens) {
|
||||
result.generationConfig.maxOutputTokens = body.max_tokens;
|
||||
}
|
||||
if (body.temperature !== undefined) {
|
||||
result.generationConfig.temperature = body.temperature;
|
||||
}
|
||||
if (body.top_p !== undefined) {
|
||||
result.generationConfig.topP = body.top_p;
|
||||
}
|
||||
|
||||
// Messages
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "system") {
|
||||
result.systemInstruction = {
|
||||
parts: [{ text: typeof msg.content === "string" ? msg.content : extractTextContent(msg.content) }]
|
||||
};
|
||||
} else if (msg.role === "tool") {
|
||||
result.contents.push({
|
||||
role: "function",
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name: msg.tool_call_id,
|
||||
response: tryParseJSON(msg.content)
|
||||
}
|
||||
}]
|
||||
});
|
||||
} else {
|
||||
const converted = convertOpenAIToGeminiContent(msg);
|
||||
if (converted) {
|
||||
result.contents.push(converted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tools
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
const validTools = body.tools.filter(tool => tool && tool.function && tool.function.name);
|
||||
if (validTools.length > 0) {
|
||||
result.tools = [{
|
||||
functionDeclarations: validTools.map(tool => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description || "",
|
||||
parameters: tool.function.parameters || { type: "object", properties: {} }
|
||||
}))
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get content blocks from single message (like src.cc getContentBlocksFromMessage)
|
||||
function getContentBlocksFromMessage(msg) {
|
||||
const blocks = [];
|
||||
|
||||
if (msg.role === "tool") {
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: msg.tool_call_id,
|
||||
content: msg.content
|
||||
});
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.content) {
|
||||
blocks.push({ type: "text", text: msg.content });
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && part.text) {
|
||||
blocks.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "tool_result") {
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.tool_use_id,
|
||||
content: part.content,
|
||||
...(part.is_error && { is_error: part.is_error })
|
||||
});
|
||||
} else if (part.type === "image_url") {
|
||||
const url = part.image_url.url;
|
||||
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: match[1], data: match[2] }
|
||||
});
|
||||
}
|
||||
} else if (part.type === "image" && part.source) {
|
||||
blocks.push({ type: "image", source: part.source });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
// Handle Anthropic format: content is array with tool_use blocks
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && part.text) {
|
||||
blocks.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "tool_use") {
|
||||
blocks.push({ type: "tool_use", id: part.id, name: part.name, input: part.input });
|
||||
}
|
||||
}
|
||||
} else if (msg.content) {
|
||||
const text = typeof msg.content === "string" ? msg.content : extractTextContent(msg.content);
|
||||
if (text) {
|
||||
blocks.push({ type: "text", text });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OpenAI format: tool_calls array
|
||||
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.type === "function") {
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
input: tryParseJSON(tc.function.arguments)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// Convert single OpenAI message to Claude format (for backward compatibility)
|
||||
function convertOpenAIMessage(msg) {
|
||||
const role = msg.role === "assistant" ? "assistant" : "user";
|
||||
const content = convertOpenAIMessageContent(msg);
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
return { role, content };
|
||||
}
|
||||
|
||||
// Convert OpenAI message to Gemini content
|
||||
function convertOpenAIToGeminiContent(msg) {
|
||||
const role = msg.role === "assistant" ? "model" : "user";
|
||||
const parts = [];
|
||||
|
||||
// Text content
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.content) {
|
||||
parts.push({ text: msg.content });
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") {
|
||||
parts.push({ text: part.text });
|
||||
} else if (part.type === "image_url") {
|
||||
const url = part.image_url.url;
|
||||
if (url.startsWith("data:")) {
|
||||
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: match[1],
|
||||
data: match[2]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
parts.push({
|
||||
functionCall: {
|
||||
name: tc.function.name,
|
||||
args: tryParseJSON(tc.function.arguments)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return { role, parts };
|
||||
}
|
||||
|
||||
// Convert tool choice
|
||||
function convertOpenAIToolChoice(choice) {
|
||||
if (!choice) return { type: "auto" };
|
||||
// Passthrough if already Claude format
|
||||
if (typeof choice === "object" && choice.type) return choice;
|
||||
if (choice === "auto" || choice === "none") return { type: "auto" };
|
||||
if (choice === "required") return { type: "any" };
|
||||
if (typeof choice === "object" && choice.function) {
|
||||
return { type: "tool", name: choice.function.name };
|
||||
}
|
||||
return { type: "auto" };
|
||||
}
|
||||
|
||||
// Extract text from content
|
||||
function extractTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter(c => c.type === "text").map(c => c.text).join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Try parse JSON
|
||||
function tryParseJSON(str) {
|
||||
if (typeof str !== "string") return str;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
// Register
|
||||
register(FORMATS.OPENAI, FORMATS.CLAUDE, openaiToClaude, null);
|
||||
register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGemini, null);
|
||||
register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, openaiToGemini, null);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user