mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
470 lines
15 KiB
JavaScript
470 lines
15 KiB
JavaScript
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);
|