|
|
import express from 'express'; |
|
|
import fetch from 'node-fetch'; |
|
|
import dotenv from 'dotenv'; |
|
|
import { v4 as uuidv4 } from 'uuid'; |
|
|
import cors from 'cors'; |
|
|
import fs from 'fs'; |
|
|
import path from 'path'; |
|
|
import crypto from 'crypto'; |
|
|
|
|
|
|
|
|
dotenv.config(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LogManager { |
|
|
constructor() { |
|
|
this.logs = new Map(); |
|
|
this.logFile = path.join(process.cwd(), 'api_logs.json'); |
|
|
this.enableFileLogging = process.env.ENABLE_FILE_LOGGING !== 'false'; |
|
|
|
|
|
if (this.enableFileLogging) { |
|
|
this.loadLogs(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setFileLogging(enabled) { |
|
|
this.enableFileLogging = enabled; |
|
|
if (!enabled) { |
|
|
|
|
|
try { |
|
|
if (fs.existsSync(this.logFile)) { |
|
|
fs.unlinkSync(this.logFile); |
|
|
console.log('📄 日志文件已删除,切换到内存模式'); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('删除日志文件失败:', error); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getFileLoggingStatus() { |
|
|
return this.enableFileLogging; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
loadLogs() { |
|
|
try { |
|
|
if (fs.existsSync(this.logFile)) { |
|
|
const data = fs.readFileSync(this.logFile, 'utf8'); |
|
|
const logsData = JSON.parse(data); |
|
|
this.logs = new Map(Object.entries(logsData)); |
|
|
console.log('✅ 日志文件加载成功'); |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn('⚠️ 加载日志文件失败:', error.message); |
|
|
this.logs = new Map(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
saveLogs() { |
|
|
if (!this.enableFileLogging) { |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
const logsData = Object.fromEntries(this.logs); |
|
|
fs.writeFileSync(this.logFile, JSON.stringify(logsData, null, 2)); |
|
|
} catch (error) { |
|
|
console.error('❌ 保存日志文件失败:', error.message); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
countTokens(text) { |
|
|
if (!text) return 0; |
|
|
|
|
|
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; |
|
|
const englishWords = (text.match(/[a-zA-Z]+/g) || []).length; |
|
|
const punctuation = (text.match(/[^\w\s\u4e00-\u9fa5]/g) || []).length; |
|
|
|
|
|
return Math.ceil(chineseChars * 1.5 + englishWords + punctuation * 0.5); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logRequest(authKey, requestData) { |
|
|
if (!this.logs.has(authKey)) { |
|
|
this.logs.set(authKey, []); |
|
|
} |
|
|
|
|
|
|
|
|
const responseTokens = this.countTokens(requestData.responseContent); |
|
|
|
|
|
const log = { |
|
|
id: uuidv4(), |
|
|
timestamp: new Date().toISOString(), |
|
|
requestTime: requestData.requestTime, |
|
|
responseTime: requestData.responseTime, |
|
|
duration: requestData.duration, |
|
|
requestType: requestData.requestType, |
|
|
model: requestData.model, |
|
|
responseContent: requestData.responseContent, |
|
|
responseTokens: responseTokens, |
|
|
status: requestData.status, |
|
|
error: requestData.error |
|
|
}; |
|
|
|
|
|
this.logs.get(authKey).push(log); |
|
|
|
|
|
|
|
|
if (this.logs.get(authKey).length > 1000) { |
|
|
this.logs.get(authKey).shift(); |
|
|
} |
|
|
|
|
|
this.saveLogs(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getLogs(authKey, page = 1, pageSize = 50) { |
|
|
const logs = this.logs.get(authKey) || []; |
|
|
const total = logs.length; |
|
|
const totalPages = Math.ceil(total / pageSize); |
|
|
const start = (page - 1) * pageSize; |
|
|
const end = start + pageSize; |
|
|
|
|
|
return { |
|
|
logs: logs.slice(start, end).reverse(), |
|
|
pagination: { |
|
|
page, |
|
|
pageSize, |
|
|
total, |
|
|
totalPages |
|
|
} |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getAllLogs() { |
|
|
const allLogs = {}; |
|
|
for (const [authKey, logs] of this.logs) { |
|
|
allLogs[authKey] = logs; |
|
|
} |
|
|
return allLogs; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getStatistics() { |
|
|
const stats = {}; |
|
|
|
|
|
for (const [authKey, logs] of this.logs) { |
|
|
const keyStats = { |
|
|
totalRequests: logs.length, |
|
|
streamRequests: logs.filter(log => log.requestType === 'stream').length, |
|
|
normalRequests: logs.filter(log => log.requestType === 'normal').length, |
|
|
fakeStreamRequests: logs.filter(log => log.requestType === 'fake-stream').length, |
|
|
errorRequests: logs.filter(log => log.error).length, |
|
|
modelUsage: {} |
|
|
}; |
|
|
|
|
|
|
|
|
logs.forEach(log => { |
|
|
if (log.model) { |
|
|
keyStats.modelUsage[log.model] = (keyStats.modelUsage[log.model] || 0) + 1; |
|
|
} |
|
|
}); |
|
|
|
|
|
stats[authKey] = keyStats; |
|
|
} |
|
|
|
|
|
return stats; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
clearLogs(authKey) { |
|
|
this.logs.delete(authKey); |
|
|
this.saveLogs(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
clearAllLogs() { |
|
|
this.logs.clear(); |
|
|
this.saveLogs(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
exportLogsAsJson() { |
|
|
return JSON.stringify(this.getAllLogs(), null, 2); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
exportLogsAsCsv() { |
|
|
let csv = 'AuthKey,Timestamp,RequestTime,ResponseTime,Duration,RequestType,Model,Status,ResponseTokens,ResponseContent,Error\n'; |
|
|
|
|
|
for (const [authKey, logs] of this.logs) { |
|
|
logs.forEach(log => { |
|
|
const row = [ |
|
|
authKey, |
|
|
log.timestamp, |
|
|
log.requestTime || '', |
|
|
log.responseTime || '', |
|
|
log.duration || '', |
|
|
log.requestType || '', |
|
|
log.model || '', |
|
|
log.status || '', |
|
|
log.responseTokens || 0, |
|
|
`"${(log.responseContent || '').replace(/"/g, '""')}"`, |
|
|
`"${(log.error || '').replace(/"/g, '""')}"` |
|
|
].join(','); |
|
|
csv += row + '\n'; |
|
|
}); |
|
|
} |
|
|
|
|
|
return csv; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AuthKeyManager { |
|
|
constructor() { |
|
|
this.keys = new Map(); |
|
|
this.adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; |
|
|
this.initializeKeys(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
initializeKeys() { |
|
|
const authTokensEnv = process.env.AUTH_TOKENS; |
|
|
if (authTokensEnv) { |
|
|
|
|
|
const tokens = authTokensEnv.split(',').map(item => item.trim()); |
|
|
|
|
|
tokens.forEach((token, index) => { |
|
|
if (token.includes(':')) { |
|
|
const [alias, key] = token.split(':'); |
|
|
this.keys.set(key.trim(), { |
|
|
alias: alias.trim(), |
|
|
enabled: true, |
|
|
createdAt: new Date().toISOString() |
|
|
}); |
|
|
} else { |
|
|
this.keys.set(token, { |
|
|
alias: `Key-${index + 1}`, |
|
|
enabled: true, |
|
|
createdAt: new Date().toISOString() |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
|
|
|
const defaultToken = process.env.AUTH_TOKEN || 'sk-123456'; |
|
|
this.keys.set(defaultToken, { |
|
|
alias: 'Default-Key', |
|
|
enabled: true, |
|
|
createdAt: new Date().toISOString() |
|
|
}); |
|
|
} |
|
|
|
|
|
console.log(`✅ 成功加载 ${this.keys.size} 个认证Keys`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
validateAuth(authHeader) { |
|
|
if (!authHeader) { |
|
|
return { valid: false, key: null }; |
|
|
} |
|
|
|
|
|
const token = authHeader.replace('Bearer ', ''); |
|
|
const keyInfo = this.keys.get(token); |
|
|
|
|
|
if (keyInfo && keyInfo.enabled) { |
|
|
return { valid: true, key: token, alias: keyInfo.alias }; |
|
|
} |
|
|
|
|
|
return { valid: false, key: null }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
addKey(alias, token) { |
|
|
if (this.keys.has(token)) { |
|
|
return { success: false, message: 'Key已存在' }; |
|
|
} |
|
|
|
|
|
this.keys.set(token, { |
|
|
alias, |
|
|
enabled: true, |
|
|
createdAt: new Date().toISOString() |
|
|
}); |
|
|
|
|
|
return { success: true, message: 'Key添加成功' }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
updateKeyAlias(token, newAlias) { |
|
|
if (!this.keys.has(token)) { |
|
|
return { success: false, message: 'Key不存在' }; |
|
|
} |
|
|
|
|
|
if (!newAlias || newAlias.trim() === '') { |
|
|
return { success: false, message: '别名不能为空' }; |
|
|
} |
|
|
|
|
|
|
|
|
for (const [existingToken, existingInfo] of this.keys) { |
|
|
if (existingToken !== token && existingInfo.alias === newAlias.trim()) { |
|
|
return { success: false, message: '别名已存在,请使用其他别名' }; |
|
|
} |
|
|
} |
|
|
|
|
|
const keyInfo = this.keys.get(token); |
|
|
keyInfo.alias = newAlias.trim(); |
|
|
|
|
|
return { success: true, message: '别名更新成功' }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
updateKeyStatus(token, enabled) { |
|
|
if (!this.keys.has(token)) { |
|
|
return { success: false, message: 'Key不存在' }; |
|
|
} |
|
|
|
|
|
this.keys.get(token).enabled = enabled; |
|
|
return { success: true, message: `Key已${enabled ? '启用' : '禁用'}` }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deleteKey(token) { |
|
|
if (!this.keys.has(token)) { |
|
|
return { success: false, message: 'Key不存在' }; |
|
|
} |
|
|
|
|
|
this.keys.delete(token); |
|
|
return { success: true, message: 'Key删除成功' }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getAllKeys() { |
|
|
const result = []; |
|
|
for (const [token, info] of this.keys) { |
|
|
result.push({ |
|
|
token, |
|
|
alias: info.alias, |
|
|
enabled: info.enabled, |
|
|
createdAt: info.createdAt |
|
|
}); |
|
|
} |
|
|
return result; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
exportToEnv() { |
|
|
const tokens = []; |
|
|
for (const [token, info] of this.keys) { |
|
|
if (info.enabled) { |
|
|
tokens.push(`${info.alias}:${token}`); |
|
|
} |
|
|
} |
|
|
return `AUTH_TOKENS=${tokens.join(',')}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
validateAdminPassword(password) { |
|
|
return password === this.adminPassword; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Config { |
|
|
constructor() { |
|
|
this.initializeApiKeys(); |
|
|
|
|
|
|
|
|
this.usedApiKeys = []; |
|
|
|
|
|
|
|
|
this.invalidApiKeys = []; |
|
|
|
|
|
|
|
|
this.geminiSafety = [ |
|
|
{ |
|
|
category: 'HARM_CATEGORY_HARASSMENT', |
|
|
threshold: 'OFF', |
|
|
}, |
|
|
{ |
|
|
category: 'HARM_CATEGORY_HATE_SPEECH', |
|
|
threshold: 'OFF', |
|
|
}, |
|
|
{ |
|
|
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', |
|
|
threshold: 'OFF', |
|
|
}, |
|
|
{ |
|
|
category: 'HARM_CATEGORY_DANGEROUS_CONTENT', |
|
|
threshold: 'OFF', |
|
|
}, |
|
|
{ |
|
|
category: 'HARM_CATEGORY_CIVIC_INTEGRITY', |
|
|
threshold: 'OFF', |
|
|
}, |
|
|
]; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
initializeApiKeys() { |
|
|
const apiKeysEnv = process.env.GEMINI_API_KEYS; |
|
|
if (!apiKeysEnv) { |
|
|
console.error('❌ 错误: 未找到 GEMINI_API_KEYS 环境变量'); |
|
|
process.exit(1); |
|
|
} |
|
|
|
|
|
|
|
|
this.apiKeys = apiKeysEnv |
|
|
.split('\n') |
|
|
.map(key => key.trim()) |
|
|
.filter(key => key.length > 0); |
|
|
|
|
|
if (this.apiKeys.length === 0) { |
|
|
console.error('❌ 错误: 没有找到有效的API Keys'); |
|
|
process.exit(1); |
|
|
} |
|
|
|
|
|
console.log(`✅ 成功加载 ${this.apiKeys.length} 个API Keys`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getApiKey() { |
|
|
if (this.apiKeys.length === 0) { |
|
|
if (this.usedApiKeys.length > 0) { |
|
|
this.apiKeys.push(...this.usedApiKeys); |
|
|
this.usedApiKeys = []; |
|
|
} else { |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
const apiKey = this.apiKeys.shift(); |
|
|
this.usedApiKeys.push(apiKey); |
|
|
return apiKey; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getFirstAvailableApiKey() { |
|
|
if (this.apiKeys.length > 0) { |
|
|
return this.apiKeys[0]; |
|
|
} |
|
|
if (this.usedApiKeys.length > 0) { |
|
|
return this.usedApiKeys[0]; |
|
|
} |
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
markKeyAsInvalid(apiKey) { |
|
|
const usedIndex = this.usedApiKeys.indexOf(apiKey); |
|
|
if (usedIndex !== -1) { |
|
|
this.usedApiKeys.splice(usedIndex, 1); |
|
|
} |
|
|
|
|
|
const mainIndex = this.apiKeys.indexOf(apiKey); |
|
|
if (mainIndex !== -1) { |
|
|
this.apiKeys.splice(mainIndex, 1); |
|
|
} |
|
|
|
|
|
if (!this.invalidApiKeys.includes(apiKey)) { |
|
|
this.invalidApiKeys.push(apiKey); |
|
|
} |
|
|
|
|
|
console.warn(`⚠️ API Key 已标记为失效: ${apiKey.substring(0, 10)}...`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
moveToUsed(apiKey) { |
|
|
if (!this.usedApiKeys.includes(apiKey)) { |
|
|
this.usedApiKeys.push(apiKey); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getAvailableKeysCount() { |
|
|
return this.apiKeys.length + this.usedApiKeys.length; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageProcessor { |
|
|
|
|
|
|
|
|
|
|
|
static parseDataUrl(dataUrl) { |
|
|
try { |
|
|
|
|
|
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); |
|
|
if (!match) { |
|
|
throw new Error('无效的data URL格式'); |
|
|
} |
|
|
|
|
|
const mimeType = match[1]; |
|
|
const base64Data = match[2]; |
|
|
|
|
|
|
|
|
const supportedMimeTypes = [ |
|
|
'image/jpeg', |
|
|
'image/jpg', |
|
|
'image/png', |
|
|
'image/gif', |
|
|
'image/webp', |
|
|
'image/bmp', |
|
|
'image/tiff' |
|
|
]; |
|
|
|
|
|
if (!supportedMimeTypes.includes(mimeType.toLowerCase())) { |
|
|
throw new Error(`不支持的图片格式: ${mimeType}`); |
|
|
} |
|
|
|
|
|
return { |
|
|
mimeType, |
|
|
data: base64Data |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error('解析图片data URL错误:', error); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static validateBase64(base64String) { |
|
|
try { |
|
|
|
|
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64String)) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
|
|
|
return base64String.length % 4 === 0; |
|
|
} catch (error) { |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static async fetchImageAsBase64(imageUrl) { |
|
|
try { |
|
|
const response = await fetch(imageUrl, { |
|
|
timeout: 30000, |
|
|
headers: { |
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' |
|
|
} |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(`获取图片失败: HTTP ${response.status}`); |
|
|
} |
|
|
|
|
|
const contentType = response.headers.get('content-type'); |
|
|
if (!contentType || !contentType.startsWith('image/')) { |
|
|
throw new Error(`URL返回的不是图片类型: ${contentType}`); |
|
|
} |
|
|
|
|
|
const buffer = await response.buffer(); |
|
|
const base64Data = buffer.toString('base64'); |
|
|
|
|
|
return { |
|
|
mimeType: contentType, |
|
|
data: base64Data |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error('下载图片错误:', error); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MessageConverter { |
|
|
|
|
|
|
|
|
|
|
|
static async convertMessages(openaiMessages) { |
|
|
const geminiMessages = []; |
|
|
let currentRole = null; |
|
|
let currentParts = []; |
|
|
|
|
|
for (const message of openaiMessages) { |
|
|
let role = message.role; |
|
|
let content = message.content; |
|
|
|
|
|
|
|
|
if(role == 'system'){ |
|
|
role = 'user'; |
|
|
} |
|
|
if (role === 'assistant') { |
|
|
role = 'model'; |
|
|
} |
|
|
|
|
|
|
|
|
let parts = []; |
|
|
|
|
|
if (typeof content === 'string') { |
|
|
|
|
|
parts = [{ text: content }]; |
|
|
} else if (Array.isArray(content)) { |
|
|
|
|
|
parts = await this.convertContentArray(content); |
|
|
} else { |
|
|
|
|
|
parts = [{ text: String(content) }]; |
|
|
} |
|
|
|
|
|
|
|
|
if (role === currentRole) { |
|
|
currentParts.push(...parts); |
|
|
} else { |
|
|
|
|
|
if (currentRole !== null && currentParts.length > 0) { |
|
|
geminiMessages.push({ |
|
|
role: currentRole, |
|
|
parts: currentParts |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
currentRole = role; |
|
|
currentParts = [...parts]; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (currentRole !== null && currentParts.length > 0) { |
|
|
geminiMessages.push({ |
|
|
role: currentRole, |
|
|
parts: currentParts |
|
|
}); |
|
|
} |
|
|
|
|
|
return geminiMessages; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static async convertContentArray(contentArray) { |
|
|
const parts = []; |
|
|
|
|
|
for (const item of contentArray) { |
|
|
try { |
|
|
if (item.type === 'text') { |
|
|
|
|
|
parts.push({ text: item.text || '' }); |
|
|
} else if (item.type === 'image_url') { |
|
|
|
|
|
const imagePart = await this.convertImageContent(item); |
|
|
if (imagePart) { |
|
|
parts.push(imagePart); |
|
|
} |
|
|
} else { |
|
|
|
|
|
console.warn(`未知的内容类型: ${item.type},将转为文本处理`); |
|
|
parts.push({ text: JSON.stringify(item) }); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('转换内容项错误:', error); |
|
|
|
|
|
continue; |
|
|
} |
|
|
} |
|
|
|
|
|
return parts; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static async convertImageContent(imageItem) { |
|
|
try { |
|
|
const imageUrl = imageItem.image_url?.url; |
|
|
if (!imageUrl) { |
|
|
throw new Error('缺少图片URL'); |
|
|
} |
|
|
|
|
|
let imageData; |
|
|
|
|
|
if (imageUrl.startsWith('data:')) { |
|
|
|
|
|
imageData = ImageProcessor.parseDataUrl(imageUrl); |
|
|
|
|
|
|
|
|
if (!ImageProcessor.validateBase64(imageData.data)) { |
|
|
throw new Error('无效的base64图片数据'); |
|
|
} |
|
|
} else if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { |
|
|
|
|
|
imageData = await ImageProcessor.fetchImageAsBase64(imageUrl); |
|
|
} else { |
|
|
throw new Error(`不支持的图片URL格式: ${imageUrl}`); |
|
|
} |
|
|
|
|
|
|
|
|
return { |
|
|
inlineData: { |
|
|
mimeType: imageData.mimeType, |
|
|
data: imageData.data |
|
|
} |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error('转换图片内容错误:', error); |
|
|
|
|
|
return { text: `[图片处理失败: ${error.message}]` }; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static extractParams(openaiRequest) { |
|
|
return { |
|
|
model: openaiRequest.model || 'gemini-2.5-flash', |
|
|
messages: openaiRequest.messages || [], |
|
|
stream: openaiRequest.stream || false, |
|
|
temperature: openaiRequest.temperature, |
|
|
maxTokens: openaiRequest.max_tokens, |
|
|
topP: openaiRequest.top_p |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ModelManager { |
|
|
constructor(config) { |
|
|
this.config = config; |
|
|
this.cachedModels = null; |
|
|
this.cacheExpiry = null; |
|
|
this.cacheTimeout = 5 * 60 * 1000; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getModels() { |
|
|
if (this.cachedModels && this.cacheExpiry && Date.now() < this.cacheExpiry) { |
|
|
return { success: true, data: this.cachedModels }; |
|
|
} |
|
|
|
|
|
const apiKey = this.config.getFirstAvailableApiKey(); |
|
|
if (!apiKey) { |
|
|
return { |
|
|
success: false, |
|
|
error: '没有可用的API Key', |
|
|
status: 503 |
|
|
}; |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, { |
|
|
method: 'GET', |
|
|
headers: { |
|
|
'Content-Type': 'application/json' |
|
|
} |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
return { |
|
|
success: false, |
|
|
error: `获取模型列表失败: ${response.status}`, |
|
|
status: response.status |
|
|
}; |
|
|
} |
|
|
|
|
|
const geminiResponse = await response.json(); |
|
|
const filteredModels = this.filterModels(geminiResponse.models || []); |
|
|
|
|
|
this.cachedModels = filteredModels; |
|
|
this.cacheExpiry = Date.now() + this.cacheTimeout; |
|
|
|
|
|
return { success: true, data: filteredModels }; |
|
|
|
|
|
} catch (error) { |
|
|
console.error('获取模型列表错误:', error); |
|
|
return { |
|
|
success: false, |
|
|
error: '网络请求失败', |
|
|
status: 500 |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
filterModels(models) { |
|
|
const allowedPrefixes = [ |
|
|
'models/gemini-2.5-flash', |
|
|
'models/gemini-2.0-flash', |
|
|
'models/gemini-1.5-flash' |
|
|
]; |
|
|
|
|
|
const excludedModels = [ |
|
|
'models/gemini-1.5-flash-8b' |
|
|
]; |
|
|
|
|
|
const filteredModels = models.filter(model => { |
|
|
const modelName = model.name; |
|
|
|
|
|
if (excludedModels.some(excluded => modelName.startsWith(excluded))) { |
|
|
return false; |
|
|
} |
|
|
if (modelName == "models/gemini-2.5-pro") { |
|
|
return true; |
|
|
} |
|
|
|
|
|
return allowedPrefixes.some(prefix => modelName.startsWith(prefix)); |
|
|
}); |
|
|
|
|
|
|
|
|
const processedModels = filteredModels.map(model => { |
|
|
const modelId = model.name.replace('models/', ''); |
|
|
|
|
|
return { |
|
|
id: modelId, |
|
|
object: 'model', |
|
|
created: Math.floor(Date.now() / 1000), |
|
|
owned_by: 'google', |
|
|
permission: [ |
|
|
{ |
|
|
id: `modelperm-${modelId}`, |
|
|
object: 'model_permission', |
|
|
created: Math.floor(Date.now() / 1000), |
|
|
allow_create_engine: false, |
|
|
allow_sampling: true, |
|
|
allow_logprobs: false, |
|
|
allow_search_indices: false, |
|
|
allow_view: true, |
|
|
allow_fine_tuning: false, |
|
|
organization: '*', |
|
|
group: null, |
|
|
is_blocking: false |
|
|
} |
|
|
], |
|
|
root: modelId, |
|
|
parent: null |
|
|
}; |
|
|
}); |
|
|
|
|
|
return { |
|
|
object: 'list', |
|
|
data: processedModels |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GeminiRequestBuilder { |
|
|
constructor(config) { |
|
|
this.config = config; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
buildRequestBody(geminiMessages, params) { |
|
|
const requestBody = { |
|
|
contents: geminiMessages, |
|
|
safetySettings: this.config.geminiSafety, |
|
|
generationConfig: {} |
|
|
}; |
|
|
|
|
|
if (params.temperature !== undefined) { |
|
|
requestBody.generationConfig.temperature = params.temperature; |
|
|
} |
|
|
|
|
|
if (params.maxTokens !== undefined) { |
|
|
requestBody.generationConfig.maxOutputTokens = params.maxTokens; |
|
|
} |
|
|
|
|
|
if (params.topP !== undefined) { |
|
|
requestBody.generationConfig.topP = params.topP; |
|
|
} |
|
|
|
|
|
if (params.model == "gemini-2.5-pro") { |
|
|
requestBody.generationConfig.thinkingConfig = { |
|
|
thinkingBudget: 128 |
|
|
}; |
|
|
} |
|
|
return requestBody; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
buildApiUrl(model, apiKey, isStream = false) { |
|
|
const method = isStream ? 'streamGenerateContent' : 'generateContent'; |
|
|
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:${method}?key=${apiKey}`; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ResponseConverter { |
|
|
|
|
|
|
|
|
|
|
|
static convertStreamChunk(geminiData, requestId, model) { |
|
|
try { |
|
|
if (geminiData.candidates && geminiData.candidates[0]) { |
|
|
const candidate = geminiData.candidates[0]; |
|
|
if (candidate.content && candidate.content.parts) { |
|
|
const text = candidate.content.parts[0]?.text || ''; |
|
|
const openaiChunk = { |
|
|
id: requestId, |
|
|
object: 'chat.completion.chunk', |
|
|
created: Math.floor(Date.now() / 1000), |
|
|
model: model, |
|
|
choices: [{ |
|
|
index: 0, |
|
|
delta: { content: text }, |
|
|
finish_reason: candidate.finishReason === 'STOP' ? 'stop' : null |
|
|
}] |
|
|
}; |
|
|
return `data: ${JSON.stringify(openaiChunk)}\n\n`; |
|
|
} |
|
|
} |
|
|
return ''; |
|
|
} catch (error) { |
|
|
console.error('转换流响应块错误:', error); |
|
|
return ''; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static convertNormalResponse(geminiResponse, requestId, model) { |
|
|
const openaiResponse = { |
|
|
id: requestId, |
|
|
object: 'chat.completion', |
|
|
created: Math.floor(Date.now() / 1000), |
|
|
model: model, |
|
|
choices: [], |
|
|
usage: { |
|
|
prompt_tokens: 0, |
|
|
completion_tokens: 0, |
|
|
total_tokens: 0 |
|
|
} |
|
|
}; |
|
|
|
|
|
if (geminiResponse.candidates && geminiResponse.candidates[0]) { |
|
|
const candidate = geminiResponse.candidates[0]; |
|
|
if (candidate.content && candidate.content.parts) { |
|
|
const text = candidate.content.parts.map(part => part.text).join(''); |
|
|
openaiResponse.choices.push({ |
|
|
index: 0, |
|
|
message: { |
|
|
role: 'assistant', |
|
|
content: text |
|
|
}, |
|
|
finish_reason: candidate.finishReason === 'STOP' ? 'stop' : 'length' |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (geminiResponse.usageMetadata) { |
|
|
openaiResponse.usage = { |
|
|
prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0, |
|
|
completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0, |
|
|
total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0 |
|
|
}; |
|
|
} |
|
|
|
|
|
return openaiResponse; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static hasValidTextContent(geminiResponse) { |
|
|
if (!geminiResponse.candidates || !geminiResponse.candidates[0]) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
const candidate = geminiResponse.candidates[0]; |
|
|
if (!candidate.content || !candidate.content.parts) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
const text = candidate.content.parts |
|
|
.map(part => part.text || '') |
|
|
.join('') |
|
|
.trim(); |
|
|
|
|
|
return text.length > 0; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static splitTextToFakeStream(text, requestId, model) { |
|
|
const chunks = []; |
|
|
const chunkSize = 3; |
|
|
|
|
|
for (let i = 0; i < text.length; i += chunkSize) { |
|
|
const chunk = text.slice(i, i + chunkSize); |
|
|
const isLast = i + chunkSize >= text.length; |
|
|
|
|
|
const openaiChunk = { |
|
|
id: requestId, |
|
|
object: 'chat.completion.chunk', |
|
|
created: Math.floor(Date.now() / 1000), |
|
|
model: model, |
|
|
choices: [{ |
|
|
index: 0, |
|
|
delta: { content: chunk }, |
|
|
finish_reason: isLast ? 'stop' : null |
|
|
}] |
|
|
}; |
|
|
|
|
|
chunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`); |
|
|
} |
|
|
|
|
|
return chunks; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GeminiRealtimeStreamParser { |
|
|
constructor(response, onChunk) { |
|
|
this.response = response; |
|
|
this.onChunk = onChunk; |
|
|
this.buffer = ''; |
|
|
this.bufferLv = 0; |
|
|
this.inString = false; |
|
|
this.escapeNext = false; |
|
|
this.decoder = new TextDecoder(); |
|
|
} |
|
|
|
|
|
async start() { |
|
|
try { |
|
|
for await (const chunk of this.response.body) { |
|
|
const text = this.decoder.decode(chunk, { stream: true }); |
|
|
await this.processText(text); |
|
|
} |
|
|
|
|
|
await this.handleRemainingBuffer(); |
|
|
} catch (error) { |
|
|
console.error('流式解析错误:', error); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
async processText(text) { |
|
|
for (const char of text) { |
|
|
if (this.escapeNext) { |
|
|
if (this.bufferLv > 1) { |
|
|
this.buffer += char; |
|
|
} |
|
|
this.escapeNext = false; |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (char === '\\' && this.inString) { |
|
|
this.escapeNext = true; |
|
|
if (this.bufferLv > 1) { |
|
|
this.buffer += char; |
|
|
} |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (char === '"') { |
|
|
this.inString = !this.inString; |
|
|
} |
|
|
|
|
|
if (!this.inString) { |
|
|
if (char === '{' || char === '[') { |
|
|
this.bufferLv++; |
|
|
} else if (char === '}' || char === ']') { |
|
|
this.bufferLv--; |
|
|
} |
|
|
} |
|
|
|
|
|
if (this.bufferLv > 1) { |
|
|
if (this.inString && char === '\n') { |
|
|
this.buffer += '\\n'; |
|
|
} else { |
|
|
this.buffer += char; |
|
|
} |
|
|
} else if (this.bufferLv === 1 && this.buffer) { |
|
|
this.buffer += '}'; |
|
|
|
|
|
try { |
|
|
const bufferJson = JSON.parse(this.buffer); |
|
|
await this.onChunk(bufferJson); |
|
|
} catch (parseError) { |
|
|
console.error('解析Gemini流数据错误:', parseError); |
|
|
} |
|
|
|
|
|
this.buffer = ''; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
async handleRemainingBuffer() { |
|
|
if (this.buffer.trim() && this.bufferLv >= 1) { |
|
|
try { |
|
|
if (!this.buffer.endsWith('}')) { |
|
|
this.buffer += '}'; |
|
|
} |
|
|
const bufferJson = JSON.parse(this.buffer); |
|
|
await this.onChunk(bufferJson); |
|
|
} catch (parseError) { |
|
|
console.error('解析最后的缓冲区数据错误:', parseError); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AuthMiddleware { |
|
|
constructor(authKeyManager, logManager) { |
|
|
this.authKeyManager = authKeyManager; |
|
|
this.logManager = logManager; |
|
|
} |
|
|
|
|
|
middleware() { |
|
|
return (req, res, next) => { |
|
|
|
|
|
if (req.path === '/health' || |
|
|
req.method === 'OPTIONS' || |
|
|
req.path.startsWith('/admin') || |
|
|
req.path === '/logs.html') { |
|
|
return next(); |
|
|
} |
|
|
|
|
|
const authHeader = req.headers.authorization; |
|
|
const authResult = this.authKeyManager.validateAuth(authHeader); |
|
|
|
|
|
if (!authResult.valid) { |
|
|
return res.status(401).json({ |
|
|
error: { |
|
|
message: 'Invalid authentication credentials', |
|
|
type: 'invalid_request_error', |
|
|
code: 'invalid_api_key' |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
req.authKey = authResult.key; |
|
|
req.authAlias = authResult.alias; |
|
|
next(); |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ApiProxyService { |
|
|
constructor() { |
|
|
this.config = new Config(); |
|
|
this.requestBuilder = new GeminiRequestBuilder(this.config); |
|
|
this.modelManager = new ModelManager(this.config); |
|
|
this.logManager = new LogManager(); |
|
|
this.authKeyManager = new AuthKeyManager(); |
|
|
this.authMiddleware = new AuthMiddleware(this.authKeyManager, this.logManager); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logRequest(authKey, requestData) { |
|
|
this.logManager.logRequest(authKey, requestData); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async handleChatRequest(req, res) { |
|
|
const requestTime = new Date().toISOString(); |
|
|
const startTime = Date.now(); |
|
|
let logData = { |
|
|
requestTime, |
|
|
requestType: 'normal', |
|
|
model: '', |
|
|
responseContent: '', |
|
|
status: 'success', |
|
|
error: null |
|
|
}; |
|
|
|
|
|
try { |
|
|
const requestId = `chatcmpl-${uuidv4()}`; |
|
|
const params = MessageConverter.extractParams(req.body); |
|
|
logData.model = params.model; |
|
|
logData.requestType = params.stream ? 'stream' : 'normal'; |
|
|
|
|
|
|
|
|
const geminiMessages = await MessageConverter.convertMessages(params.messages); |
|
|
|
|
|
if (!geminiMessages || geminiMessages.length === 0) { |
|
|
logData.status = 'error'; |
|
|
logData.error = '无效的消息格式或消息为空'; |
|
|
this.logRequest(req.authKey, { |
|
|
...logData, |
|
|
responseTime: new Date().toISOString(), |
|
|
duration: Date.now() - startTime |
|
|
}); |
|
|
|
|
|
return res.status(400).json({ |
|
|
error: { |
|
|
message: '无效的消息格式或消息为空', |
|
|
type: 'invalid_request_error', |
|
|
code: 'invalid_messages' |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
const requestBody = this.requestBuilder.buildRequestBody(geminiMessages, params); |
|
|
|
|
|
if (params.stream) { |
|
|
const result = await this.handleStreamRequest(requestBody, params, requestId, res, req.authKey, logData, startTime); |
|
|
if (!result.success) { |
|
|
res.status(result.status || 500).json({ error: result.error }); |
|
|
} |
|
|
} else { |
|
|
const result = await this.executeNormalRequest(requestBody, params, requestId); |
|
|
|
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
|
|
|
if (result.success) { |
|
|
logData.responseContent = result.data.choices[0]?.message?.content || ''; |
|
|
this.logRequest(req.authKey, logData); |
|
|
res.json(result.data); |
|
|
} else { |
|
|
logData.status = 'error'; |
|
|
logData.error = result.error; |
|
|
this.logRequest(req.authKey, logData); |
|
|
res.status(result.status || 500).json({ error: result.error }); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('处理聊天请求错误:', error); |
|
|
logData.status = 'error'; |
|
|
logData.error = error.message; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(req.authKey, logData); |
|
|
|
|
|
res.status(500).json({ |
|
|
error: { |
|
|
message: '内部服务器错误: ' + error.message, |
|
|
type: 'internal_server_error', |
|
|
code: 'server_error' |
|
|
} |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async handleFakeStreamChatRequest(req, res) { |
|
|
const requestTime = new Date().toISOString(); |
|
|
const startTime = Date.now(); |
|
|
let logData = { |
|
|
requestTime, |
|
|
requestType: 'fake-stream', |
|
|
model: '', |
|
|
responseContent: '', |
|
|
status: 'success', |
|
|
error: null |
|
|
}; |
|
|
|
|
|
try { |
|
|
const requestId = `chatcmpl-${uuidv4()}`; |
|
|
const params = MessageConverter.extractParams(req.body); |
|
|
logData.model = params.model; |
|
|
|
|
|
|
|
|
const geminiMessages = await MessageConverter.convertMessages(params.messages); |
|
|
|
|
|
if (!geminiMessages || geminiMessages.length === 0) { |
|
|
logData.status = 'error'; |
|
|
logData.error = '无效的消息格式或消息为空'; |
|
|
this.logRequest(req.authKey, { |
|
|
...logData, |
|
|
responseTime: new Date().toISOString(), |
|
|
duration: Date.now() - startTime |
|
|
}); |
|
|
|
|
|
return res.status(400).json({ |
|
|
error: { |
|
|
message: '无效的消息格式或消息为空', |
|
|
type: 'invalid_request_error', |
|
|
code: 'invalid_messages' |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
const requestBody = this.requestBuilder.buildRequestBody(geminiMessages, params); |
|
|
|
|
|
if (params.stream) { |
|
|
|
|
|
await this.handleFakeStreamRequest(requestBody, params, requestId, res, req.authKey, logData, startTime); |
|
|
} else { |
|
|
|
|
|
const result = await this.executeNormalRequest(requestBody, params, requestId); |
|
|
|
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
|
|
|
if (result.success) { |
|
|
logData.responseContent = result.data.choices[0]?.message?.content || ''; |
|
|
this.logRequest(req.authKey, logData); |
|
|
res.json(result.data); |
|
|
} else { |
|
|
logData.status = 'error'; |
|
|
logData.error = result.error; |
|
|
this.logRequest(req.authKey, logData); |
|
|
res.status(result.status || 500).json({ error: result.error }); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('处理假流式聊天请求错误:', error); |
|
|
logData.status = 'error'; |
|
|
logData.error = error.message; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(req.authKey, logData); |
|
|
|
|
|
|
|
|
if (!res.headersSent) { |
|
|
res.status(500).json({ |
|
|
error: { |
|
|
message: '内部服务器错误: ' + error.message, |
|
|
type: 'internal_server_error', |
|
|
code: 'server_error' |
|
|
} |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async handleFakeStreamRequest(requestBody, params, requestId, res, authKey, logData, startTime) { |
|
|
let responseStarted = false; |
|
|
let pingInterval = null; |
|
|
|
|
|
try { |
|
|
|
|
|
const result = await this.executeNormalRequest(requestBody, params, requestId); |
|
|
|
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
|
|
|
if (!result.success) { |
|
|
logData.status = 'error'; |
|
|
logData.error = result.error; |
|
|
this.logRequest(authKey, logData); |
|
|
|
|
|
|
|
|
if (!res.headersSent) { |
|
|
res.writeHead(200, { |
|
|
'Content-Type': 'text/event-stream', |
|
|
'Cache-Control': 'no-cache', |
|
|
'Connection': 'keep-alive', |
|
|
'Access-Control-Allow-Origin': '*' |
|
|
}); |
|
|
responseStarted = true; |
|
|
} |
|
|
|
|
|
if (responseStarted) { |
|
|
const errorChunk = { |
|
|
id: requestId, |
|
|
object: 'chat.completion.chunk', |
|
|
created: Math.floor(Date.now() / 1000), |
|
|
model: params.model, |
|
|
choices: [{ |
|
|
index: 0, |
|
|
delta: { content: `错误: ${result.error}` }, |
|
|
finish_reason: 'stop' |
|
|
}] |
|
|
}; |
|
|
res.write(`data: ${JSON.stringify(errorChunk)}\n\n`); |
|
|
res.write('data: [DONE]\n\n'); |
|
|
res.end(); |
|
|
} |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const responseText = result.data.choices[0]?.message?.content || ''; |
|
|
logData.responseContent = responseText; |
|
|
this.logRequest(authKey, logData); |
|
|
|
|
|
|
|
|
if (!res.headersSent) { |
|
|
res.writeHead(200, { |
|
|
'Content-Type': 'text/event-stream', |
|
|
'Cache-Control': 'no-cache', |
|
|
'Connection': 'keep-alive', |
|
|
'Access-Control-Allow-Origin': '*' |
|
|
}); |
|
|
responseStarted = true; |
|
|
|
|
|
|
|
|
pingInterval = setInterval(() => { |
|
|
try { |
|
|
if (!res.destroyed) { |
|
|
res.write(': ping\n\n'); |
|
|
} |
|
|
} catch (error) { |
|
|
clearInterval(pingInterval); |
|
|
} |
|
|
}, 1000); |
|
|
} |
|
|
|
|
|
if (responseText && responseStarted) { |
|
|
|
|
|
const chunks = ResponseConverter.splitTextToFakeStream(responseText, requestId, params.model); |
|
|
|
|
|
|
|
|
for (const chunk of chunks) { |
|
|
if (res.destroyed) break; |
|
|
res.write(chunk); |
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50)); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (pingInterval) { |
|
|
clearInterval(pingInterval); |
|
|
} |
|
|
|
|
|
if (responseStarted && !res.destroyed) { |
|
|
res.write('data: [DONE]\n\n'); |
|
|
res.end(); |
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
console.error('处理假流式请求错误:', error); |
|
|
|
|
|
logData.status = 'error'; |
|
|
logData.error = error.message; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
|
|
|
|
|
|
if (pingInterval) { |
|
|
clearInterval(pingInterval); |
|
|
} |
|
|
|
|
|
|
|
|
if (!res.headersSent && !responseStarted) { |
|
|
try { |
|
|
res.writeHead(200, { |
|
|
'Content-Type': 'text/event-stream', |
|
|
'Cache-Control': 'no-cache', |
|
|
'Connection': 'keep-alive', |
|
|
'Access-Control-Allow-Origin': '*' |
|
|
}); |
|
|
responseStarted = true; |
|
|
} catch (headerError) { |
|
|
console.error('设置响应头失败:', headerError); |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (responseStarted && !res.destroyed) { |
|
|
try { |
|
|
const errorChunk = { |
|
|
id: requestId, |
|
|
object: 'chat.completion.chunk', |
|
|
created: Math.floor(Date.now() / 1000), |
|
|
model: params.model, |
|
|
choices: [{ |
|
|
index: 0, |
|
|
delta: { content: `错误: ${error.message}` }, |
|
|
finish_reason: 'stop' |
|
|
}] |
|
|
}; |
|
|
res.write(`data: ${JSON.stringify(errorChunk)}\n\n`); |
|
|
res.write('data: [DONE]\n\n'); |
|
|
res.end(); |
|
|
} catch (writeError) { |
|
|
console.error('写入错误响应失败:', writeError); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async handleStreamRequest(requestBody, params, requestId, res, authKey, logData, startTime, retryCount = 0) { |
|
|
const maxRetries = 3; |
|
|
let apiKey = null; |
|
|
let response = null; |
|
|
|
|
|
|
|
|
for (let keyAttempt = 0; keyAttempt < maxRetries; keyAttempt++) { |
|
|
console.log(`尝试请求 (${keyAttempt + 1}/${maxRetries})`); |
|
|
apiKey = this.config.getApiKey(); |
|
|
if (!apiKey) { |
|
|
if (this.config.getAvailableKeysCount() === 0) { |
|
|
logData.status = 'error'; |
|
|
logData.error = '目前暂无可用的API Key'; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
return { success: false, error: '目前暂无可用的API Key', status: 503 }; |
|
|
} |
|
|
continue; |
|
|
} |
|
|
|
|
|
console.log(`使用API Key: ${apiKey?.substring(0, 10)}...`); |
|
|
try { |
|
|
const apiUrl = this.requestBuilder.buildApiUrl(params.model, apiKey, true); |
|
|
|
|
|
response = await fetch(apiUrl, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(requestBody) |
|
|
}); |
|
|
|
|
|
if (response.status === 403) { |
|
|
this.config.markKeyAsInvalid(apiKey); |
|
|
console.log(`API Key失效,尝试下一个 (${keyAttempt + 1}/${maxRetries})`); |
|
|
continue; |
|
|
} |
|
|
if (response.status === 400) { |
|
|
this.config.markKeyAsInvalid(apiKey); |
|
|
console.log(`API Key过期,尝试下一个 (${keyAttempt + 1}/${maxRetries})`); |
|
|
continue; |
|
|
} |
|
|
if (response.status === 429) { |
|
|
this.config.moveToUsed(apiKey); |
|
|
console.log(`请求频率限制,切换API Key (${keyAttempt + 1}/${maxRetries})`); |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (response.status === 500) { |
|
|
this.config.moveToUsed(apiKey); |
|
|
logData.status = 'error'; |
|
|
logData.error = '目前服务器繁忙,请稍后重试'; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
return { success: false, error: '目前服务器繁忙,请稍后重试', status: 500 }; |
|
|
} |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text(); |
|
|
console.error(`API请求失败: ${response.status}, 错误信息: ${errorText}`); |
|
|
logData.status = 'error'; |
|
|
logData.error = `API请求失败: ${response.status}`; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
return { success: false, error: `API请求失败: ${response.status}`, status: response.status }; |
|
|
} |
|
|
|
|
|
|
|
|
let firstChunkData = null; |
|
|
let isProhibitedContent = false; |
|
|
let responseStarted = false; |
|
|
let responseContent = ''; |
|
|
|
|
|
const parser = new GeminiRealtimeStreamParser(response, async (geminiData) => { |
|
|
|
|
|
if (firstChunkData === null) { |
|
|
firstChunkData = geminiData; |
|
|
|
|
|
|
|
|
if (geminiData.promptFeedback && |
|
|
geminiData.promptFeedback.blockReason === 'PROHIBITED_CONTENT') { |
|
|
isProhibitedContent = true; |
|
|
console.log(`内容被阻止 (PROHIBITED_CONTENT),切换API Key重试 (${keyAttempt + 1}/${maxRetries})`); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
res.writeHead(200, { |
|
|
'Content-Type': 'text/event-stream', |
|
|
'Cache-Control': 'no-cache', |
|
|
'Connection': 'keep-alive', |
|
|
'Access-Control-Allow-Origin': '*' |
|
|
}); |
|
|
responseStarted = true; |
|
|
|
|
|
|
|
|
const convertedChunk = ResponseConverter.convertStreamChunk(geminiData, requestId, params.model); |
|
|
if (convertedChunk) { |
|
|
res.write(convertedChunk); |
|
|
|
|
|
if (geminiData.candidates && geminiData.candidates[0] && |
|
|
geminiData.candidates[0].content && geminiData.candidates[0].content.parts) { |
|
|
responseContent += geminiData.candidates[0].content.parts[0]?.text || ''; |
|
|
} |
|
|
} |
|
|
} else { |
|
|
|
|
|
if (responseStarted && !isProhibitedContent) { |
|
|
const convertedChunk = ResponseConverter.convertStreamChunk(geminiData, requestId, params.model); |
|
|
if (convertedChunk) { |
|
|
res.write(convertedChunk); |
|
|
|
|
|
if (geminiData.candidates && geminiData.candidates[0] && |
|
|
geminiData.candidates[0].content && geminiData.candidates[0].content.parts) { |
|
|
responseContent += geminiData.candidates[0].content.parts[0]?.text || ''; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
await parser.start(); |
|
|
|
|
|
|
|
|
if (isProhibitedContent) { |
|
|
this.config.moveToUsed(apiKey); |
|
|
if (keyAttempt < maxRetries - 1) { |
|
|
continue; |
|
|
} |
|
|
logData.status = 'error'; |
|
|
logData.error = '内容被阻止'; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
return { success: false, error: '内容被阻止', status: 503 }; |
|
|
} |
|
|
|
|
|
|
|
|
if (responseStarted) { |
|
|
res.write('data: [DONE]\n\n'); |
|
|
res.end(); |
|
|
|
|
|
logData.responseContent = responseContent; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
|
|
|
return { success: true }; |
|
|
} |
|
|
|
|
|
|
|
|
logData.status = 'error'; |
|
|
logData.error = '未收到有效响应'; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
return { success: false, error: '未收到有效响应', status: 204 }; |
|
|
|
|
|
} catch (error) { |
|
|
console.error(`执行流式请求错误 (尝试 ${keyAttempt + 1}/${maxRetries}):`, error); |
|
|
this.config.moveToUsed(apiKey); |
|
|
|
|
|
if (keyAttempt < maxRetries - 1) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
logData.status = 'error'; |
|
|
logData.error = '网络请求失败: ' + error.message; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
return { success: false, error: '网络请求失败: ' + error.message, status: 500 }; |
|
|
} |
|
|
} |
|
|
|
|
|
logData.status = 'error'; |
|
|
logData.error = '所有重试均失败'; |
|
|
logData.responseTime = new Date().toISOString(); |
|
|
logData.duration = Date.now() - startTime; |
|
|
this.logRequest(authKey, logData); |
|
|
return { success: false, error: '所有重试均失败', status: 500 }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async executeNormalRequest(requestBody, params, requestId, retryCount = 0) { |
|
|
const maxRetries = 3; |
|
|
let apiKey = null; |
|
|
let response = null; |
|
|
let geminiResponse = null; |
|
|
|
|
|
|
|
|
for (let keyAttempt = 0; keyAttempt < maxRetries; keyAttempt++) { |
|
|
console.log(`尝试请求 (${keyAttempt + 1}/${maxRetries})`); |
|
|
apiKey = this.config.getApiKey(); |
|
|
|
|
|
if (!apiKey) { |
|
|
if (this.config.getAvailableKeysCount() === 0) { |
|
|
return { success: false, error: '目前暂无可用的API Key', status: 503 }; |
|
|
} |
|
|
continue; |
|
|
} |
|
|
console.log(`使用API Key: ${apiKey?.substring(0, 10)}...`); |
|
|
|
|
|
try { |
|
|
const apiUrl = this.requestBuilder.buildApiUrl(params.model, apiKey, false); |
|
|
|
|
|
response = await fetch(apiUrl, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(requestBody) |
|
|
}); |
|
|
|
|
|
if (response.status === 403) { |
|
|
this.config.markKeyAsInvalid(apiKey); |
|
|
console.log(`API Key失效,尝试下一个 (${keyAttempt + 1}/${maxRetries})`); |
|
|
continue; |
|
|
} |
|
|
if (response.status === 400) { |
|
|
this.config.markKeyAsInvalid(apiKey); |
|
|
console.log(`API Key过期,尝试下一个 (${keyAttempt + 1}/${maxRetries})`); |
|
|
continue; |
|
|
} |
|
|
if (response.status === 429) { |
|
|
this.config.moveToUsed(apiKey); |
|
|
console.log(`请求频率限制,切换API Key (${keyAttempt + 1}/${maxRetries})`); |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (response.status === 500) { |
|
|
this.config.moveToUsed(apiKey); |
|
|
return { success: false, error: '目前服务器繁忙,请稍后重试', status: 500 }; |
|
|
} |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text(); |
|
|
console.error(`API请求失败: ${response.status}, 错误信息: ${errorText}`); |
|
|
return { success: false, error: `API请求失败: ${response.status}`, status: response.status }; |
|
|
} |
|
|
|
|
|
geminiResponse = await response.json(); |
|
|
|
|
|
|
|
|
if (geminiResponse.candidates && |
|
|
geminiResponse.candidates.finishReason === 'PROHIBITED_CONTENT') { |
|
|
console.log(`内容被阻止 (PROHIBITED_CONTENT),切换API Key重试 (${keyAttempt + 1}/${maxRetries})`); |
|
|
this.config.moveToUsed(apiKey); |
|
|
if (keyAttempt < maxRetries - 1) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
return { success: false, error: '内容被阻止', status: 503 }; |
|
|
} |
|
|
|
|
|
|
|
|
if (!ResponseConverter.hasValidTextContent(geminiResponse)) { |
|
|
console.log(`响应无文本内容,切换API Key重试 (${keyAttempt + 1}/${maxRetries})`); |
|
|
this.config.moveToUsed(apiKey); |
|
|
|
|
|
if (keyAttempt < maxRetries - 1) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
return { success: false, error: '响应无文本内容', status: 204 }; |
|
|
} |
|
|
|
|
|
const openaiResponse = ResponseConverter.convertNormalResponse(geminiResponse, requestId, params.model); |
|
|
return { success: true, data: openaiResponse }; |
|
|
|
|
|
} catch (error) { |
|
|
console.error(`执行非流式请求错误 (尝试 ${keyAttempt + 1}/${maxRetries}):`, error); |
|
|
this.config.moveToUsed(apiKey); |
|
|
|
|
|
if (keyAttempt < maxRetries - 1) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
return { success: false, error: '网络请求失败: ' + error.message, status: 500 }; |
|
|
} |
|
|
} |
|
|
|
|
|
return { success: false, error: '所有重试均失败', status: 500 }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async handleModelsRequest(req, res) { |
|
|
try { |
|
|
const result = await this.modelManager.getModels(); |
|
|
|
|
|
if (result.success) { |
|
|
res.json(result.data); |
|
|
} else { |
|
|
res.status(result.status || 500).json({ error: result.error }); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('处理模型列表请求错误:', error); |
|
|
res.status(500).json({ error: '内部服务器错误' }); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Server { |
|
|
constructor() { |
|
|
this.app = express(); |
|
|
this.apiProxy = new ApiProxyService(); |
|
|
this.setupMiddleware(); |
|
|
this.setupRoutes(); |
|
|
} |
|
|
|
|
|
setupMiddleware() { |
|
|
|
|
|
this.app.use(cors({ |
|
|
origin: '*', |
|
|
credentials: true, |
|
|
optionsSuccessStatus: 200 |
|
|
})); |
|
|
|
|
|
|
|
|
this.app.use(express.json({ limit: '50mb' })); |
|
|
this.app.use(express.urlencoded({ limit: '50mb', extended: true })); |
|
|
|
|
|
|
|
|
this.app.use('/static', express.static(path.join(process.cwd(), 'public'))); |
|
|
|
|
|
|
|
|
this.app.use(this.apiProxy.authMiddleware.middleware()); |
|
|
|
|
|
|
|
|
this.app.use((req, res, next) => { |
|
|
const start = Date.now(); |
|
|
res.on('finish', () => { |
|
|
const duration = Date.now() - start; |
|
|
console.log(`${req.method} ${req.path} - ${res.statusCode} [${duration}ms]`); |
|
|
}); |
|
|
next(); |
|
|
}); |
|
|
} |
|
|
|
|
|
setupRoutes() { |
|
|
|
|
|
this.app.post('/v1/chat/completions', (req, res) => { |
|
|
this.apiProxy.handleChatRequest(req, res); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.post('/fakestream/v1/chat/completions', (req, res) => { |
|
|
this.apiProxy.handleFakeStreamChatRequest(req, res); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/v1/models', (req, res) => { |
|
|
this.apiProxy.handleModelsRequest(req, res); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/fakestream/v1/models', (req, res) => { |
|
|
this.apiProxy.handleModelsRequest(req, res); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.post('/admin/auth', (req, res) => { |
|
|
const { password } = req.body; |
|
|
if (this.apiProxy.authKeyManager.validateAdminPassword(password)) { |
|
|
const token = crypto.randomBytes(32).toString('hex'); |
|
|
res.json({ success: true, token }); |
|
|
} else { |
|
|
res.status(401).json({ success: false, message: '密码错误' }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/logs.html', (req, res) => { |
|
|
res.sendFile(path.join(process.cwd(), 'logs.html')); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/admin/logs/:authKey', (req, res) => { |
|
|
const { authKey } = req.params; |
|
|
const page = parseInt(req.query.page) || 1; |
|
|
const pageSize = parseInt(req.query.pageSize) || 50; |
|
|
|
|
|
const result = this.apiProxy.logManager.getLogs(authKey, page, pageSize); |
|
|
res.json(result); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/admin/all-logs', (req, res) => { |
|
|
const allLogs = []; |
|
|
const authKeys = this.apiProxy.authKeyManager.getAllKeys(); |
|
|
|
|
|
for (const keyInfo of authKeys) { |
|
|
const keyLogs = this.apiProxy.logManager.getLogs(keyInfo.token, 1, 10000); |
|
|
keyLogs.logs.forEach(log => { |
|
|
allLogs.push({ |
|
|
...log, |
|
|
authKey: keyInfo.token, |
|
|
keyAlias: keyInfo.alias |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
allLogs.sort((a, b) => new Date(b.timestamp || b.requestTime) - new Date(a.timestamp || a.requestTime)); |
|
|
|
|
|
res.json(allLogs); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/admin/statistics', (req, res) => { |
|
|
const stats = this.apiProxy.logManager.getStatistics(); |
|
|
res.json(stats); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/admin/keys', (req, res) => { |
|
|
const keys = this.apiProxy.authKeyManager.getAllKeys(); |
|
|
res.json(keys); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.post('/admin/keys', (req, res) => { |
|
|
const { alias, token } = req.body; |
|
|
const result = this.apiProxy.authKeyManager.addKey(alias, token); |
|
|
res.json(result); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.put('/admin/keys/:token/alias', (req, res) => { |
|
|
const { token } = req.params; |
|
|
const { alias } = req.body; |
|
|
|
|
|
if (!alias || alias.trim() === '') { |
|
|
return res.status(400).json({ |
|
|
success: false, |
|
|
message: '别名不能为空' |
|
|
}); |
|
|
} |
|
|
|
|
|
const result = this.apiProxy.authKeyManager.updateKeyAlias(decodeURIComponent(token), alias.trim()); |
|
|
|
|
|
if (result.success) { |
|
|
res.json(result); |
|
|
} else { |
|
|
res.status(400).json(result); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.put('/admin/keys/:token/status', (req, res) => { |
|
|
const { token } = req.params; |
|
|
const { enabled } = req.body; |
|
|
const result = this.apiProxy.authKeyManager.updateKeyStatus(decodeURIComponent(token), enabled); |
|
|
res.json(result); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.delete('/admin/keys/:token', (req, res) => { |
|
|
const { token } = req.params; |
|
|
const result = this.apiProxy.authKeyManager.deleteKey(decodeURIComponent(token)); |
|
|
res.json(result); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/admin/export', (req, res) => { |
|
|
const envString = this.apiProxy.authKeyManager.exportToEnv(); |
|
|
res.json({ envString }); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.delete('/admin/logs/:authKey', (req, res) => { |
|
|
const { authKey } = req.params; |
|
|
this.apiProxy.logManager.clearLogs(authKey); |
|
|
res.json({ success: true, message: '日志已清除' }); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/admin/download-logs', (req, res) => { |
|
|
const format = req.query.format || 'json'; |
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); |
|
|
|
|
|
try { |
|
|
if (format === 'json') { |
|
|
const jsonData = this.apiProxy.logManager.exportLogsAsJson(); |
|
|
res.setHeader('Content-Disposition', `attachment; filename=api-logs-${timestamp}.json`); |
|
|
res.setHeader('Content-Type', 'application/json'); |
|
|
res.send(jsonData); |
|
|
} else if (format === 'csv') { |
|
|
const csvData = this.apiProxy.logManager.exportLogsAsCsv(); |
|
|
res.setHeader('Content-Disposition', `attachment; filename=api-logs-${timestamp}.csv`); |
|
|
res.setHeader('Content-Type', 'text/csv'); |
|
|
res.send(csvData); |
|
|
} else { |
|
|
res.status(400).json({ error: '不支持的格式' }); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('导出日志失败:', error); |
|
|
res.status(500).json({ error: '导出日志失败' }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/admin/file-logging-status', (req, res) => { |
|
|
const enabled = this.apiProxy.logManager.getFileLoggingStatus(); |
|
|
res.json({ enabled }); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.post('/admin/file-logging', (req, res) => { |
|
|
const { enabled } = req.body; |
|
|
this.apiProxy.logManager.setFileLogging(enabled); |
|
|
res.json({ success: true, message: `文件日志已${enabled ? '启用' : '禁用'}` }); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.get('/health', (req, res) => { |
|
|
res.json({ |
|
|
status: 'healthy', |
|
|
timestamp: new Date().toISOString(), |
|
|
availableKeys: this.apiProxy.config.apiKeys.length, |
|
|
usedKeys: this.apiProxy.config.usedApiKeys.length, |
|
|
invalidKeys: this.apiProxy.config.invalidApiKeys.length, |
|
|
authKeys: this.apiProxy.authKeyManager.getAllKeys().length, |
|
|
fileLoggingEnabled: this.apiProxy.logManager.getFileLoggingStatus(), |
|
|
version: '2.3.1', |
|
|
features: ['text', 'vision', 'stream', 'fake_stream', 'load_balancing', 'auto_retry', 'logging', 'multi_auth', 'file_export'] |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.use('*', (req, res) => { |
|
|
res.status(404).json({ |
|
|
error: { |
|
|
message: 'Not Found', |
|
|
type: 'invalid_request_error', |
|
|
code: 'not_found' |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
this.app.use((err, req, res, next) => { |
|
|
console.error('服务器错误:', err); |
|
|
res.status(500).json({ |
|
|
error: { |
|
|
message: '内部服务器错误', |
|
|
type: 'internal_server_error', |
|
|
code: 'server_error' |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
start(port = 3000) { |
|
|
this.app.listen(port, () => { |
|
|
console.log(`🚀 OpenAI to Gemini Proxy Server (Enhanced) 启动在端口 ${port}`); |
|
|
console.log(`📍 聊天API: http://localhost:${port}/v1/chat/completions`); |
|
|
console.log(`📍 假流式聊天API: http://localhost:${port}/fakestream/v1/chat/completions`); |
|
|
console.log(`📋 模型列表: http://localhost:${port}/v1/models`); |
|
|
console.log(`📋 假流式模型列表: http://localhost:${port}/fakestream/v1/models`); |
|
|
console.log(`🔍 健康检查: http://localhost:${port}/health`); |
|
|
console.log(`📊 日志管理: http://localhost:${port}/logs.html`); |
|
|
console.log(`📄 文件日志状态: ${process.env.ENABLE_FILE_LOGGING !== 'false' ? '启用' : '禁用'}`); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const server = new Server(); |
|
|
const port = process.env.PORT || 3000; |
|
|
server.start(port); |
|
|
|