feat: 处理 openai 格式请求

This commit is contained in:
mouyong
2025-08-04 18:20:39 +08:00
parent 327d14bd5e
commit 2eee902988
5 changed files with 1517 additions and 28 deletions

View File

@@ -3,7 +3,7 @@ const router = express.Router();
const logger = require('../utils/logger');
const { authenticateApiKey } = require('../middleware/auth');
const geminiAccountService = require('../services/geminiAccountService');
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
const { getAvailableModels } = require('../services/geminiRelayService');
const crypto = require('crypto');
// 生成会话哈希
@@ -23,6 +23,142 @@ function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
return permissions === 'all' || permissions === requiredPermission;
}
// 转换 OpenAI 消息格式到 Gemini 格式
function convertMessagesToGemini(messages) {
const contents = [];
let systemInstruction = '';
// 辅助函数:提取文本内容
function extractTextContent(content) {
// 处理 null 或 undefined
if (content == null) {
return '';
}
// 处理字符串
if (typeof content === 'string') {
return content;
}
// 处理数组格式的内容
if (Array.isArray(content)) {
return content.map(item => {
if (item == null) return '';
if (typeof item === 'string') {
return item;
}
if (typeof item === 'object') {
// 处理 {type: 'text', text: '...'} 格式
if (item.type === 'text' && item.text) {
return item.text;
}
// 处理 {text: '...'} 格式
if (item.text) {
return item.text;
}
// 处理嵌套的对象或数组
if (item.content) {
return extractTextContent(item.content);
}
}
return '';
}).join('');
}
// 处理对象格式的内容
if (typeof content === 'object') {
// 处理 {text: '...'} 格式
if (content.text) {
return content.text;
}
// 处理 {content: '...'} 格式
if (content.content) {
return extractTextContent(content.content);
}
// 处理 {parts: [{text: '...'}]} 格式
if (content.parts && Array.isArray(content.parts)) {
return content.parts.map(part => {
if (part && part.text) {
return part.text;
}
return '';
}).join('');
}
}
// 最后的后备选项:只有在内容确实不为空且有意义时才转换为字符串
if (content !== undefined && content !== null && content !== '' && typeof content !== 'object') {
return String(content);
}
return '';
}
for (const message of messages) {
const textContent = extractTextContent(message.content);
if (message.role === 'system') {
systemInstruction += (systemInstruction ? '\n\n' : '') + textContent;
} else if (message.role === 'user') {
contents.push({
role: 'user',
parts: [{ text: textContent }]
});
} else if (message.role === 'assistant') {
contents.push({
role: 'model',
parts: [{ text: textContent }]
});
}
}
return { contents, systemInstruction };
}
// 转换 Gemini 响应到 OpenAI 格式
function convertGeminiResponseToOpenAI(geminiResponse, model, stream = false) {
if (stream) {
// 处理流式响应 - 原样返回 SSE 数据
return geminiResponse;
} else {
// 非流式响应转换
if (geminiResponse.candidates && geminiResponse.candidates.length > 0) {
const candidate = geminiResponse.candidates[0];
const content = candidate.content?.parts?.[0]?.text || '';
const finishReason = candidate.finishReason?.toLowerCase() || 'stop';
// 计算 token 使用量
const usage = geminiResponse.usageMetadata || {
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0
};
return {
id: `chatcmpl-${Date.now()}`,
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: usage.promptTokenCount,
completion_tokens: usage.candidatesTokenCount,
total_tokens: usage.totalTokenCount
}
};
} else {
throw new Error('No response from Gemini');
}
}
}
// OpenAI 兼容的聊天完成端点
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
const startTime = Date.now();
@@ -62,7 +198,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 提取请求参数
const {
messages: requestMessages,
contents,
contents: requestContents,
model: bodyModel = 'gemini-2.0-flash-exp',
temperature = 0.7,
max_tokens = 4096,
@@ -74,8 +210,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents
let messages = requestMessages;
if (contents && Array.isArray(contents)) {
messages = contents;
if (requestContents && Array.isArray(requestContents)) {
messages = requestContents;
}
// 验证必需参数
@@ -102,6 +238,23 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
}
}
// 转换消息格式
const { contents: geminiContents, systemInstruction } = convertMessagesToGemini(messages);
// 构建 Gemini 请求体
const geminiRequestBody = {
contents: geminiContents,
generationConfig: {
temperature,
maxOutputTokens: max_tokens,
candidateCount: 1
}
};
if (systemInstruction) {
geminiRequestBody.systemInstruction = { parts: [{ text: systemInstruction }] };
}
// 生成会话哈希用于粘性会话
const sessionHash = generateSessionHash(req);
@@ -137,39 +290,141 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
}
});
// 发送请求到 Gemini已经返回 OpenAI 格式)
const geminiResponse = await sendGeminiRequest({
messages,
model,
temperature,
maxTokens: max_tokens,
stream,
accessToken: account.accessToken,
proxy: account.proxy,
apiKeyId: apiKeyData.id,
signal: abortController.signal,
projectId: account.projectId
});
// 获取OAuth客户端
const client = await geminiAccountService.getOauthClient(account.accessToken, account.refreshToken);
let project = 'verdant-wares-464411-k9';
if (stream) {
// 流式响应
logger.info('StreamGenerateContent request', {
model: model,
projectId: account.projectId,
apiKeyId: apiKeyData.id
});
const streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
project || account.projectId,
apiKeyData.id, // 使用 API Key ID 作为 session ID
abortController.signal // 传递中止信号
);
// 设置流式响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
// 流式传输响应
for await (const chunk of geminiResponse) {
if (abortController.signal.aborted) {
break;
}
res.write(chunk);
}
// 处理流式响应,转换为 OpenAI 格式
let buffer = '';
streamResponse.on('data', (chunk) => {
try {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后一个不完整的行
for (const line of lines) {
if (!line.trim()) continue;
// 处理 SSE 格式
let jsonData = line;
if (line.startsWith('data: ')) {
jsonData = line.substring(6).trim();
}
if (!jsonData || jsonData === '[DONE]') continue;
try {
const data = JSON.parse(jsonData);
// 转换为 OpenAI 流式格式
if (data.candidates && data.candidates.length > 0) {
const candidate = data.candidates[0];
const content = candidate.content?.parts?.[0]?.text || '';
const finishReason = candidate.finishReason?.toLowerCase();
const openaiChunk = {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: model,
choices: [{
index: 0,
delta: {
content: content
},
finish_reason: finishReason === 'stop' ? 'stop' : null
}]
};
res.write(`data: ${JSON.stringify(openaiChunk)}\n\n`);
// 如果结束了,发送最终的 [DONE]
if (finishReason === 'stop') {
res.write('data: [DONE]\n\n');
}
}
} catch (e) {
logger.debug('Error parsing JSON line:', e.message);
}
}
} catch (error) {
logger.error('Stream processing error:', error);
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Stream error',
type: 'api_error'
}
});
}
}
});
streamResponse.on('end', () => {
logger.info('Stream completed successfully');
if (!res.headersSent) {
res.write('data: [DONE]\n\n');
}
res.end();
});
streamResponse.on('error', (error) => {
logger.error('Stream error:', error);
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message || 'Stream error',
type: 'api_error'
}
});
} else {
res.end();
}
});
res.end();
} else {
// 非流式响应
res.json(geminiResponse);
logger.info('GenerateContent request', {
model: model,
projectId: account.projectId,
apiKeyId: apiKeyData.id
});
const response = await geminiAccountService.generateContent(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
account.projectId,
apiKeyData.id // 使用 API Key ID 作为 session ID
);
// 转换为 OpenAI 格式并返回
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false);
res.json(openaiResponse);
}
const duration = Date.now() - startTime;