Merge pull request #102 from lethanhson9901/feature/openai-structured-output-support
feat: Add structured output support for OpenAI to Gemini conversion
This commit is contained in:
commit
8239f165cf
1 changed files with 49 additions and 37 deletions
|
|
@ -190,12 +190,12 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
const mergedClaudeMessages = [];
|
const mergedClaudeMessages = [];
|
||||||
for (let i = 0; i < claudeMessages.length; i++) {
|
for (let i = 0; i < claudeMessages.length; i++) {
|
||||||
const currentMessage = claudeMessages[i];
|
const currentMessage = claudeMessages[i];
|
||||||
|
|
||||||
if (mergedClaudeMessages.length === 0) {
|
if (mergedClaudeMessages.length === 0) {
|
||||||
mergedClaudeMessages.push(currentMessage);
|
mergedClaudeMessages.push(currentMessage);
|
||||||
} else {
|
} else {
|
||||||
const lastMessage = mergedClaudeMessages[mergedClaudeMessages.length - 1];
|
const lastMessage = mergedClaudeMessages[mergedClaudeMessages.length - 1];
|
||||||
|
|
||||||
// 如果当前消息的 role 与上一条消息的 role 相同,则合并 content 数组
|
// 如果当前消息的 role 与上一条消息的 role 相同,则合并 content 数组
|
||||||
if (lastMessage.role === currentMessage.role) {
|
if (lastMessage.role === currentMessage.role) {
|
||||||
lastMessage.content = lastMessage.content.concat(currentMessage.content);
|
lastMessage.content = lastMessage.content.concat(currentMessage.content);
|
||||||
|
|
@ -346,7 +346,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
// 处理 OpenAI chunk 对象
|
// 处理 OpenAI chunk 对象
|
||||||
if (typeof openaiChunk === 'object' && !Array.isArray(openaiChunk)) {
|
if (typeof openaiChunk === 'object' && !Array.isArray(openaiChunk)) {
|
||||||
const choice = openaiChunk.choices?.[0];
|
const choice = openaiChunk.choices?.[0];
|
||||||
if (!choice){
|
if (!choice) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,7 +400,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
// }
|
// }
|
||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// // 如果有 function.arguments,说明是参数增量
|
// // 如果有 function.arguments,说明是参数增量
|
||||||
// if (toolCall.function?.arguments) {
|
// if (toolCall.function?.arguments) {
|
||||||
// events.push({
|
// events.push({
|
||||||
|
|
@ -445,8 +445,8 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
if (finishReason) {
|
if (finishReason) {
|
||||||
// 映射 finish_reason
|
// 映射 finish_reason
|
||||||
const stopReason = finishReason === "stop" ? "end_turn" :
|
const stopReason = finishReason === "stop" ? "end_turn" :
|
||||||
finishReason === "length" ? "max_tokens" :
|
finishReason === "length" ? "max_tokens" :
|
||||||
"end_turn";
|
"end_turn";
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
type: "content_block_stop",
|
type: "content_block_stop",
|
||||||
|
|
@ -545,20 +545,20 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
toGeminiRequest(openaiRequest) {
|
toGeminiRequest(openaiRequest) {
|
||||||
const messages = openaiRequest.messages || [];
|
const messages = openaiRequest.messages || [];
|
||||||
const { systemInstruction, nonSystemMessages } = extractSystemMessages(messages);
|
const { systemInstruction, nonSystemMessages } = extractSystemMessages(messages);
|
||||||
|
|
||||||
const processedMessages = [];
|
const processedMessages = [];
|
||||||
let lastMessage = null;
|
let lastMessage = null;
|
||||||
|
|
||||||
for (const message of nonSystemMessages) {
|
for (const message of nonSystemMessages) {
|
||||||
const geminiRole = message.role === 'assistant' ? 'model' : message.role;
|
const geminiRole = message.role === 'assistant' ? 'model' : message.role;
|
||||||
|
|
||||||
if (geminiRole === 'tool') {
|
if (geminiRole === 'tool') {
|
||||||
// Save previous model response with functionCall
|
// Save previous model response with functionCall
|
||||||
if (lastMessage) {
|
if (lastMessage) {
|
||||||
processedMessages.push(lastMessage);
|
processedMessages.push(lastMessage);
|
||||||
lastMessage = null;
|
lastMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get function name from message.name or via tool_call_id
|
// Get function name from message.name or via tool_call_id
|
||||||
let functionName = message.name;
|
let functionName = message.name;
|
||||||
if (!functionName && message.tool_call_id) {
|
if (!functionName && message.tool_call_id) {
|
||||||
|
|
@ -574,11 +574,11 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build functionResponse according to Gemini API spec
|
// Build functionResponse according to Gemini API spec
|
||||||
const parsedContent = safeParseJSON(message.content);
|
const parsedContent = safeParseJSON(message.content);
|
||||||
const contentStr = typeof parsedContent === 'string' ? parsedContent : JSON.stringify(parsedContent);
|
const contentStr = typeof parsedContent === 'string' ? parsedContent : JSON.stringify(parsedContent);
|
||||||
|
|
||||||
processedMessages.push({
|
processedMessages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [{
|
parts: [{
|
||||||
|
|
@ -594,9 +594,9 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
lastMessage = null;
|
lastMessage = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let processedContent = this.processOpenAIContentToGeminiParts(message.content);
|
let processedContent = this.processOpenAIContentToGeminiParts(message.content);
|
||||||
|
|
||||||
// Add tool_calls as functionCall to parts
|
// Add tool_calls as functionCall to parts
|
||||||
if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||||
for (const toolCall of message.tool_calls) {
|
for (const toolCall of message.tool_calls) {
|
||||||
|
|
@ -610,25 +610,25 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls &&
|
if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls &&
|
||||||
Array.isArray(processedContent) && processedContent.every(p => p.text) &&
|
Array.isArray(processedContent) && processedContent.every(p => p.text) &&
|
||||||
Array.isArray(lastMessage.parts) && lastMessage.parts.every(p => p.text)) {
|
Array.isArray(lastMessage.parts) && lastMessage.parts.every(p => p.text)) {
|
||||||
lastMessage.parts.push(...processedContent);
|
lastMessage.parts.push(...processedContent);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastMessage) processedMessages.push(lastMessage);
|
if (lastMessage) processedMessages.push(lastMessage);
|
||||||
lastMessage = { role: geminiRole, parts: processedContent };
|
lastMessage = { role: geminiRole, parts: processedContent };
|
||||||
}
|
}
|
||||||
if (lastMessage) processedMessages.push(lastMessage);
|
if (lastMessage) processedMessages.push(lastMessage);
|
||||||
|
|
||||||
const geminiRequest = {
|
const geminiRequest = {
|
||||||
contents: processedMessages.filter(item => item.parts && item.parts.length > 0)
|
contents: processedMessages.filter(item => item.parts && item.parts.length > 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (systemInstruction) geminiRequest.systemInstruction = systemInstruction;
|
if (systemInstruction) geminiRequest.systemInstruction = systemInstruction;
|
||||||
|
|
||||||
if (openaiRequest.tools?.length) {
|
if (openaiRequest.tools?.length) {
|
||||||
geminiRequest.tools = [{
|
geminiRequest.tools = [{
|
||||||
functionDeclarations: openaiRequest.tools.map(t => {
|
functionDeclarations: openaiRequest.tools.map(t => {
|
||||||
|
|
@ -646,14 +646,14 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
delete geminiRequest.tools;
|
delete geminiRequest.tools;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openaiRequest.tool_choice) {
|
if (openaiRequest.tool_choice) {
|
||||||
geminiRequest.toolConfig = this.buildGeminiToolConfig(openaiRequest.tool_choice);
|
geminiRequest.toolConfig = this.buildGeminiToolConfig(openaiRequest.tool_choice);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.buildGeminiGenerationConfig(openaiRequest, openaiRequest.model);
|
const config = this.buildGeminiGenerationConfig(openaiRequest, openaiRequest.model);
|
||||||
if (Object.keys(config).length) geminiRequest.generationConfig = config;
|
if (Object.keys(config).length) geminiRequest.generationConfig = config;
|
||||||
|
|
||||||
return geminiRequest;
|
return geminiRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -663,20 +663,20 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
processOpenAIContentToGeminiParts(content) {
|
processOpenAIContentToGeminiParts(content) {
|
||||||
if (!content) return [];
|
if (!content) return [];
|
||||||
if (typeof content === 'string') return [{ text: content }];
|
if (typeof content === 'string') return [{ text: content }];
|
||||||
|
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
for (const item of content) {
|
for (const item of content) {
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
|
||||||
if (item.type === 'text' && item.text) {
|
if (item.type === 'text' && item.text) {
|
||||||
parts.push({ text: item.text });
|
parts.push({ text: item.text });
|
||||||
} else if (item.type === 'image_url' && item.image_url) {
|
} else if (item.type === 'image_url' && item.image_url) {
|
||||||
const imageUrl = typeof item.image_url === 'string'
|
const imageUrl = typeof item.image_url === 'string'
|
||||||
? item.image_url
|
? item.image_url
|
||||||
: item.image_url.url;
|
: item.image_url.url;
|
||||||
|
|
||||||
if (imageUrl.startsWith('data:')) {
|
if (imageUrl.startsWith('data:')) {
|
||||||
const [header, data] = imageUrl.split(',');
|
const [header, data] = imageUrl.split(',');
|
||||||
const mimeType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';
|
const mimeType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';
|
||||||
|
|
@ -688,10 +688,10 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -711,13 +711,25 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
/**
|
/**
|
||||||
* 构建Gemini生成配置
|
* 构建Gemini生成配置
|
||||||
*/
|
*/
|
||||||
buildGeminiGenerationConfig({ temperature, max_tokens, top_p, stop, tools }, model) {
|
buildGeminiGenerationConfig({ temperature, max_tokens, top_p, stop, tools, response_format }, model) {
|
||||||
const config = {};
|
const config = {};
|
||||||
config.temperature = checkAndAssignOrDefault(temperature, 1);
|
config.temperature = checkAndAssignOrDefault(temperature, 1);
|
||||||
config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, 65535);
|
config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, 65535);
|
||||||
config.topP = checkAndAssignOrDefault(top_p, 0.95);
|
config.topP = checkAndAssignOrDefault(top_p, 0.95);
|
||||||
if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop];
|
if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop];
|
||||||
|
|
||||||
|
// Handle response_format
|
||||||
|
if (response_format) {
|
||||||
|
if (response_format.type === 'json_object') {
|
||||||
|
config.responseMimeType = 'application/json';
|
||||||
|
} else if (response_format.type === 'json_schema' && response_format.json_schema) {
|
||||||
|
config.responseMimeType = 'application/json';
|
||||||
|
if (response_format.json_schema.schema) {
|
||||||
|
config.responseSchema = response_format.json_schema.schema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gemini 2.5 and thinking models require responseModalities: ["TEXT"]
|
// Gemini 2.5 and thinking models require responseModalities: ["TEXT"]
|
||||||
// But this parameter cannot be added when using tools (causes 400 error)
|
// But this parameter cannot be added when using tools (causes 400 error)
|
||||||
const hasTools = tools && Array.isArray(tools) && tools.length > 0;
|
const hasTools = tools && Array.isArray(tools) && tools.length > 0;
|
||||||
|
|
@ -727,7 +739,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
} else if (hasTools && model && (model.includes('2.5') || model.includes('thinking') || model.includes('2.0-flash-thinking'))) {
|
} else if (hasTools && model && (model.includes('2.5') || model.includes('thinking') || model.includes('2.0-flash-thinking'))) {
|
||||||
console.log(`[OpenAI->Gemini] Skipping responseModalities for model ${model} because tools are present`);
|
console.log(`[OpenAI->Gemini] Skipping responseModalities for model ${model} because tools are present`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|
@ -822,7 +834,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
name: toolCall.function.name || '',
|
name: toolCall.function.name || '',
|
||||||
args: {}
|
args: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (toolCall.function.arguments) {
|
if (toolCall.function.arguments) {
|
||||||
try {
|
try {
|
||||||
functionCall.args = typeof toolCall.function.arguments === 'string'
|
functionCall.args = typeof toolCall.function.arguments === 'string'
|
||||||
|
|
@ -833,7 +845,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
functionCall.args = { partial: toolCall.function.arguments };
|
functionCall.args = { partial: toolCall.function.arguments };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push({ functionCall });
|
parts.push({ functionCall });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -894,7 +906,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
if (openaiRequest.messages && openaiRequest.messages.length > 0) {
|
if (openaiRequest.messages && openaiRequest.messages.length > 0) {
|
||||||
responsesRequest.messages = openaiRequest.messages.map(msg => ({
|
responsesRequest.messages = openaiRequest.messages.map(msg => ({
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
content: typeof msg.content === 'string'
|
content: typeof msg.content === 'string'
|
||||||
? [{ type: 'input_text', text: msg.content }]
|
? [{ type: 'input_text', text: msg.content }]
|
||||||
: msg.content
|
: msg.content
|
||||||
}));
|
}));
|
||||||
|
|
@ -1031,7 +1043,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
if (delta.tool_calls && delta.tool_calls.length > 0) {
|
if (delta.tool_calls && delta.tool_calls.length > 0) {
|
||||||
for (const toolCall of delta.tool_calls) {
|
for (const toolCall of delta.tool_calls) {
|
||||||
const outputIndex = toolCall.index || 0;
|
const outputIndex = toolCall.index || 0;
|
||||||
|
|
||||||
// 如果有 function.name,说明是工具调用开始
|
// 如果有 function.name,说明是工具调用开始
|
||||||
if (toolCall.function && toolCall.function.name) {
|
if (toolCall.function && toolCall.function.name) {
|
||||||
events.push({
|
events.push({
|
||||||
|
|
@ -1047,7 +1059,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
type: "response.output_item.added"
|
type: "response.output_item.added"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有 function.arguments,说明是参数增量
|
// 如果有 function.arguments,说明是参数增量
|
||||||
if (toolCall.function && toolCall.function.arguments) {
|
if (toolCall.function && toolCall.function.arguments) {
|
||||||
events.push({
|
events.push({
|
||||||
|
|
@ -1080,7 +1092,7 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
generateOutputItemDone(responseId),
|
generateOutputItemDone(responseId),
|
||||||
generateResponseCompleted(responseId)
|
generateResponseCompleted(responseId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果有 usage 信息,更新最后一个事件
|
// 如果有 usage 信息,更新最后一个事件
|
||||||
if (openaiChunk.usage && events.length > 0) {
|
if (openaiChunk.usage && events.length > 0) {
|
||||||
const lastEvent = events[events.length - 1];
|
const lastEvent = events[events.length - 1];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue