diff --git a/.gitignore b/.gitignore index da000a3..28c8ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,7 @@ RM.md cursor/* PUBLIC.md scripts/* +Thanks.md +package.json +PUBLIC.en.md +PR/* diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 2bc2a0e..fb1526f 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -70,6 +70,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred let translatedBody = body; translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider); + // Extract toolNameMap for response translation (Claude OAuth) + const toolNameMap = translatedBody._toolNameMap; + delete translatedBody._toolNameMap; // Update model in body translatedBody.model = model; @@ -251,7 +254,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Create transform stream with logger for streaming response let transformStream; if (needsTranslation(targetFormat, sourceFormat)) { - transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger); + transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap); } else { transformStream = createPassthroughStreamWithLogger(provider, reqLogger); } diff --git a/open-sse/translator/from-openai/claude.js b/open-sse/translator/from-openai/claude.js deleted file mode 100644 index 474ebd6..0000000 --- a/open-sse/translator/from-openai/claude.js +++ /dev/null @@ -1,348 +0,0 @@ -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: "" })); - } 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: "" })); - 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); - diff --git a/open-sse/translator/from-openai/gemini.js b/open-sse/translator/from-openai/gemini.js deleted file mode 100644 index fabe5b8..0000000 --- a/open-sse/translator/from-openai/gemini.js +++ /dev/null @@ -1,469 +0,0 @@ -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); diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index dce842c..b209166 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -51,7 +51,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream if (targetFormat !== FORMATS.OPENAI) { const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`); if (fromOpenAI) { - result = fromOpenAI(model, result, stream, credentials); + result = fromOpenAI(model, result, stream); } } } @@ -157,11 +157,16 @@ export function initState(sourceFormat) { // 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"); + // Request translators + await import("./request/claude-to-openai.js"); + await import("./request/openai-to-claude.js"); + await import("./request/gemini-to-openai.js"); + await import("./request/openai-to-gemini.js"); + await import("./request/openai-responses.js"); + + // Response translators + await import("./response/claude-to-openai.js"); + await import("./response/openai-to-claude.js"); + await import("./response/gemini-to-openai.js"); + await import("./response/openai-responses.js"); } diff --git a/open-sse/translator/to-openai/claude.js b/open-sse/translator/request/claude-to-openai.js similarity index 91% rename from open-sse/translator/to-openai/claude.js rename to open-sse/translator/request/claude-to-openai.js index 70a00c6..98d6006 100644 --- a/open-sse/translator/to-openai/claude.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -3,7 +3,7 @@ import { FORMATS } from "../formats.js"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; // Convert Claude request to OpenAI format -function claudeToOpenAI(model, body, stream) { +function claudeToOpenAIRequest(model, body, stream) { const result = { model: model, messages: [], @@ -81,7 +81,6 @@ function fixMissingToolResponses(messages) { 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++) { @@ -90,7 +89,6 @@ function fixMissingToolResponses(messages) { respondedIds.add(nextMsg.tool_call_id); insertPosition = j + 1; } else { - // Stop at any non-tool message (user or assistant) break; } } @@ -104,9 +102,7 @@ function fixMissingToolResponses(messages) { 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; } } @@ -157,12 +153,10 @@ function convertClaudeMessage(msg) { 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) @@ -182,7 +176,6 @@ function convertClaudeMessage(msg) { // 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 @@ -212,7 +205,7 @@ function convertClaudeMessage(msg) { }; } - // Empty content array - return empty string content to keep message in conversation + // Empty content array if (msg.content.length === 0) { return { role, content: "" }; } @@ -235,5 +228,5 @@ function convertToolChoice(choice) { } // Register -register(FORMATS.CLAUDE, FORMATS.OPENAI, claudeToOpenAI, null); +register(FORMATS.CLAUDE, FORMATS.OPENAI, claudeToOpenAIRequest, null); diff --git a/open-sse/translator/to-openai/gemini.js b/open-sse/translator/request/gemini-to-openai.js similarity index 90% rename from open-sse/translator/to-openai/gemini.js rename to open-sse/translator/request/gemini-to-openai.js index 74eebde..87f8883 100644 --- a/open-sse/translator/to-openai/gemini.js +++ b/open-sse/translator/request/gemini-to-openai.js @@ -3,7 +3,7 @@ import { FORMATS } from "../formats.js"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; // Convert Gemini request to OpenAI format -function geminiToOpenAI(model, body, stream) { +function geminiToOpenAIRequest(model, body, stream) { const result = { model: model, messages: [], @@ -14,7 +14,6 @@ function geminiToOpenAI(model, body, stream) { 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); } @@ -81,12 +80,10 @@ function convertGeminiContent(content) { 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", @@ -96,7 +93,6 @@ function convertGeminiContent(content) { }); } - // Function call if (part.functionCall) { toolCalls.push({ id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, @@ -108,7 +104,6 @@ function convertGeminiContent(content) { }); } - // Function response - use id if available, fallback to name if (part.functionResponse) { return { role: "tool", @@ -118,7 +113,6 @@ function convertGeminiContent(content) { } } - // Has tool calls if (toolCalls.length > 0) { const result = { role: "assistant" }; if (parts.length > 0) { @@ -128,7 +122,6 @@ function convertGeminiContent(content) { return result; } - // Regular message if (parts.length > 0) { return { role, @@ -149,6 +142,6 @@ function extractGeminiText(content) { } // Register -register(FORMATS.GEMINI, FORMATS.OPENAI, geminiToOpenAI, null); -register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, geminiToOpenAI, null); +register(FORMATS.GEMINI, FORMATS.OPENAI, geminiToOpenAIRequest, null); +register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, geminiToOpenAIRequest, null); diff --git a/open-sse/translator/to-openai/openai-responses.js b/open-sse/translator/request/openai-responses.js similarity index 87% rename from open-sse/translator/to-openai/openai-responses.js rename to open-sse/translator/request/openai-responses.js index 2346482..b1cda76 100644 --- a/open-sse/translator/to-openai/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -10,7 +10,7 @@ import { FORMATS } from "../formats.js"; /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ -function translateRequest(model, body, stream, credentials) { +function openaiResponsesToOpenAIRequest(model, body, stream, credentials) { if (!body.input) return body; const result = { ...body }; @@ -81,7 +81,7 @@ function translateRequest(model, body, stream, credentials) { } pendingToolResults = []; } - // Add tool result immediately (not pending) + // Add tool result immediately result.messages.push({ role: "tool", tool_call_id: item.call_id, @@ -104,14 +104,10 @@ function translateRequest(model, body, stream, credentials) { } } - // 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 } } + // Convert tools format 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: { @@ -135,6 +131,6 @@ function translateRequest(model, body, stream, credentials) { return result; } -// Register translator -register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, translateRequest, null); +// Register +register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null); diff --git a/open-sse/translator/to-openai/openai.js b/open-sse/translator/request/openai-to-claude.js similarity index 62% rename from open-sse/translator/to-openai/openai.js rename to open-sse/translator/request/openai-to-claude.js index 3b34b7a..25a87a8 100644 --- a/open-sse/translator/to-openai/openai.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -4,7 +4,9 @@ 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) { +function openaiToClaudeRequest(model, body, stream) { + // Tool name mapping for Claude OAuth (capitalizedName → originalName) + const toolNameMap = new Map(); const result = { model: model, max_tokens: adjustMaxTokens(body), @@ -27,10 +29,10 @@ function openaiToClaude(model, body, stream) { 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; @@ -54,15 +56,12 @@ function openaiToClaude(model, body, stream) { 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); @@ -83,8 +82,8 @@ function openaiToClaude(model, body, stream) { } flushCurrentMessage(); - - // Add cache_control to last assistant message (like worker.old) + + // Add cache_control to last assistant message for (let i = result.messages.length - 1; i >= 0; i--) { const message = result.messages[i]; if (message.role === "assistant" && Array.isArray(message.content) && message.content.length > 0) { @@ -99,7 +98,7 @@ function openaiToClaude(model, body, stream) { // 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 = [ @@ -113,21 +112,27 @@ function openaiToClaude(model, body, stream) { // 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; + const originalName = toolData.name; + + // Claude requires capitalized tool names + const toolName = originalName.charAt(0).toUpperCase() + originalName.slice(1); + + // Store mapping for response translation + if (toolName !== originalName) { + toolNameMap.set(toolName, originalName); + } + return { - name: toolData.name, + name: toolName, 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 @@ -135,71 +140,15 @@ function openaiToClaude(model, body, stream) { 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: {} } - })) - }]; - } + // Attach toolNameMap to result for response translation + if (toolNameMap.size > 0) { + result._toolNameMap = toolNameMap; } return result; } -// Get content blocks from single message (like src.cc getContentBlocksFromMessage) +// Get content blocks from single message function getContentBlocksFromMessage(msg) { const blocks = []; @@ -240,7 +189,6 @@ function getContentBlocksFromMessage(msg) { } } } 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) { @@ -256,7 +204,6 @@ function getContentBlocksFromMessage(msg) { } } - // 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") { @@ -274,68 +221,9 @@ function getContentBlocksFromMessage(msg) { 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 +// Convert OpenAI tool choice to Claude format 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" }; @@ -365,8 +253,5 @@ function tryParseJSON(str) { } // Register -register(FORMATS.OPENAI, FORMATS.CLAUDE, openaiToClaude, null); -register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGemini, null); -register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, openaiToGemini, null); - +register(FORMATS.OPENAI, FORMATS.CLAUDE, openaiToClaudeRequest, null); diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js new file mode 100644 index 0000000..e5c4bab --- /dev/null +++ b/open-sse/translator/request/openai-to-gemini.js @@ -0,0 +1,247 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; +import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js"; +import { + DEFAULT_SAFETY_SETTINGS, + convertOpenAIContentToParts, + extractTextContent, + tryParseJSON, + generateRequestId, + generateSessionId, + generateProjectId, + cleanJSONSchemaForAntigravity +} from "../helpers/geminiHelper.js"; + +// 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 + const toolParts = []; + for (const fid of toolCallIds) { + let name = tcID2Name[fid]; + if (!name) { + 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 openaiToGeminiRequest(model, body, stream) { + return openaiToGeminiBase(model, body, stream); +} + +// OpenAI -> Gemini CLI (Cloud Code Assist) +function openaiToGeminiCLIRequest(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 + 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) { + 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 openaiToAntigravityRequest(model, body, stream, credentials = null) { + const geminiCLI = openaiToGeminiCLIRequest(model, body, stream); + return wrapInCloudCodeEnvelope(model, geminiCLI, credentials); +} + +// Register +register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGeminiRequest, null); +register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, (model, body, stream, credentials) => wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials), null); +register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null); + diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js new file mode 100644 index 0000000..a9b5939 --- /dev/null +++ b/open-sse/translator/response/claude-to-openai.js @@ -0,0 +1,157 @@ +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; + 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") { + state.inThinkingBlock = true; + state.currentBlockIndex = chunk.index; + results.push(createChunk(state, { content: "" })); + } else if (block?.type === "tool_use") { + const toolCallIndex = state.toolCallIndex++; + // Restore original tool name from mapping (Claude OAuth) + const toolName = state.toolNameMap?.get(block.name) || block.name; + const toolCall = { + index: toolCallIndex, + id: block.id, + type: "function", + function: { + name: toolName, + arguments: "" + } + }; + 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) { + 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; + 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) { + results.push(createChunk(state, { content: "" })); + 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); + 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; + } + break; + } + + case "message_stop": { + 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; +} + +// 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"; + } +} + +// Register +register(FORMATS.CLAUDE, FORMATS.OPENAI, null, claudeToOpenAIResponse); + diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.js new file mode 100644 index 0000000..1a73b7a --- /dev/null +++ b/open-sse/translator/response/gemini-to-openai.js @@ -0,0 +1,207 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +// 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 (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 + }] + }); + } + + 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) + 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 +register(FORMATS.GEMINI, FORMATS.OPENAI, null, geminiToOpenAIResponse); +register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, null, geminiToOpenAIResponse); +register(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, null, geminiToOpenAIResponse); + diff --git a/open-sse/translator/from-openai/openai-responses.js b/open-sse/translator/response/openai-responses.js similarity index 98% rename from open-sse/translator/from-openai/openai-responses.js rename to open-sse/translator/response/openai-responses.js index b4b3e26..5c8c0f8 100644 --- a/open-sse/translator/from-openai/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -9,9 +9,8 @@ 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) { +function openaiToOpenAIResponsesResponse(chunk, state) { if (!chunk) { - // Flush remaining events return flushEvents(state); } @@ -356,6 +355,6 @@ function flushEvents(state) { return events; } -// Register translator -register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, null, translateResponse); +// Register +register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, null, openaiToOpenAIResponsesResponse); diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js new file mode 100644 index 0000000..f030754 --- /dev/null +++ b/open-sse/translator/response/openai-to-claude.js @@ -0,0 +1,174 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +// 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) { + stopTextBlock(state, results); + + if (!state.thinkingBlockStarted) { + state.thinkingBlockIndex = state.nextBlockIndex++; + state.thinkingBlockStarted = true; + results.push({ + type: "content_block_start", + index: state.thinkingBlockIndex, + content_block: { type: "thinking", thinking: "" } + }); + } + + results.push({ + type: "content_block_delta", + index: state.thinkingBlockIndex, + delta: { type: "thinking_delta", thinking: reasoningContent } + }); + } + + // Handle regular content + if (delta?.content) { + stopThinkingBlock(state, results); + + 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: "" } + }); + } + + 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) { + stopThinkingBlock(state, results); + stopTextBlock(state, results); + + 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) { + stopThinkingBlock(state, results); + stopTextBlock(state, results); + + 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 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.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse); + diff --git a/open-sse/utils/bypassHandler.js b/open-sse/utils/bypassHandler.js index 8ead68f..0e0d461 100644 --- a/open-sse/utils/bypassHandler.js +++ b/open-sse/utils/bypassHandler.js @@ -33,12 +33,12 @@ export function handleBypassRequest(body, model) { if (firstText === "Warmup") shouldBypass = true; // Check count pattern: [{"role":"user","content":"count"}] - if (!shouldBypass && - messages.length === 1 && - messages[0]?.role === "user" && - firstText === "count") { - shouldBypass = true; - } + // if (!shouldBypass && + // messages.length === 1 && + // messages[0]?.role === "user" && + // firstText === "count") { + // shouldBypass = true; + // } // Check skip patterns if (!shouldBypass && SKIP_PATTERNS?.length) { @@ -54,8 +54,10 @@ export function handleBypassRequest(body, model) { // Create bypass response using translator if (stream) { + console.log("createStreamingResponse", sourceFormat, model); return createStreamingResponse(sourceFormat, model); } else { + console.log("createNonStreamingResponse", sourceFormat, model); return createNonStreamingResponse(sourceFormat, model); } } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index ad7ce10..646b023 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -1,9 +1,9 @@ import { translateResponse, initState } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; -// Get HH:MM timestamp +// Get HH:MM:SS timestamp function getTimeString() { - return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); + return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); } // Extract usage from any format (Claude, OpenAI, Gemini) @@ -128,7 +128,8 @@ export function createSSEStream(options = {}) { targetFormat, sourceFormat, provider = null, - reqLogger = null + reqLogger = null, + toolNameMap = null } = options; const decoder = new TextDecoder(); @@ -137,7 +138,7 @@ export function createSSEStream(options = {}) { let usage = null; // State for translate mode - const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider } : null; + const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap } : null; return new TransformStream({ transform(chunk, controller) { @@ -258,13 +259,14 @@ export function createSSEStream(options = {}) { } // Convenience functions for backward compatibility -export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null) { +export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, targetFormat, sourceFormat, provider, - reqLogger + reqLogger, + toolNameMap }); } diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 70ee6d2..f9d8b06 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -1,8 +1,8 @@ // Stream handler with disconnect detection - shared for all providers -// Get HH:MM timestamp +// Get HH:MM:SS timestamp function getTimeString() { - return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); + return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); } /** diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 8ac22cd..bc65bb9 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -4,19 +4,9 @@ import { v4 as uuidv4 } from "uuid"; import path from "path"; import os from "os"; import fs from "fs"; -import { fileURLToPath } from "url"; - -// Get app name from root package.json config +// Get app name - fixed constant to avoid Windows path issues in standalone build function getAppName() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - // Look for root package.json (monorepo root) - const rootPkgPath = path.resolve(__dirname, "../../../package.json"); - try { - const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8")); - return pkg.config?.appName || "9router"; - } catch { - return "9router"; - } + return "9router"; } // Get user data directory based on platform diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index ad01d03..c37e671 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -31,12 +31,24 @@ export async function handleChat(request, clientRawRequest = null) { }; } + // Log request endpoint and model + const url = new URL(request.url); + const modelStr = body.model; + // Count messages (support both messages[] and input[] formats) const msgCount = body.messages?.length || body.input?.length || 0; const toolCount = body.tools?.length || 0; - log.request("POST", `${body.model} | ${msgCount} msgs${toolCount ? ` | ${toolCount} tools` : ""}`); + log.request("POST", `${url.pathname} | ${modelStr} | ${msgCount} msgs${toolCount ? ` | ${toolCount} tools` : ""}`); + + // Log API key (masked) + const apiKey = request.headers.get("Authorization"); + if (apiKey) { + const masked = log.maskKey(apiKey.replace("Bearer ", "")); + log.debug("AUTH", `API Key: ${masked}`); + } else { + log.debug("AUTH", "No API key provided (local mode)"); + } - const modelStr = body.model; if (!modelStr) { log.warn("CHAT", "Missing model"); return errorResponse(400, "Missing model"); @@ -70,6 +82,13 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { const { provider, model } = modelInfo; + // Log model routing (alias → actual model) + if (modelStr !== `${provider}/${model}`) { + log.info("ROUTING", `${modelStr} → ${provider}/${model}`); + } else { + log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); + } + // Try with available accounts (fallback on errors) let excludeConnectionId = null; let lastError = null; @@ -78,6 +97,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { const credentials = await getProviderCredentials(provider, excludeConnectionId); if (!credentials) { if (!excludeConnectionId) { + log.error("AUTH", `No credentials for provider: ${provider}`); return errorResponse(400, `No credentials for provider: ${provider}`); } log.warn("CHAT", "No more accounts available", { provider }); @@ -87,7 +107,9 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { ); } - log.debug("CHAT", `Using account ${credentials.connectionId} for ${provider}`); + // Log account selection + const accountId = credentials.connectionId.slice(0, 8); + log.info("AUTH", `Using ${provider} account: ${accountId}...`); const refreshedCredentials = await checkAndRefreshToken(provider, credentials); @@ -118,11 +140,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { const { shouldFallback, cooldownMs } = checkFallbackError(result.status, result.error); if (shouldFallback) { - log.warn("CHAT", "Account unavailable, trying next", { - provider, - connectionId: credentials.connectionId, - status: result.status - }); + const accountId = credentials.connectionId.slice(0, 8); + log.warn("AUTH", `Account ${accountId}... unavailable (status: ${result.status}), trying fallback`); await markAccountUnavailable(credentials.connectionId, cooldownMs, result.error?.slice(0, 100), result.status, provider); excludeConnectionId = credentials.connectionId; lastError = result.error;