1480 lines
55 KiB
JavaScript
1480 lines
55 KiB
JavaScript
import { v4 as uuidv4 } from 'uuid';
|
||
import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js';
|
||
|
||
// 定义默认常量
|
||
const DEFAULT_MAX_TOKENS = 8192;
|
||
const DEFAULT_GEMINI_MAX_TOKENS = 65536;
|
||
const DEFAULT_TEMPERATURE = 1;
|
||
const DEFAULT_TOP_P = 0.9;
|
||
|
||
// 辅助函数:判断值是否为 undefined 或 0,并返回默认值
|
||
function checkAndAssignOrDefault(value, defaultValue) {
|
||
if (value !== undefined && value !== 0) {
|
||
return value;
|
||
}
|
||
return defaultValue;
|
||
}
|
||
|
||
/**
|
||
* Generic data conversion function.
|
||
* @param {object} data - The data to convert (request body or response).
|
||
* @param {string} type - The type of conversion: 'request', 'response', 'streamChunk', 'modelList'.
|
||
* @param {string} fromProvider - The source model provider (e.g., MODEL_PROVIDER.GEMINI_CLI).
|
||
* @param {string} toProvider - The target model provider (e.g., MODEL_PROVIDER.OPENAI_CUSTOM).
|
||
* @param {string} [model] - Optional model name for response conversions.
|
||
* @returns {object} The converted data.
|
||
* @throws {Error} If no suitable conversion function is found.
|
||
*/
|
||
export function convertData(data, type, fromProvider, toProvider, model) {
|
||
// Define a map of conversion functions using protocol prefixes
|
||
const conversionMap = {
|
||
request: {
|
||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIRequestFromGemini, // from Gemini protocol
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIRequestFromClaude, // from Claude protocol
|
||
},
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
||
[MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeRequestFromOpenAI, // from OpenAI protocol
|
||
},
|
||
[MODEL_PROTOCOL_PREFIX.GEMINI]: { // to Gemini protocol
|
||
[MODEL_PROTOCOL_PREFIX.OPENAI]: toGeminiRequestFromOpenAI, // from OpenAI protocol
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toGeminiRequestFromClaude, // from Claude protocol
|
||
},
|
||
},
|
||
response: {
|
||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIChatCompletionFromGemini, // from Gemini protocol
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIChatCompletionFromClaude, // from Claude protocol
|
||
},
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeChatCompletionFromGemini, // from Gemini protocol
|
||
},
|
||
},
|
||
streamChunk: {
|
||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIStreamChunkFromGemini, // from Gemini protocol
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIStreamChunkFromClaude, // from Claude protocol
|
||
},
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeStreamChunkFromGemini, // from Gemini protocol
|
||
},
|
||
},
|
||
modelList: {
|
||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIModelListFromGemini, // from Gemini protocol
|
||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIModelListFromClaude, // from Claude protocol
|
||
},
|
||
}
|
||
};
|
||
|
||
const targetConversions = conversionMap[type];
|
||
if (!targetConversions) {
|
||
throw new Error(`Unsupported conversion type: ${type}`);
|
||
}
|
||
|
||
const toConversions = targetConversions[getProtocolPrefix(toProvider)];
|
||
if (!toConversions) {
|
||
throw new Error(`No conversions defined for target protocol: ${getProtocolPrefix(toProvider)} for type: ${type}`);
|
||
}
|
||
|
||
const conversionFunction = toConversions[getProtocolPrefix(fromProvider)];
|
||
if (!conversionFunction) {
|
||
throw new Error(`No conversion function found from ${fromProvider} to ${toProvider} for type: ${type}`);
|
||
}
|
||
|
||
console.log(conversionFunction);
|
||
if (type === 'response' || type === 'streamChunk' || type === 'modelList') {
|
||
return conversionFunction(data, model);
|
||
} else {
|
||
return conversionFunction(data);
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Converts a Gemini API request body to an OpenAI chat completion request body.
|
||
* Handles system instructions and role mapping with multimodal support.
|
||
* @param {Object} geminiRequest - The request body from the Gemini API.
|
||
* @returns {Object} The formatted request body for the OpenAI API.
|
||
*/
|
||
export function toOpenAIRequestFromGemini(geminiRequest) {
|
||
const openaiRequest = {
|
||
messages: [],
|
||
model: geminiRequest.model || "gpt-3.5-turbo", // Default model if not specified in Gemini request
|
||
max_tokens: checkAndAssignOrDefault(geminiRequest.max_tokens, DEFAULT_MAX_TOKENS),
|
||
temperature: checkAndAssignOrDefault(geminiRequest.temperature, DEFAULT_TEMPERATURE),
|
||
top_p: checkAndAssignOrDefault(geminiRequest.top_p, DEFAULT_TOP_P),
|
||
};
|
||
|
||
// Process system instruction
|
||
if (geminiRequest.systemInstruction && Array.isArray(geminiRequest.systemInstruction.parts)) {
|
||
const systemContent = processGeminiPartsToOpenAIContent(geminiRequest.systemInstruction.parts);
|
||
if (systemContent) {
|
||
openaiRequest.messages.push({
|
||
role: 'system',
|
||
content: systemContent
|
||
});
|
||
}
|
||
}
|
||
|
||
// Process contents
|
||
if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) {
|
||
geminiRequest.contents.forEach(content => {
|
||
if (content && Array.isArray(content.parts)) {
|
||
const openaiContent = processGeminiPartsToOpenAIContent(content.parts);
|
||
if (openaiContent && openaiContent.length > 0) {
|
||
const openaiRole = content.role === 'model' ? 'assistant' : content.role;
|
||
openaiRequest.messages.push({
|
||
role: openaiRole,
|
||
content: openaiContent
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
return openaiRequest;
|
||
}
|
||
|
||
/**
|
||
* Processes Gemini parts to OpenAI content format with multimodal support.
|
||
* @param {Array} parts - Array of Gemini parts.
|
||
* @returns {Array|string} OpenAI content format.
|
||
*/
|
||
function processGeminiPartsToOpenAIContent(parts) {
|
||
if (!parts || !Array.isArray(parts)) return '';
|
||
|
||
const contentArray = [];
|
||
|
||
parts.forEach(part => {
|
||
if (!part) return;
|
||
|
||
// Handle text content
|
||
if (typeof part.text === 'string') {
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: part.text
|
||
});
|
||
}
|
||
|
||
// Handle inline data (images, audio)
|
||
if (part.inlineData) {
|
||
const { mimeType, data } = part.inlineData;
|
||
if (mimeType && data) {
|
||
contentArray.push({
|
||
type: 'image_url',
|
||
image_url: {
|
||
url: `data:${mimeType};base64,${data}`
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle file data
|
||
if (part.fileData) {
|
||
const { mimeType, fileUri } = part.fileData;
|
||
if (mimeType && fileUri) {
|
||
// For file URIs, we need to determine if it's an image or audio
|
||
if (mimeType.startsWith('image/')) {
|
||
contentArray.push({
|
||
type: 'image_url',
|
||
image_url: {
|
||
url: fileUri
|
||
}
|
||
});
|
||
} else if (mimeType.startsWith('audio/')) {
|
||
// For audio, we'll use a placeholder or handle as text description
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: `[Audio file: ${fileUri}]`
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Return as array for multimodal, or string for simple text
|
||
return contentArray.length === 1 && contentArray[0].type === 'text'
|
||
? contentArray[0].text
|
||
: contentArray;
|
||
}
|
||
|
||
|
||
export function toOpenAIModelListFromGemini(geminiModels) {
|
||
return {
|
||
object: "list",
|
||
data: geminiModels.models.map(m => ({
|
||
id: m.name.startsWith('models/') ? m.name.substring(7) : m.name, // 移除 'models/' 前缀作为 id
|
||
object: "model",
|
||
created: Math.floor(Date.now() / 1000),
|
||
owned_by: "google",
|
||
})),
|
||
};
|
||
}
|
||
|
||
export function toOpenAIChatCompletionFromGemini(geminiResponse, model) {
|
||
const content = processGeminiResponseContent(geminiResponse);
|
||
|
||
return {
|
||
id: `chatcmpl-${uuidv4()}`,
|
||
object: "chat.completion",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
choices: [{
|
||
index: 0,
|
||
message: {
|
||
role: "assistant",
|
||
content: content
|
||
},
|
||
finish_reason: "stop",
|
||
}],
|
||
usage: geminiResponse.usageMetadata ? {
|
||
prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0,
|
||
completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0,
|
||
total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0,
|
||
} : {
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
total_tokens: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Processes Gemini response content to OpenAI format with multimodal support.
|
||
* @param {Object} geminiResponse - The Gemini API response.
|
||
* @returns {string|Array} Processed content.
|
||
*/
|
||
function processGeminiResponseContent(geminiResponse) {
|
||
if (!geminiResponse || !geminiResponse.candidates) return '';
|
||
|
||
const contents = [];
|
||
|
||
geminiResponse.candidates.forEach(candidate => {
|
||
if (candidate.content && candidate.content.parts) {
|
||
candidate.content.parts.forEach(part => {
|
||
if (part.text) {
|
||
contents.push(part.text);
|
||
}
|
||
// Note: Gemini response typically doesn't include multimodal content in responses
|
||
// but we handle it for completeness
|
||
});
|
||
}
|
||
});
|
||
|
||
return contents.join('\n');
|
||
}
|
||
|
||
export function toOpenAIStreamChunkFromGemini(geminiChunk, model) {
|
||
return {
|
||
id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
choices: [{
|
||
index: 0,
|
||
delta: { content: geminiChunk },
|
||
finish_reason: null,
|
||
}],
|
||
usage: geminiChunk.usageMetadata ? {
|
||
prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
||
completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0,
|
||
total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0,
|
||
} : {
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
total_tokens: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Converts a Claude API messages response to an OpenAI chat completion response.
|
||
* @param {Object} claudeResponse - The Claude API messages response object.
|
||
* @param {string} model - The model name to include in the response.
|
||
* @returns {Object} The formatted OpenAI chat completion response.
|
||
*/
|
||
export function toOpenAIChatCompletionFromClaude(claudeResponse, model) {
|
||
if (!claudeResponse || !claudeResponse.content || claudeResponse.content.length === 0) {
|
||
return {
|
||
id: `chatcmpl-${uuidv4()}`,
|
||
object: "chat.completion",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
choices: [{
|
||
index: 0,
|
||
message: {
|
||
role: "assistant",
|
||
content: "",
|
||
},
|
||
finish_reason: "stop",
|
||
}],
|
||
usage: {
|
||
prompt_tokens: claudeResponse.usage?.input_tokens || 0,
|
||
completion_tokens: claudeResponse.usage?.output_tokens || 0,
|
||
total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0),
|
||
},
|
||
};
|
||
}
|
||
|
||
const content = processClaudeResponseContent(claudeResponse.content);
|
||
const finishReason = claudeResponse.stop_reason === 'end_turn' ? 'stop' : claudeResponse.stop_reason;
|
||
|
||
return {
|
||
id: `chatcmpl-${uuidv4()}`,
|
||
object: "chat.completion",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
choices: [{
|
||
index: 0,
|
||
message: {
|
||
role: "assistant",
|
||
content: content
|
||
},
|
||
finish_reason: finishReason,
|
||
}],
|
||
usage: {
|
||
prompt_tokens: claudeResponse.usage?.input_tokens || 0,
|
||
completion_tokens: claudeResponse.usage?.output_tokens || 0,
|
||
total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Processes Claude response content to OpenAI format with multimodal support.
|
||
* @param {Array} content - Array of Claude content blocks.
|
||
* @returns {string|Array} Processed content.
|
||
*/
|
||
function processClaudeResponseContent(content) {
|
||
if (!content || !Array.isArray(content)) return '';
|
||
|
||
const contentArray = [];
|
||
|
||
content.forEach(block => {
|
||
if (!block) return;
|
||
|
||
switch (block.type) {
|
||
case 'text':
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: block.text || ''
|
||
});
|
||
break;
|
||
|
||
case 'image':
|
||
// Handle image blocks from Claude
|
||
if (block.source && block.source.type === 'base64') {
|
||
contentArray.push({
|
||
type: 'image_url',
|
||
image_url: {
|
||
url: `data:${block.source.media_type};base64,${block.source.data}`
|
||
}
|
||
});
|
||
}
|
||
break;
|
||
|
||
default:
|
||
// Handle other content types as text
|
||
if (block.text) {
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: block.text
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Return as array for multimodal, or string for simple text
|
||
return contentArray.length === 1 && contentArray[0].type === 'text'
|
||
? contentArray[0].text
|
||
: contentArray;
|
||
}
|
||
|
||
/**
|
||
* Converts a Claude API messages stream chunk to an OpenAI chat completion stream chunk.
|
||
* Based on the official Claude Messages API stream events.
|
||
* @param {Object} claudeChunk - The Claude API messages stream chunk object.
|
||
* @param {string} [model] - Optional model name to include in the response.
|
||
* @returns {Object} The formatted OpenAI chat completion stream chunk, or an empty object for events that don't map.
|
||
*/
|
||
export function toOpenAIStreamChunkFromClaude(claudeChunk, model) {
|
||
if (!claudeChunk) {
|
||
return null;
|
||
}
|
||
return {
|
||
id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
system_fingerprint: "",
|
||
choices: [{
|
||
index: 0,
|
||
delta: {
|
||
content: claudeChunk,
|
||
reasoning_content: ""
|
||
},
|
||
finish_reason: !claudeChunk ? 'stop' : null,
|
||
message: {
|
||
content: claudeChunk,
|
||
reasoning_content: ""
|
||
}
|
||
}],
|
||
usage:{
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
total_tokens: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function getOpenAIStreamChunkStop(model) {
|
||
return {
|
||
id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled
|
||
object: "chat.completion.chunk",
|
||
created: Math.floor(Date.now() / 1000),
|
||
model: model,
|
||
system_fingerprint: "",
|
||
choices: [{
|
||
index: 0,
|
||
delta: {
|
||
content: "",
|
||
reasoning_content: ""
|
||
},
|
||
finish_reason: 'stop',
|
||
message: {
|
||
content: "",
|
||
reasoning_content: ""
|
||
}
|
||
}],
|
||
usage:{
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
total_tokens: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Converts a Claude API model list response to an OpenAI model list response.
|
||
* @param {Array<Object>} claudeModels - The array of model objects from Claude API.
|
||
* @returns {Object} The formatted OpenAI model list response.
|
||
*/
|
||
export function toOpenAIModelListFromClaude(claudeModels) {
|
||
return {
|
||
object: "list",
|
||
data: claudeModels.models.map(m => ({
|
||
id: m.id || m.name, // Claude models might use 'name' instead of 'id'
|
||
object: "model",
|
||
created: Math.floor(Date.now() / 1000), // Claude may not provide 'created' timestamp
|
||
owned_by: "anthropic",
|
||
// You can add more properties here if they exist in Claude's model response
|
||
// and you want to map them to OpenAI's format, e.g., permissions.
|
||
})),
|
||
};
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Converts a Claude API request body to an OpenAI chat completion request body.
|
||
* Handles system instructions and multimodal content.
|
||
* @param {Object} claudeRequest - The request body from the Claude API.
|
||
* @returns {Object} The formatted request body for the OpenAI API.
|
||
*/
|
||
export function toOpenAIRequestFromClaude(claudeRequest) {
|
||
const openaiMessages = [];
|
||
let systemMessageContent = '';
|
||
|
||
// Claude system message handling
|
||
if (claudeRequest.system) {
|
||
systemMessageContent = claudeRequest.system;
|
||
}
|
||
|
||
if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) {
|
||
claudeRequest.messages.forEach(message => {
|
||
const openaiRole = message.role === 'assistant' ? 'assistant' : 'user';
|
||
const content = message.content; // Claude content can be string or array
|
||
|
||
if (typeof content === 'string') {
|
||
openaiMessages.push({ role: openaiRole, content: content });
|
||
} else if (Array.isArray(content)) {
|
||
// Process multimodal content
|
||
const processedContent = processClaudeContentToOpenAIContent(content);
|
||
if (processedContent && processedContent.length > 0) {
|
||
openaiMessages.push({
|
||
role: openaiRole,
|
||
content: processedContent
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
const openaiRequest = {
|
||
model: claudeRequest.model || 'gpt-3.5-turbo', // Default OpenAI model
|
||
messages: openaiMessages,
|
||
max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, DEFAULT_MAX_TOKENS),
|
||
temperature: checkAndAssignOrDefault(claudeRequest.temperature, DEFAULT_TEMPERATURE),
|
||
top_p: checkAndAssignOrDefault(claudeRequest.top_p, DEFAULT_TOP_P),
|
||
// stream: claudeRequest.stream, // Stream mode is handled by different endpoint
|
||
};
|
||
|
||
// Add system message at the beginning if present
|
||
if (systemMessageContent) {
|
||
openaiRequest.messages.unshift({ role: 'system', content: systemMessageContent });
|
||
}
|
||
|
||
return openaiRequest;
|
||
}
|
||
|
||
/**
|
||
* Processes Claude content to OpenAI content format with multimodal support.
|
||
* @param {Array} content - Array of Claude content blocks.
|
||
* @returns {Array} OpenAI content format.
|
||
*/
|
||
function processClaudeContentToOpenAIContent(content) {
|
||
if (!content || !Array.isArray(content)) return [];
|
||
|
||
const contentArray = [];
|
||
|
||
content.forEach(block => {
|
||
if (!block) return;
|
||
|
||
switch (block.type) {
|
||
case 'text':
|
||
if (block.text) {
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: block.text
|
||
});
|
||
}
|
||
break;
|
||
|
||
case 'image':
|
||
// Handle image blocks from Claude
|
||
if (block.source && block.source.type === 'base64') {
|
||
contentArray.push({
|
||
type: 'image_url',
|
||
image_url: {
|
||
url: `data:${block.source.media_type};base64,${block.source.data}`
|
||
}
|
||
});
|
||
}
|
||
break;
|
||
|
||
case 'tool_use':
|
||
// Handle tool use as text
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: `[Tool use: ${block.name}]`
|
||
});
|
||
break;
|
||
|
||
case 'tool_result':
|
||
// Handle tool results as text
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content)
|
||
});
|
||
break;
|
||
|
||
default:
|
||
// Handle any other content types as text
|
||
if (block.text) {
|
||
contentArray.push({
|
||
type: 'text',
|
||
text: block.text
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
return contentArray;
|
||
}
|
||
|
||
|
||
/**
|
||
* Converts an OpenAI chat completion request body to a Gemini API request body.
|
||
* Handles system instructions and merges consecutive messages of the same role with multimodal support.
|
||
* @param {Object} openaiRequest - The request body from the OpenAI API.
|
||
* @returns {Object} The formatted request body for the Gemini API.
|
||
*/
|
||
export function toGeminiRequestFromOpenAI(openaiRequest) {
|
||
const messages = openaiRequest.messages || [];
|
||
const { systemInstruction, nonSystemMessages } = extractAndProcessSystemMessages(messages);
|
||
|
||
// Process messages with role conversion and multimodal support
|
||
const processedMessages = [];
|
||
let lastMessage = null;
|
||
|
||
for (const message of nonSystemMessages) {
|
||
const geminiRole = message.role === 'assistant' ? 'model' : message.role;
|
||
|
||
// Handle tool responses
|
||
if (geminiRole === 'tool') {
|
||
if (lastMessage) processedMessages.push(lastMessage);
|
||
processedMessages.push({
|
||
role: 'function',
|
||
parts: [{
|
||
functionResponse: {
|
||
name: message.name,
|
||
response: { content: safeParseJSON(message.content) }
|
||
}
|
||
}]
|
||
});
|
||
lastMessage = null;
|
||
continue;
|
||
}
|
||
|
||
// Process multimodal content
|
||
const processedContent = processOpenAIContentToGeminiParts(message.content);
|
||
|
||
// Merge consecutive text messages
|
||
if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls &&
|
||
Array.isArray(processedContent) && processedContent.every(p => p.text) &&
|
||
Array.isArray(lastMessage.parts) && lastMessage.parts.every(p => p.text)) {
|
||
lastMessage.parts.push(...processedContent);
|
||
continue;
|
||
}
|
||
|
||
if (lastMessage) processedMessages.push(lastMessage);
|
||
lastMessage = { role: geminiRole, parts: processedContent };
|
||
}
|
||
if (lastMessage) processedMessages.push(lastMessage);
|
||
|
||
// Build Gemini request
|
||
const geminiRequest = {
|
||
contents: processedMessages.filter(item => item.parts && item.parts.length > 0)
|
||
};
|
||
|
||
if (systemInstruction) geminiRequest.systemInstruction = systemInstruction;
|
||
|
||
// Handle tools and tool_choice
|
||
if (openaiRequest.tools?.length) {
|
||
geminiRequest.tools = [{
|
||
functionDeclarations: openaiRequest.tools.map(t => ({
|
||
name: t.function.name,
|
||
description: t.function.description,
|
||
parameters: t.function.parameters
|
||
}))
|
||
}];
|
||
}
|
||
|
||
if (openaiRequest.tool_choice) {
|
||
geminiRequest.toolConfig = buildToolConfig(openaiRequest.tool_choice);
|
||
}
|
||
|
||
// Add generation config
|
||
const config = buildGenerationConfig(openaiRequest);
|
||
if (Object.keys(config).length) geminiRequest.generationConfig = config;
|
||
|
||
// Validation
|
||
if (geminiRequest.contents[0]?.role !== 'user') {
|
||
console.warn(`[Request Conversion] Warning: Conversation does not start with a 'user' role.`);
|
||
}
|
||
|
||
return geminiRequest;
|
||
}
|
||
|
||
/**
|
||
* Processes OpenAI content to Gemini parts format with multimodal support.
|
||
* @param {string|Array} content - OpenAI message content.
|
||
* @returns {Array} Array of Gemini parts.
|
||
*/
|
||
function processOpenAIContentToGeminiParts(content) {
|
||
if (!content) return [];
|
||
|
||
// Handle string content
|
||
if (typeof content === 'string') {
|
||
return [{ text: content }];
|
||
}
|
||
|
||
// Handle array content (multimodal)
|
||
if (Array.isArray(content)) {
|
||
const parts = [];
|
||
|
||
content.forEach(item => {
|
||
if (!item) return;
|
||
|
||
switch (item.type) {
|
||
case 'text':
|
||
if (item.text) {
|
||
parts.push({ text: item.text });
|
||
}
|
||
break;
|
||
|
||
case 'image_url':
|
||
if (item.image_url) {
|
||
const imageUrl = typeof item.image_url === 'string'
|
||
? item.image_url
|
||
: item.image_url.url;
|
||
|
||
if (imageUrl.startsWith('data:')) {
|
||
// Handle base64 data URL
|
||
const [header, data] = imageUrl.split(',');
|
||
const mimeType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';
|
||
parts.push({
|
||
inlineData: {
|
||
mimeType,
|
||
data
|
||
}
|
||
});
|
||
} else {
|
||
// Handle regular URL
|
||
parts.push({
|
||
fileData: {
|
||
mimeType: 'image/jpeg', // Default MIME type
|
||
fileUri: imageUrl
|
||
}
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'audio':
|
||
// Handle audio content
|
||
if (item.audio_url) {
|
||
const audioUrl = typeof item.audio_url === 'string'
|
||
? item.audio_url
|
||
: item.audio_url.url;
|
||
|
||
if (audioUrl.startsWith('data:')) {
|
||
const [header, data] = audioUrl.split(',');
|
||
const mimeType = header.match(/data:([^;]+)/)?.[1] || 'audio/wav';
|
||
parts.push({
|
||
inlineData: {
|
||
mimeType,
|
||
data
|
||
}
|
||
});
|
||
} else {
|
||
parts.push({
|
||
fileData: {
|
||
mimeType: 'audio/wav', // Default MIME type
|
||
fileUri: audioUrl
|
||
}
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
|
||
return parts;
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function safeParseJSON(str) {
|
||
try {
|
||
return JSON.parse(str || '{}');
|
||
} catch {
|
||
return str;
|
||
}
|
||
}
|
||
|
||
function buildToolConfig(toolChoice) {
|
||
if (typeof toolChoice === 'string' && ['none', 'auto'].includes(toolChoice)) {
|
||
return { functionCallingConfig: { mode: toolChoice.toUpperCase() } };
|
||
}
|
||
if (typeof toolChoice === 'object' && toolChoice.function) {
|
||
return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name] } };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildGenerationConfig({ temperature, max_tokens, top_p, stop }) {
|
||
const config = {};
|
||
config.temperature = checkAndAssignOrDefault(temperature, DEFAULT_TEMPERATURE);
|
||
config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, DEFAULT_GEMINI_MAX_TOKENS);
|
||
config.topP = checkAndAssignOrDefault(top_p, DEFAULT_TOP_P);
|
||
if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop];
|
||
return config;
|
||
}
|
||
|
||
|
||
/**
|
||
* Converts an OpenAI chat completion request body to a Claude API request body.
|
||
* Handles system instructions, tool calls, and multimodal content.
|
||
* @param {Object} openaiRequest - The request body from the OpenAI API.
|
||
* @returns {Object} The formatted request body for the Claude API.
|
||
*/
|
||
export function toClaudeRequestFromOpenAI(openaiRequest) {
|
||
const messages = openaiRequest.messages || [];
|
||
const { systemInstruction, nonSystemMessages } = extractAndProcessSystemMessages(messages);
|
||
|
||
const claudeMessages = [];
|
||
|
||
for (const message of nonSystemMessages) {
|
||
const role = message.role === 'assistant' ? 'assistant' : 'user';
|
||
let content = [];
|
||
|
||
if (message.role === 'tool') {
|
||
// Claude expects tool_result to be in a 'user' message
|
||
// The content of a tool message is a single tool_result block
|
||
content.push({
|
||
type: 'tool_result',
|
||
tool_use_id: message.tool_call_id, // Use tool_call_id from OpenAI tool message
|
||
content: safeParseJSON(message.content) // Parse content as JSON if possible
|
||
});
|
||
claudeMessages.push({ role: 'user', content: content });
|
||
} else if (message.role === 'assistant' && message.tool_calls?.length) {
|
||
// Assistant message with tool calls - properly format as tool_use blocks
|
||
// Claude expects tool_use to be in an 'assistant' message
|
||
const toolUseBlocks = message.tool_calls.map(tc => ({
|
||
type: 'tool_use',
|
||
id: tc.id,
|
||
name: tc.function.name,
|
||
input: safeParseJSON(tc.function.arguments)
|
||
}));
|
||
claudeMessages.push({ role: 'assistant', content: toolUseBlocks });
|
||
} else {
|
||
// Regular user or assistant message (text and multimodal)
|
||
if (typeof message.content === 'string') {
|
||
if (message.content) {
|
||
content.push({ type: 'text', text: message.content });
|
||
}
|
||
} else if (Array.isArray(message.content)) {
|
||
message.content.forEach(item => {
|
||
if (!item) return;
|
||
switch (item.type) {
|
||
case 'text':
|
||
if (item.text) {
|
||
content.push({ type: 'text', text: item.text });
|
||
}
|
||
break;
|
||
case 'image_url':
|
||
if (item.image_url) {
|
||
const imageUrl = typeof item.image_url === 'string'
|
||
? item.image_url
|
||
: item.image_url.url;
|
||
if (imageUrl.startsWith('data:')) {
|
||
const [header, data] = imageUrl.split(',');
|
||
const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';
|
||
content.push({
|
||
type: 'image',
|
||
source: {
|
||
type: 'base64',
|
||
media_type: mediaType,
|
||
data: data
|
||
}
|
||
});
|
||
} else {
|
||
// Claude requires base64 for images, so for URLs, we'll represent as text
|
||
content.push({ type: 'text', text: `[Image: ${imageUrl}]` });
|
||
}
|
||
}
|
||
break;
|
||
case 'audio':
|
||
// Handle audio content as text placeholder
|
||
if (item.audio_url) {
|
||
const audioUrl = typeof item.audio_url === 'string'
|
||
? item.audio_url
|
||
: item.audio_url.url;
|
||
content.push({ type: 'text', text: `[Audio: ${audioUrl}]` });
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
}
|
||
// Only add message if content is not empty
|
||
if (content.length > 0) {
|
||
claudeMessages.push({ role: role, content: content });
|
||
}
|
||
}
|
||
}
|
||
|
||
const claudeRequest = {
|
||
model: openaiRequest.model || 'claude-3-opus-20240229',
|
||
messages: claudeMessages,
|
||
max_tokens: checkAndAssignOrDefault(openaiRequest.max_tokens, DEFAULT_MAX_TOKENS),
|
||
temperature: checkAndAssignOrDefault(openaiRequest.temperature, DEFAULT_TEMPERATURE),
|
||
top_p: checkAndAssignOrDefault(openaiRequest.top_p, DEFAULT_TOP_P),
|
||
};
|
||
|
||
if (systemInstruction) {
|
||
claudeRequest.system = extractTextFromMessageContent(systemInstruction.parts[0].text);
|
||
}
|
||
|
||
if (openaiRequest.tools?.length) {
|
||
claudeRequest.tools = openaiRequest.tools.map(t => ({
|
||
name: t.function.name,
|
||
description: t.function.description || '',
|
||
input_schema: t.function.parameters || { type: 'object', properties: {} }
|
||
}));
|
||
claudeRequest.tool_choice = buildClaudeToolChoice(openaiRequest.tool_choice);
|
||
}
|
||
|
||
return claudeRequest;
|
||
}
|
||
|
||
function buildClaudeToolChoice(toolChoice) {
|
||
if (typeof toolChoice === 'string') {
|
||
const mapping = { auto: 'auto', none: 'none', required: 'any' };
|
||
return { type: mapping[toolChoice] };
|
||
}
|
||
if (typeof toolChoice === 'object' && toolChoice.function) {
|
||
return { type: 'tool', name: toolChoice.function.name };
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
|
||
/**
|
||
* Extracts and combines all 'system' role messages into a single system instruction.
|
||
* Filters out system messages and returns the remaining non-system messages.
|
||
* @param {Array<Object>} messages - Array of message objects from OpenAI request.
|
||
* @returns {{systemInstruction: Object|null, nonSystemMessages: Array<Object>}}
|
||
* An object containing the system instruction and an array of non-system messages.
|
||
*/
|
||
export function extractAndProcessSystemMessages(messages) {
|
||
const systemContents = [];
|
||
const nonSystemMessages = [];
|
||
|
||
for (const message of messages) {
|
||
if (message.role === 'system') {
|
||
systemContents.push(extractTextFromMessageContent(message.content));
|
||
} else {
|
||
nonSystemMessages.push(message);
|
||
}
|
||
}
|
||
|
||
let systemInstruction = null;
|
||
if (systemContents.length > 0) {
|
||
systemInstruction = {
|
||
parts: [{
|
||
text: systemContents.join('\n')
|
||
}]
|
||
};
|
||
}
|
||
return { systemInstruction, nonSystemMessages };
|
||
}
|
||
|
||
/**
|
||
* Extracts text from various forms of message content.
|
||
* @param {string|Array<Object>} content - The content from a message object.
|
||
* @returns {string} The extracted text.
|
||
*/
|
||
export function extractTextFromMessageContent(content) {
|
||
if (typeof content === 'string') {
|
||
return content;
|
||
}
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.filter(part => part.type === 'text' && part.text)
|
||
.map(part => part.text)
|
||
.join('\n');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Utility function to detect MIME type from base64 data URL
|
||
* @param {string} dataUrl - Data URL string
|
||
* @returns {string} MIME type
|
||
*/
|
||
function detectMimeType(dataUrl) {
|
||
const match = dataUrl.match(/^data:([^;]+);base64,/);
|
||
return match ? match[1] : 'application/octet-stream';
|
||
}
|
||
|
||
/**
|
||
* Utility function to extract base64 data from data URL
|
||
* @param {string} dataUrl - Data URL string
|
||
* @returns {string} Base64 data
|
||
*/
|
||
function extractBase64Data(dataUrl) {
|
||
return dataUrl.replace(/^data:[^;]+;base64,/, '');
|
||
}
|
||
|
||
/**
|
||
* Utility function to validate image MIME types
|
||
* @param {string} mimeType - MIME type to validate
|
||
* @returns {boolean} Whether it's a valid image type
|
||
*/
|
||
function isValidImageType(mimeType) {
|
||
const validTypes = [
|
||
'image/jpeg', 'image/jpg', 'image/png', 'image/gif',
|
||
'image/webp', 'image/bmp', 'image/tiff'
|
||
];
|
||
return validTypes.includes(mimeType.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* Utility function to validate audio MIME types
|
||
* @param {string} mimeType - MIME type to validate
|
||
* @returns {boolean} Whether it's a valid audio type
|
||
*/
|
||
function isValidAudioType(mimeType) {
|
||
const validTypes = [
|
||
'audio/wav', 'audio/wave', 'audio/mp3', 'audio/mpeg',
|
||
'audio/ogg', 'audio/aac', 'audio/flac', 'audio/m4a'
|
||
];
|
||
return validTypes.includes(mimeType.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* Converts a Claude API request body to a Gemini API request body.
|
||
* Handles system instructions and multimodal content.
|
||
* @param {Object} claudeRequest - The request body from the Claude API.
|
||
* @returns {Object} The formatted request body for the Gemini API.
|
||
*/
|
||
/**
|
||
* Converts a Claude API request body to a Gemini API request body.
|
||
* Handles system instructions and multimodal content.
|
||
* @param {Object} claudeRequest - The request body from the Claude API.
|
||
* @returns {Object} The formatted request body for the Gemini API.
|
||
*/
|
||
export function toGeminiRequestFromClaude(claudeRequest) {
|
||
// Ensure claudeRequest is a valid object
|
||
if (!claudeRequest || typeof claudeRequest !== 'object') {
|
||
console.warn("Invalid claudeRequest provided to toGeminiRequestFromClaude.");
|
||
return { contents: [] };
|
||
}
|
||
|
||
const geminiRequest = {
|
||
contents: []
|
||
};
|
||
|
||
// Handle system instruction
|
||
if (claudeRequest.system) {
|
||
let incomingSystemText = null;
|
||
if (typeof claudeRequest.system === 'string') {
|
||
incomingSystemText = claudeRequest.system;
|
||
} else if (typeof claudeRequest.system === 'object') {
|
||
incomingSystemText = JSON.stringify(claudeRequest.system);
|
||
} else if (claudeRequest.messages?.length > 0) {
|
||
// Fallback to first user message if no system property
|
||
const userMessage = claudeRequest.messages.find(m => m.role === 'user');
|
||
if (userMessage) {
|
||
if (Array.isArray(userMessage.content)) {
|
||
incomingSystemText = userMessage.content.map(block => block.text).join('');
|
||
} else {
|
||
incomingSystemText = userMessage.content;
|
||
}
|
||
}
|
||
}
|
||
geminiRequest.systemInstruction = {
|
||
parts: [{ text: incomingSystemText}] // Ensure system is string
|
||
};
|
||
}
|
||
|
||
// Process messages
|
||
if (Array.isArray(claudeRequest.messages)) {
|
||
claudeRequest.messages.forEach(message => {
|
||
// Ensure message is a valid object and has a role and content
|
||
if (!message || typeof message !== 'object' || !message.role || !message.content) {
|
||
console.warn("Skipping invalid message in claudeRequest.messages.");
|
||
return;
|
||
}
|
||
|
||
const geminiRole = message.role === 'assistant' ? 'model' : 'user';
|
||
const processedParts = processClaudeContentToGeminiParts(message.content);
|
||
|
||
// If the processed parts contain a function response, it should be a 'function' role message
|
||
// Claude's tool_result block does not contain the function name, only tool_use_id.
|
||
// We need to infer the function name from the previous tool_use message.
|
||
// For simplicity in this conversion, we'll assume the tool_use_id is the function name
|
||
// or that the tool_result is always preceded by a tool_use with the correct name.
|
||
// A more robust solution would involve tracking tool_use_ids to function names.
|
||
const functionResponsePart = processedParts.find(part => part.functionResponse);
|
||
if (functionResponsePart) {
|
||
geminiRequest.contents.push({
|
||
role: 'function',
|
||
parts: [functionResponsePart]
|
||
});
|
||
} else if (processedParts.length > 0) { // Only push if there are actual parts
|
||
geminiRequest.contents.push({
|
||
role: geminiRole,
|
||
parts: processedParts
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add generation config
|
||
const generationConfig = {};
|
||
generationConfig.maxOutputTokens = checkAndAssignOrDefault(claudeRequest.max_tokens, DEFAULT_GEMINI_MAX_TOKENS);
|
||
generationConfig.temperature = checkAndAssignOrDefault(claudeRequest.temperature, DEFAULT_TEMPERATURE);
|
||
generationConfig.topP = checkAndAssignOrDefault(claudeRequest.top_p, DEFAULT_TOP_P);
|
||
|
||
if (Object.keys(generationConfig).length > 0) {
|
||
geminiRequest.generationConfig = generationConfig;
|
||
}
|
||
|
||
// Handle tools
|
||
if (Array.isArray(claudeRequest.tools)) {
|
||
geminiRequest.tools = [{
|
||
functionDeclarations: claudeRequest.tools.map(tool => {
|
||
// Ensure tool is a valid object and has a name
|
||
if (!tool || typeof tool !== 'object' || !tool.name) {
|
||
console.warn("Skipping invalid tool declaration in claudeRequest.tools.");
|
||
return null; // Return null for invalid tools, filter out later
|
||
}
|
||
|
||
delete tool.input_schema.$schema;
|
||
return {
|
||
name: String(tool.name), // Ensure name is string
|
||
description: String(tool.description || ''), // Ensure description is string
|
||
parameters: tool.input_schema && typeof tool.input_schema === 'object' ? tool.input_schema : { type: 'object', properties: {} }
|
||
};
|
||
}).filter(Boolean) // Filter out any nulls from invalid tool declarations
|
||
}];
|
||
// If no valid functionDeclarations, remove the tools array
|
||
if (geminiRequest.tools[0].functionDeclarations.length === 0) {
|
||
delete geminiRequest.tools;
|
||
}
|
||
}
|
||
|
||
// Handle tool_choice
|
||
if (claudeRequest.tool_choice) {
|
||
geminiRequest.toolConfig = buildGeminiToolConfigFromClaude(claudeRequest.tool_choice);
|
||
}
|
||
|
||
return geminiRequest;
|
||
}
|
||
|
||
/**
|
||
* Builds Gemini toolConfig from Claude tool_choice.
|
||
* @param {Object} claudeToolChoice - The tool_choice object from Claude API.
|
||
* @returns {Object|undefined} The formatted toolConfig for Gemini API, or undefined if invalid.
|
||
*/
|
||
function buildGeminiToolConfigFromClaude(claudeToolChoice) {
|
||
if (!claudeToolChoice || typeof claudeToolChoice !== 'object' || !claudeToolChoice.type) {
|
||
console.warn("Invalid claudeToolChoice provided to buildGeminiToolConfigFromClaude.");
|
||
return undefined;
|
||
}
|
||
|
||
switch (claudeToolChoice.type) {
|
||
case 'auto':
|
||
return { functionCallingConfig: { mode: 'AUTO' } };
|
||
case 'none':
|
||
return { functionCallingConfig: { mode: 'NONE' } };
|
||
case 'tool':
|
||
if (claudeToolChoice.name && typeof claudeToolChoice.name === 'string') {
|
||
return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [claudeToolChoice.name] } };
|
||
}
|
||
console.warn("Invalid tool name in claudeToolChoice of type 'tool'.");
|
||
return undefined;
|
||
default:
|
||
console.warn(`Unsupported claudeToolChoice type: ${claudeToolChoice.type}`);
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Processes Claude content to Gemini parts format with multimodal support.
|
||
* @param {string|Array} content - Claude message content.
|
||
* @returns {Array} Array of Gemini parts.
|
||
*/
|
||
function processClaudeContentToGeminiParts(content) {
|
||
if (!content) return [];
|
||
|
||
// Handle string content
|
||
if (typeof content === 'string') {
|
||
return [{ text: content }];
|
||
}
|
||
|
||
// Handle array content (multimodal)
|
||
if (Array.isArray(content)) {
|
||
const parts = [];
|
||
|
||
content.forEach(block => {
|
||
// Ensure block is a valid object and has a type
|
||
if (!block || typeof block !== 'object' || !block.type) {
|
||
console.warn("Skipping invalid content block in processClaudeContentToGeminiParts.");
|
||
return;
|
||
}
|
||
|
||
switch (block.type) {
|
||
case 'text':
|
||
if (typeof block.text === 'string') {
|
||
parts.push({ text: block.text });
|
||
} else {
|
||
console.warn("Invalid text content in Claude text block.");
|
||
}
|
||
break;
|
||
|
||
case 'image':
|
||
if (block.source && typeof block.source === 'object' && block.source.type === 'base64' &&
|
||
typeof block.source.media_type === 'string' && typeof block.source.data === 'string') {
|
||
parts.push({
|
||
inlineData: {
|
||
mimeType: block.source.media_type,
|
||
data: block.source.data
|
||
}
|
||
});
|
||
} else {
|
||
console.warn("Invalid image source in Claude image block.");
|
||
}
|
||
break;
|
||
|
||
case 'tool_use':
|
||
if (typeof block.name === 'string' && block.input && typeof block.input === 'object') {
|
||
parts.push({
|
||
functionCall: {
|
||
name: block.name,
|
||
args: block.input
|
||
}
|
||
});
|
||
} else {
|
||
console.warn("Invalid tool_use block in Claude content.");
|
||
}
|
||
break;
|
||
|
||
case 'tool_result':
|
||
// Claude's tool_result block does not contain the function name, only tool_use_id.
|
||
// Gemini's functionResponse requires a function name.
|
||
// For now, we'll use the tool_use_id as the name, but this is a potential point of failure
|
||
// if the tool_use_id is not the actual function name in Gemini's context.
|
||
// A more robust solution would involve tracking the function name from the tool_use block.
|
||
if (typeof block.tool_use_id === 'string') {
|
||
parts.push({
|
||
functionResponse: {
|
||
name: block.tool_use_id, // This might need to be the actual function name
|
||
response: { content: block.content } // content can be any JSON-serializable value
|
||
}
|
||
});
|
||
} else {
|
||
console.warn("Invalid tool_result block in Claude content: missing tool_use_id.");
|
||
}
|
||
break;
|
||
|
||
default:
|
||
// Handle any other content types as text if they have a text property
|
||
if (typeof block.text === 'string') {
|
||
parts.push({ text: block.text });
|
||
} else {
|
||
console.warn(`Unsupported Claude content block type: ${block.type}. Skipping.`);
|
||
}
|
||
}
|
||
});
|
||
|
||
return parts;
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* Converts a Gemini API response to a Claude API messages response.
|
||
* @param {Object} geminiResponse - The Gemini API response object.
|
||
* @param {string} model - The model name to include in the response.
|
||
* @returns {Object} The formatted Claude API messages response.
|
||
*/
|
||
export function toClaudeChatCompletionFromGemini(geminiResponse, model) {
|
||
// Handle cases where geminiResponse or candidates are missing or empty
|
||
if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) {
|
||
return {
|
||
id: `msg_${uuidv4()}`,
|
||
type: "message",
|
||
role: "assistant",
|
||
content: [], // Empty content for no candidates
|
||
model: model,
|
||
stop_reason: "end_turn", // Default stop reason
|
||
stop_sequence: null,
|
||
usage: {
|
||
input_tokens: geminiResponse?.usageMetadata?.promptTokenCount || 0,
|
||
output_tokens: geminiResponse?.usageMetadata?.candidatesTokenCount || 0
|
||
}
|
||
};
|
||
}
|
||
|
||
const candidate = geminiResponse.candidates[0];
|
||
const content = processGeminiResponseToClaudeContent(geminiResponse);
|
||
const finishReason = candidate.finishReason;
|
||
let stopReason = "end_turn"; // Default stop reason
|
||
|
||
if (finishReason) {
|
||
switch (finishReason) {
|
||
case 'STOP':
|
||
stopReason = 'end_turn';
|
||
break;
|
||
case 'MAX_TOKENS':
|
||
stopReason = 'max_tokens';
|
||
break;
|
||
case 'SAFETY':
|
||
stopReason = 'safety';
|
||
break;
|
||
case 'RECITATION':
|
||
stopReason = 'recitation';
|
||
break;
|
||
case 'OTHER':
|
||
stopReason = 'other';
|
||
break;
|
||
default:
|
||
stopReason = 'end_turn';
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: `msg_${uuidv4()}`,
|
||
type: "message",
|
||
role: "assistant",
|
||
content: content,
|
||
model: model,
|
||
stop_reason: stopReason,
|
||
stop_sequence: null,
|
||
usage: {
|
||
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
|
||
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Processes Gemini response content to Claude format.
|
||
* @param {Object} geminiResponse - The Gemini API response.
|
||
* @returns {Array} Array of Claude content blocks.
|
||
*/
|
||
function processGeminiResponseToClaudeContent(geminiResponse) {
|
||
if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) return [];
|
||
|
||
const content = [];
|
||
|
||
geminiResponse.candidates.forEach(candidate => {
|
||
if (candidate.content && candidate.content.parts) {
|
||
candidate.content.parts.forEach(part => {
|
||
if (part.text) {
|
||
content.push({
|
||
type: 'text',
|
||
text: part.text
|
||
});
|
||
} else if (part.inlineData) {
|
||
content.push({
|
||
type: 'image',
|
||
source: {
|
||
type: 'base64',
|
||
media_type: part.inlineData.mimeType,
|
||
data: part.inlineData.data
|
||
}
|
||
});
|
||
} else if (part.functionCall) {
|
||
// Convert Gemini functionCall to Claude tool_use
|
||
content.push({
|
||
type: 'tool_use',
|
||
id: uuidv4(), // Generate a new ID for the tool use
|
||
name: part.functionCall.name,
|
||
input: part.functionCall.args || {}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
return content;
|
||
}
|
||
|
||
/**
|
||
* Converts a Gemini API stream chunk to a Claude API messages stream chunk.
|
||
* @param {Object} geminiChunk - The Gemini API stream chunk object.
|
||
* @param {string} [model] - Optional model name to include in the response.
|
||
* @returns {Object} The formatted Claude API messages stream chunk.
|
||
*/
|
||
export function toClaudeStreamChunkFromGemini(geminiChunk, model) {
|
||
if (!geminiChunk) {
|
||
return null;
|
||
}
|
||
|
||
// Handle different types of Gemini stream events
|
||
if (geminiChunk.candidates && geminiChunk.candidates.length > 0) {
|
||
const candidate = geminiChunk.candidates[0];
|
||
|
||
if (candidate.content && candidate.content.parts) {
|
||
const textParts = candidate.content.parts
|
||
.filter(part => part.text)
|
||
.map(part => part.text);
|
||
|
||
const functionCallPart = candidate.content.parts.find(part => part.functionCall);
|
||
|
||
if (functionCallPart) {
|
||
// Handle tool_use
|
||
return {
|
||
type: "content_block_start",
|
||
index: 0,
|
||
content_block: {
|
||
type: "tool_use",
|
||
id: `toolu_${uuidv4()}`, // Claude tool use ID format
|
||
name: functionCallPart.functionCall.name,
|
||
input: functionCallPart.functionCall.args || {}
|
||
}
|
||
};
|
||
} else if (textParts.length > 0) {
|
||
return {
|
||
type: "content_block_delta",
|
||
index: 0,
|
||
delta: {
|
||
type: "text_delta",
|
||
text: textParts.join('')
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
// Handle finish reason
|
||
if (candidate.finishReason) {
|
||
let stopReason = "end_turn";
|
||
switch (candidate.finishReason) {
|
||
case 'STOP':
|
||
stopReason = 'end_turn';
|
||
break;
|
||
case 'MAX_TOKENS':
|
||
stopReason = 'max_tokens';
|
||
break;
|
||
case 'SAFETY':
|
||
stopReason = 'safety';
|
||
break;
|
||
case 'RECITATION':
|
||
stopReason = 'recitation';
|
||
break;
|
||
case 'OTHER':
|
||
stopReason = 'other';
|
||
break;
|
||
default:
|
||
stopReason = 'end_turn';
|
||
}
|
||
return {
|
||
type: "message_delta",
|
||
delta: {
|
||
stop_reason: stopReason,
|
||
stop_sequence: null
|
||
},
|
||
usage: geminiChunk.usageMetadata ? {
|
||
output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0
|
||
} : undefined
|
||
};
|
||
}
|
||
}
|
||
|
||
// Handle usage metadata updates (only if no other content/finish reason)
|
||
if (geminiChunk.usageMetadata && (!geminiChunk.candidates || geminiChunk.candidates.length === 0)) {
|
||
return {
|
||
type: "message_delta",
|
||
delta: {},
|
||
usage: {
|
||
input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
||
output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0
|
||
}
|
||
};
|
||
}
|
||
|
||
// Default text delta for simple text chunks (should ideally be handled by candidate.content.parts)
|
||
// This case might occur if the geminiChunk is just a string, which is not typical for Gemini API.
|
||
// Added for robustness, but main logic should rely on geminiChunk.candidates.
|
||
if (typeof geminiChunk === 'string') {
|
||
return {
|
||
type: "content_block_delta",
|
||
index: 0,
|
||
delta: {
|
||
type: "text_delta",
|
||
text: geminiChunk
|
||
}
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|