mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 处理 openai 格式请求
This commit is contained in:
424
src/routes/openaiGeminiRoutes.has-convert.js
Normal file
424
src/routes/openaiGeminiRoutes.has-convert.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { authenticateApiKey } = require('../middleware/auth');
|
||||||
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler');
|
||||||
|
const sessionHelper = require('../utils/sessionHelper');
|
||||||
|
|
||||||
|
// 检查 API Key 权限
|
||||||
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
|
const permissions = apiKeyData.permissions || 'all';
|
||||||
|
return permissions === 'all' || permissions === requiredPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 OpenAI 消息格式到 Gemini 格式
|
||||||
|
function convertMessagesToGemini(messages) {
|
||||||
|
const contents = [];
|
||||||
|
let systemInstruction = '';
|
||||||
|
|
||||||
|
// 辅助函数:提取文本内容
|
||||||
|
function extractTextContent(content) {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content.map(item => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item.type === 'text' && item.text) {
|
||||||
|
return item.text;
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item.text) {
|
||||||
|
return item.text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content === 'object' && content.text) {
|
||||||
|
return content.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
let abortController = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKey;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取请求参数
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
model = 'gemini-2.0-flash-exp',
|
||||||
|
temperature = 0.7,
|
||||||
|
max_tokens = 4096,
|
||||||
|
stream = false
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Messages array is required',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'invalid_request'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(model)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: `Model ${model} is not allowed for this API key`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_allowed'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换消息格式
|
||||||
|
const { contents, systemInstruction } = convertMessagesToGemini(messages);
|
||||||
|
|
||||||
|
// 构建 Gemini 请求体
|
||||||
|
const geminiRequestBody = {
|
||||||
|
contents,
|
||||||
|
generationConfig: {
|
||||||
|
temperature,
|
||||||
|
maxOutputTokens: max_tokens,
|
||||||
|
candidateCount: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (systemInstruction) {
|
||||||
|
geminiRequestBody.systemInstruction = { parts: [{ text: systemInstruction }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成会话哈希
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body);
|
||||||
|
|
||||||
|
// 使用统一调度选择账号
|
||||||
|
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model);
|
||||||
|
const account = await geminiAccountService.getAccount(accountId);
|
||||||
|
const { accessToken, refreshToken } = account;
|
||||||
|
|
||||||
|
logger.info(`Using Gemini account: ${accountId} for API key: ${apiKeyData.id}`);
|
||||||
|
|
||||||
|
// 创建中止控制器
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
// 处理客户端断开连接
|
||||||
|
req.on('close', () => {
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
logger.info('Client disconnected, aborting Gemini request');
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken);
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
// 流式响应
|
||||||
|
logger.info('StreamGenerateContent request', {
|
||||||
|
model: model,
|
||||||
|
projectId: account.projectId,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamResponse = await geminiAccountService.generateContentStream(
|
||||||
|
client,
|
||||||
|
{ model, request: geminiRequestBody },
|
||||||
|
null, // user_prompt_id
|
||||||
|
account.projectId,
|
||||||
|
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||||
|
abortController.signal // 传递中止信号
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
// 处理流式响应,转换为 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 非流式响应
|
||||||
|
logger.info('GenerateContent request', {
|
||||||
|
model: model,
|
||||||
|
projectId: account.projectId,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await geminiAccountService.generateContent(
|
||||||
|
client,
|
||||||
|
{ model, request: geminiRequestBody },
|
||||||
|
null, // user_prompt_id
|
||||||
|
account.projectId,
|
||||||
|
req.apiKey?.id // 使用 API Key ID 作为 session ID
|
||||||
|
);
|
||||||
|
|
||||||
|
// 转换为 OpenAI 格式并返回
|
||||||
|
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false);
|
||||||
|
res.json(openaiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`OpenAI-Gemini request completed in ${duration}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OpenAI-Gemini request error:', error);
|
||||||
|
|
||||||
|
// 返回 OpenAI 格式的错误响应
|
||||||
|
const status = error.status || 500;
|
||||||
|
const errorResponse = {
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(status).json(errorResponse);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (abortController) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取模型列表端点(OpenAI 兼容)
|
||||||
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKey;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回支持的 Gemini 模型列表(OpenAI 格式)
|
||||||
|
const models = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-thinking-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-1.5-pro',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-1.5-flash',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: models
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting models:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,7 +3,7 @@ const router = express.Router();
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { authenticateApiKey } = require('../middleware/auth');
|
const { authenticateApiKey } = require('../middleware/auth');
|
||||||
const geminiAccountService = require('../services/geminiAccountService');
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
|
const { getAvailableModels } = require('../services/geminiRelayService');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
// 生成会话哈希
|
// 生成会话哈希
|
||||||
@@ -23,6 +23,142 @@ function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
|||||||
return permissions === 'all' || permissions === requiredPermission;
|
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 兼容的聊天完成端点
|
// OpenAI 兼容的聊天完成端点
|
||||||
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -62,7 +198,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
// 提取请求参数
|
// 提取请求参数
|
||||||
const {
|
const {
|
||||||
messages: requestMessages,
|
messages: requestMessages,
|
||||||
contents,
|
contents: requestContents,
|
||||||
model: bodyModel = 'gemini-2.0-flash-exp',
|
model: bodyModel = 'gemini-2.0-flash-exp',
|
||||||
temperature = 0.7,
|
temperature = 0.7,
|
||||||
max_tokens = 4096,
|
max_tokens = 4096,
|
||||||
@@ -74,8 +210,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
|
|
||||||
// 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents
|
// 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents
|
||||||
let messages = requestMessages;
|
let messages = requestMessages;
|
||||||
if (contents && Array.isArray(contents)) {
|
if (requestContents && Array.isArray(requestContents)) {
|
||||||
messages = contents;
|
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);
|
const sessionHash = generateSessionHash(req);
|
||||||
|
|
||||||
@@ -137,39 +290,141 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 发送请求到 Gemini(已经返回 OpenAI 格式)
|
// 获取OAuth客户端
|
||||||
const geminiResponse = await sendGeminiRequest({
|
const client = await geminiAccountService.getOauthClient(account.accessToken, account.refreshToken);
|
||||||
messages,
|
|
||||||
model,
|
|
||||||
temperature,
|
|
||||||
maxTokens: max_tokens,
|
|
||||||
stream,
|
|
||||||
accessToken: account.accessToken,
|
|
||||||
proxy: account.proxy,
|
|
||||||
apiKeyId: apiKeyData.id,
|
|
||||||
signal: abortController.signal,
|
|
||||||
projectId: account.projectId
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let project = 'verdant-wares-464411-k9';
|
||||||
if (stream) {
|
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('Content-Type', 'text/event-stream');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no');
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
// 流式传输响应
|
// 处理流式响应,转换为 OpenAI 格式
|
||||||
for await (const chunk of geminiResponse) {
|
let buffer = '';
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
break;
|
streamResponse.on('data', (chunk) => {
|
||||||
}
|
try {
|
||||||
res.write(chunk);
|
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 {
|
} 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;
|
const duration = Date.now() - startTime;
|
||||||
|
|||||||
318
src/routes/openaiGeminiRoutes.js.nohas-convert copy.back
Normal file
318
src/routes/openaiGeminiRoutes.js.nohas-convert copy.back
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
const express = require('express');
|
||||||
|
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 crypto = require('crypto');
|
||||||
|
|
||||||
|
// 生成会话哈希
|
||||||
|
function generateSessionHash(req) {
|
||||||
|
const sessionData = [
|
||||||
|
req.headers['user-agent'],
|
||||||
|
req.ip,
|
||||||
|
req.headers['authorization']?.substring(0, 20)
|
||||||
|
].filter(Boolean).join(':');
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(sessionData).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 API Key 权限
|
||||||
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
|
const permissions = apiKeyData.permissions || 'all';
|
||||||
|
return permissions === 'all' || permissions === requiredPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 兼容的聊天完成端点
|
||||||
|
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let abortController = null;
|
||||||
|
let account = null; // Declare account outside try block for error handling
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKey;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 处理请求体结构 - 支持多种格式
|
||||||
|
let requestBody = req.body;
|
||||||
|
|
||||||
|
// 如果请求体被包装在 body 字段中,解包它
|
||||||
|
if (req.body.body && typeof req.body.body === 'object') {
|
||||||
|
requestBody = req.body.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 URL 路径中提取模型信息(如果存在)
|
||||||
|
let urlModel = null;
|
||||||
|
const urlPath = req.body?.config?.url || req.originalUrl || req.url;
|
||||||
|
const modelMatch = urlPath.match(/\/([^\/]+):(?:stream)?[Gg]enerateContent/);
|
||||||
|
if (modelMatch) {
|
||||||
|
urlModel = modelMatch[1];
|
||||||
|
logger.debug(`Extracted model from URL: ${urlModel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取请求参数
|
||||||
|
const {
|
||||||
|
messages: requestMessages,
|
||||||
|
contents,
|
||||||
|
model: bodyModel = 'gemini-2.0-flash-exp',
|
||||||
|
temperature = 0.7,
|
||||||
|
max_tokens = 4096,
|
||||||
|
stream = false
|
||||||
|
} = requestBody;
|
||||||
|
|
||||||
|
// 优先使用 URL 中的模型,其次是请求体中的模型
|
||||||
|
const model = urlModel || bodyModel;
|
||||||
|
|
||||||
|
// 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents
|
||||||
|
let messages = requestMessages;
|
||||||
|
if (contents && Array.isArray(contents)) {
|
||||||
|
messages = contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Messages array is required',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'invalid_request'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(model)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: `Model ${model} is not allowed for this API key`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_allowed'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成会话哈希用于粘性会话
|
||||||
|
const sessionHash = generateSessionHash(req);
|
||||||
|
|
||||||
|
// 选择可用的 Gemini 账户
|
||||||
|
account = await geminiAccountService.selectAvailableAccount(
|
||||||
|
apiKeyData.id,
|
||||||
|
sessionHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: {
|
||||||
|
message: 'No available Gemini accounts',
|
||||||
|
type: 'service_unavailable',
|
||||||
|
code: 'service_unavailable'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`);
|
||||||
|
|
||||||
|
// 标记账户被使用
|
||||||
|
await geminiAccountService.markAccountUsed(account.id);
|
||||||
|
|
||||||
|
// 创建中止控制器
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
// 处理客户端断开连接
|
||||||
|
req.on('close', () => {
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
logger.info('Client disconnected, aborting Gemini request');
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送请求到 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
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
// 设置流式响应头
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
// 非流式响应
|
||||||
|
res.json(geminiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`OpenAI-Gemini request completed in ${duration}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OpenAI-Gemini request error:', error);
|
||||||
|
|
||||||
|
// 处理速率限制
|
||||||
|
if (error.status === 429) {
|
||||||
|
if (req.apiKey && account) {
|
||||||
|
await geminiAccountService.setAccountRateLimited(account.id, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 OpenAI 格式的错误响应
|
||||||
|
const status = error.status || 500;
|
||||||
|
const errorResponse = {
|
||||||
|
error: error.error || {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(status).json(errorResponse);
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (abortController) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenAI 兼容的模型列表端点
|
||||||
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKey;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择账户获取模型列表
|
||||||
|
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
|
||||||
|
|
||||||
|
let models = [];
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
// 获取实际的模型列表
|
||||||
|
models = await getAvailableModels(account.accessToken, account.proxy);
|
||||||
|
} else {
|
||||||
|
// 返回默认模型列表
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用了模型限制,过滤模型列表
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
models = models.filter(model => apiKeyData.restrictedModels.includes(model.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: models
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get OpenAI-Gemini models:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve models',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenAI 兼容的模型详情端点
|
||||||
|
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKey;
|
||||||
|
const modelId = req.params.model;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: `Model '${modelId}' not found`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_found'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回模型信息
|
||||||
|
res.json({
|
||||||
|
id: modelId,
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google',
|
||||||
|
permission: [],
|
||||||
|
root: modelId,
|
||||||
|
parent: null
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get model details:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve model details',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
490
src/routes/openaiGeminiRoutes.js.version-08041512
Normal file
490
src/routes/openaiGeminiRoutes.js.version-08041512
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { authenticateApiKey } = require('../middleware/auth');
|
||||||
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler');
|
||||||
|
const sessionHelper = require('../utils/sessionHelper');
|
||||||
|
|
||||||
|
// 检查 API Key 权限
|
||||||
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
|
const permissions = apiKeyData.permissions || 'all';
|
||||||
|
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();
|
||||||
|
let abortController = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKey;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理请求体结构 - 支持多种格式
|
||||||
|
let requestBody = req.body;
|
||||||
|
|
||||||
|
// 如果请求体被包装在 body 字段中,解包它
|
||||||
|
if (req.body.body && typeof req.body.body === 'object') {
|
||||||
|
requestBody = req.body.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 URL 路径中提取模型信息(如果存在)
|
||||||
|
let urlModel = null;
|
||||||
|
const urlPath = req.body?.config?.url || req.originalUrl || req.url;
|
||||||
|
const modelMatch = urlPath.match(/\/([^\/]+):(?:stream)?[Gg]enerateContent/);
|
||||||
|
if (modelMatch) {
|
||||||
|
urlModel = modelMatch[1];
|
||||||
|
logger.debug(`Extracted model from URL: ${urlModel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取请求参数
|
||||||
|
const {
|
||||||
|
messages: requestMessages,
|
||||||
|
contents: requestContents,
|
||||||
|
model: bodyModel = 'gemini-2.0-flash-exp',
|
||||||
|
temperature = 0.7,
|
||||||
|
max_tokens = 4096,
|
||||||
|
stream = false
|
||||||
|
} = requestBody;
|
||||||
|
|
||||||
|
// 优先使用 URL 中的模型,其次是请求体中的模型
|
||||||
|
const model = urlModel || bodyModel;
|
||||||
|
|
||||||
|
// 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents
|
||||||
|
let messages = requestMessages;
|
||||||
|
if (requestContents && Array.isArray(requestContents)) {
|
||||||
|
messages = requestContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Messages array is required',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'invalid_request'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型限制
|
||||||
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
|
if (!apiKeyData.restrictedModels.includes(model)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: `Model ${model} is not allowed for this API key`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'model_not_allowed'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换消息格式
|
||||||
|
const { contents, systemInstruction } = convertMessagesToGemini(messages);
|
||||||
|
|
||||||
|
// 构建 Gemini 请求体
|
||||||
|
const geminiRequestBody = {
|
||||||
|
contents,
|
||||||
|
generationConfig: {
|
||||||
|
temperature,
|
||||||
|
maxOutputTokens: max_tokens,
|
||||||
|
candidateCount: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (systemInstruction) {
|
||||||
|
geminiRequestBody.systemInstruction = { parts: [{ text: systemInstruction }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成会话哈希
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body);
|
||||||
|
|
||||||
|
// 使用统一调度选择账号
|
||||||
|
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model);
|
||||||
|
const account = await geminiAccountService.getAccount(accountId);
|
||||||
|
const { accessToken, refreshToken } = account;
|
||||||
|
|
||||||
|
logger.info(`Using Gemini account: ${accountId} for API key: ${apiKeyData.id}`);
|
||||||
|
|
||||||
|
// 创建中止控制器
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
// 处理客户端断开连接
|
||||||
|
req.on('close', () => {
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
logger.info('Client disconnected, aborting Gemini request');
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken);
|
||||||
|
|
||||||
|
const project = 'verdant-wares-464411-k9';
|
||||||
|
if (stream) {
|
||||||
|
// 流式响应
|
||||||
|
logger.info('StreamGenerateContent request', {
|
||||||
|
model: model,
|
||||||
|
projectId: project || account.projectId,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamResponse = await geminiAccountService.generateContentStream(
|
||||||
|
client,
|
||||||
|
{ model, request: geminiRequestBody },
|
||||||
|
null, // user_prompt_id
|
||||||
|
project || account.projectId,
|
||||||
|
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||||
|
abortController.signal // 传递中止信号
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
// 处理流式响应,转换为 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 非流式响应
|
||||||
|
logger.info('GenerateContent request', {
|
||||||
|
model: model,
|
||||||
|
projectId: project || account.projectId,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await geminiAccountService.generateContent(
|
||||||
|
client,
|
||||||
|
{ model, request: geminiRequestBody },
|
||||||
|
null, // user_prompt_id
|
||||||
|
project || account.projectId,
|
||||||
|
req.apiKey?.id // 使用 API Key ID 作为 session ID
|
||||||
|
);
|
||||||
|
|
||||||
|
// 转换为 OpenAI 格式并返回
|
||||||
|
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false);
|
||||||
|
res.json(openaiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`OpenAI-Gemini request completed in ${duration}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OpenAI-Gemini request error:', error);
|
||||||
|
|
||||||
|
// 返回 OpenAI 格式的错误响应
|
||||||
|
const status = error.status || 500;
|
||||||
|
const errorResponse = {
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(status).json(errorResponse);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (abortController) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取模型列表端点(OpenAI 兼容)
|
||||||
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyData = req.apiKey;
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
message: 'This API key does not have permission to access Gemini',
|
||||||
|
type: 'permission_denied',
|
||||||
|
code: 'permission_denied'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回支持的 Gemini 模型列表(OpenAI 格式)
|
||||||
|
const models = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-thinking-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-1.5-pro',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-1.5-flash',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: models
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting models:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: 'server_error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -977,7 +977,7 @@ async function generateContent(client, requestData, userPromptId, projectId = nu
|
|||||||
sessionId
|
sessionId
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios({
|
const axiosConfig = {
|
||||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -986,7 +986,9 @@ async function generateContent(client, requestData, userPromptId, projectId = nu
|
|||||||
},
|
},
|
||||||
data: request,
|
data: request,
|
||||||
timeout: 60000, // 生成内容可能需要更长时间
|
timeout: 60000, // 生成内容可能需要更长时间
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const response = await axios(axiosConfig);
|
||||||
|
|
||||||
logger.info('✅ generateContent API调用成功');
|
logger.info('✅ generateContent API调用成功');
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user