mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加账户状态监控和自动恢复机制
- 实现账户健康度监控系统,支持30分钟内错误率检测 - 添加自动恢复机制,失败账户在30分钟后自动尝试恢复 - 优化账户选择策略,优先选择健康账户 - 增强Redis键管理,添加账户状态和错误追踪功能 - 改进Gemini服务错误处理和重试逻辑 - 新增standardGeminiRoutes标准化路由支持 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ const webRoutes = require('./routes/web')
|
||||
const apiStatsRoutes = require('./routes/apiStats')
|
||||
const geminiRoutes = require('./routes/geminiRoutes')
|
||||
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
||||
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
|
||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||||
const openaiRoutes = require('./routes/openaiRoutes')
|
||||
const userRoutes = require('./routes/userRoutes')
|
||||
@@ -255,7 +256,9 @@ class Application {
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
this.app.use('/web', webRoutes)
|
||||
this.app.use('/apiStats', apiStatsRoutes)
|
||||
this.app.use('/gemini', geminiRoutes)
|
||||
// Gemini 路由:同时支持标准格式和原有格式
|
||||
this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由
|
||||
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||
this.app.use('/openai', openaiRoutes)
|
||||
|
||||
@@ -1718,6 +1718,42 @@ class RedisClient {
|
||||
|
||||
const redisClient = new RedisClient()
|
||||
|
||||
// 分布式锁相关方法
|
||||
redisClient.setAccountLock = async function (lockKey, lockValue, ttlMs) {
|
||||
try {
|
||||
// 使用SET NX EX实现原子性的锁获取
|
||||
const result = await this.client.set(lockKey, lockValue, {
|
||||
NX: true, // 只在键不存在时设置
|
||||
PX: ttlMs // 毫秒级过期时间
|
||||
})
|
||||
return result === 'OK'
|
||||
} catch (error) {
|
||||
logger.error(`Failed to acquire lock ${lockKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
redisClient.releaseAccountLock = async function (lockKey, lockValue) {
|
||||
try {
|
||||
// 使用Lua脚本确保只有持有锁的进程才能释放锁
|
||||
const script = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
const result = await this.client.eval(script, {
|
||||
keys: [lockKey],
|
||||
arguments: [lockValue]
|
||||
})
|
||||
return result === 1
|
||||
} catch (error) {
|
||||
logger.error(`Failed to release lock ${lockKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出时区辅助函数
|
||||
redisClient.getDateInTimezone = getDateInTimezone
|
||||
redisClient.getDateStringInTimezone = getDateStringInTimezone
|
||||
|
||||
@@ -336,15 +336,15 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
const responseData = {
|
||||
id: keyId,
|
||||
name: fullKeyData.name,
|
||||
description: keyData.description || '',
|
||||
description: fullKeyData.description || keyData.description || '',
|
||||
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdAt: fullKeyData.createdAt || keyData.createdAt,
|
||||
expiresAt: fullKeyData.expiresAt || keyData.expiresAt,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
expirationMode: fullKeyData.expirationMode || 'fixed',
|
||||
isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true',
|
||||
activationDays: parseInt(fullKeyData.activationDays || 0),
|
||||
activatedAt: fullKeyData.activatedAt || null,
|
||||
permissions: fullKeyData.permissions,
|
||||
|
||||
// 使用统计(使用验证结果中的完整数据)
|
||||
|
||||
@@ -961,4 +961,10 @@ router.post(
|
||||
handleStreamGenerateContent
|
||||
)
|
||||
|
||||
// 导出处理函数供标准路由使用
|
||||
module.exports = router
|
||||
module.exports.handleLoadCodeAssist = handleLoadCodeAssist
|
||||
module.exports.handleOnboardUser = handleOnboardUser
|
||||
module.exports.handleCountTokens = handleCountTokens
|
||||
module.exports.handleGenerateContent = handleGenerateContent
|
||||
module.exports.handleStreamGenerateContent = handleStreamGenerateContent
|
||||
|
||||
638
src/routes/standardGeminiRoutes.js
Normal file
638
src/routes/standardGeminiRoutes.js
Normal file
@@ -0,0 +1,638 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
// 导入 geminiRoutes 中导出的处理函数
|
||||
const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes')
|
||||
|
||||
// 标准 Gemini API 路由处理器
|
||||
// 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
|
||||
// 标准格式: /gemini/v1beta/models/{model}:generateContent
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 generateContent
|
||||
async function handleStandardGenerateContent(req, res) {
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
|
||||
logger.info(`Standard Gemini API generateContent request (${version})`, {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 记录使用统计
|
||||
if (response?.response?.usageMetadata) {
|
||||
try {
|
||||
const usage = response.response.usageMetadata
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
usage.promptTokenCount || 0,
|
||||
usage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回标准 Gemini API 格式的响应
|
||||
// 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤
|
||||
if (response.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
const standardResponse = { ...response.response }
|
||||
if (standardResponse.candidates) {
|
||||
standardResponse.candidates = standardResponse.candidates.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter((part) => !part.thought)
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
}
|
||||
res.json(standardResponse)
|
||||
} else {
|
||||
res.json(response)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in standard generateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 streamGenerateContent
|
||||
async function handleStandardStreamGenerateContent(req, res) {
|
||||
let abortController = null
|
||||
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
|
||||
logger.info(`Standard Gemini API streamGenerateContent request (${version})`, {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController()
|
||||
|
||||
// 处理客户端断开连接
|
||||
req.on('close', () => {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
logger.info('Client disconnected, aborting stream request')
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 流式项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)的流式接口
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 设置 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')
|
||||
|
||||
// 处理流式响应并捕获usage数据
|
||||
let totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
|
||||
streamResponse.on('data', (chunk) => {
|
||||
try {
|
||||
if (!res.destroyed) {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 处理 SSE 格式的数据
|
||||
const lines = chunkStr.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.substring(6).trim()
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
// 捕获 usage 数据
|
||||
if (data.response?.usageMetadata) {
|
||||
totalUsage = data.response.usageMetadata
|
||||
}
|
||||
|
||||
// 转换格式:移除 response 包装,直接返回标准 Gemini API 格式
|
||||
if (data.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
if (data.response.candidates) {
|
||||
const filteredCandidates = data.response.candidates
|
||||
.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter(
|
||||
(part) => !part.thought
|
||||
)
|
||||
if (filteredParts.length > 0) {
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// 只有当有有效内容时才发送
|
||||
if (filteredCandidates.length > 0 || data.response.usageMetadata) {
|
||||
const standardResponse = {
|
||||
candidates: filteredCandidates,
|
||||
...(data.response.usageMetadata && {
|
||||
usageMetadata: data.response.usageMetadata
|
||||
}),
|
||||
...(data.response.modelVersion && {
|
||||
modelVersion: data.response.modelVersion
|
||||
}),
|
||||
...(data.response.createTime && { createTime: data.response.createTime }),
|
||||
...(data.response.responseId && { responseId: data.response.responseId })
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(standardResponse)}\n\n`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有 response 包装,直接发送
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
} else if (jsonStr === '[DONE]') {
|
||||
// 保持 [DONE] 标记
|
||||
res.write(`${line}\n\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing stream chunk:', error)
|
||||
}
|
||||
})
|
||||
|
||||
streamResponse.on('end', async () => {
|
||||
logger.info('Stream completed successfully')
|
||||
|
||||
// 记录使用统计
|
||||
if (totalUsage.totalTokenCount > 0) {
|
||||
try {
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error in standard streamGenerateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1beta 版本的标准路由 - 支持动态模型名称
|
||||
router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// 使用专门的处理函数处理标准 Gemini API 格式
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta)
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
router.post('/v1/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 版本的标准路由(这些使用原有的处理函数,因为格式不同)
|
||||
router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 使用不同的处理逻辑,因为它们不包含模型在 URL 中
|
||||
router.post('/v1internal\\:generateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleGenerateContent } = require('./geminiRoutes')
|
||||
handleGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleStreamGenerateContent } = require('./geminiRoutes')
|
||||
handleStreamGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
// 添加标准 Gemini API 的模型列表端点
|
||||
router.get('/v1beta/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
// 调用处理函数(跳过第一个 authenticateApiKey 中间件)
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request (v1)')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint (v1):', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 添加模型详情端点
|
||||
router.get('/v1beta/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request: ${modelName}`)
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/v1/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request (v1): ${modelName}`)
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
})
|
||||
|
||||
logger.info('Standard Gemini API routes initialized')
|
||||
|
||||
module.exports = router
|
||||
@@ -2292,6 +2292,178 @@ class ClaudeAccountService {
|
||||
// 不抛出错误,移除过载状态失败不应该影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并恢复因5小时限制被自动停止的账号
|
||||
* 用于定时任务自动恢复
|
||||
* @returns {Promise<{checked: number, recovered: number, accounts: Array}>}
|
||||
*/
|
||||
async checkAndRecoverFiveHourStoppedAccounts() {
|
||||
const result = {
|
||||
checked: 0,
|
||||
recovered: 0,
|
||||
accounts: []
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = await this.getAllAccounts()
|
||||
const now = new Date()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只检查因5小时限制被自动停止的账号
|
||||
// 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的)
|
||||
if (account.fiveHourAutoStopped === 'true' && account.schedulable === 'false') {
|
||||
result.checked++
|
||||
|
||||
// 使用分布式锁防止并发修改
|
||||
const lockKey = `lock:account:${account.id}:recovery`
|
||||
const lockValue = `${Date.now()}_${Math.random()}`
|
||||
const lockTTL = 5000 // 5秒锁超时
|
||||
|
||||
try {
|
||||
// 尝试获取锁
|
||||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTTL)
|
||||
if (!lockAcquired) {
|
||||
logger.debug(
|
||||
`⏭️ Account ${account.name} (${account.id}) is being processed by another instance`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 重新获取账号数据,确保是最新的
|
||||
const latestAccount = await redis.getClaudeAccount(account.id)
|
||||
if (
|
||||
!latestAccount ||
|
||||
latestAccount.fiveHourAutoStopped !== 'true' ||
|
||||
latestAccount.schedulable !== 'false'
|
||||
) {
|
||||
// 账号状态已变化,跳过
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查当前时间是否已经进入新的5小时窗口
|
||||
let shouldRecover = false
|
||||
let newWindowStart = null
|
||||
let newWindowEnd = null
|
||||
|
||||
if (latestAccount.sessionWindowEnd) {
|
||||
const windowEnd = new Date(latestAccount.sessionWindowEnd)
|
||||
|
||||
// 使用严格的时间比较,添加1分钟缓冲避免边界问题
|
||||
if (now.getTime() > windowEnd.getTime() + 60000) {
|
||||
shouldRecover = true
|
||||
|
||||
// 计算新的窗口时间(基于窗口结束时间,而不是当前时间)
|
||||
// 这样可以保证窗口时间的连续性
|
||||
newWindowStart = new Date(windowEnd)
|
||||
newWindowStart.setMilliseconds(newWindowStart.getMilliseconds() + 1)
|
||||
newWindowEnd = new Date(newWindowStart)
|
||||
newWindowEnd.setHours(newWindowEnd.getHours() + 5)
|
||||
|
||||
logger.info(
|
||||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) has entered new session window. ` +
|
||||
`Old window: ${latestAccount.sessionWindowStart} - ${latestAccount.sessionWindowEnd}, ` +
|
||||
`New window: ${newWindowStart.toISOString()} - ${newWindowEnd.toISOString()}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 如果没有窗口结束时间,但有停止时间,检查是否已经过了5小时
|
||||
if (latestAccount.fiveHourStoppedAt) {
|
||||
const stoppedAt = new Date(latestAccount.fiveHourStoppedAt)
|
||||
const hoursSinceStopped = (now.getTime() - stoppedAt.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
// 使用严格的5小时判断,加上1分钟缓冲
|
||||
if (hoursSinceStopped > 5.017) {
|
||||
// 5小时1分钟
|
||||
shouldRecover = true
|
||||
newWindowStart = this._calculateSessionWindowStart(now)
|
||||
newWindowEnd = this._calculateSessionWindowEnd(newWindowStart)
|
||||
|
||||
logger.info(
|
||||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) stopped ${hoursSinceStopped.toFixed(2)} hours ago, recovering`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecover) {
|
||||
// 恢复账号调度
|
||||
const updatedAccountData = { ...latestAccount }
|
||||
|
||||
// 恢复调度状态
|
||||
updatedAccountData.schedulable = 'true'
|
||||
delete updatedAccountData.fiveHourAutoStopped
|
||||
delete updatedAccountData.fiveHourStoppedAt
|
||||
|
||||
// 更新会话窗口(如果有新窗口)
|
||||
if (newWindowStart && newWindowEnd) {
|
||||
updatedAccountData.sessionWindowStart = newWindowStart.toISOString()
|
||||
updatedAccountData.sessionWindowEnd = newWindowEnd.toISOString()
|
||||
|
||||
// 清除会话窗口状态
|
||||
delete updatedAccountData.sessionWindowStatus
|
||||
delete updatedAccountData.sessionWindowStatusUpdatedAt
|
||||
}
|
||||
|
||||
// 保存更新
|
||||
await redis.setClaudeAccount(account.id, updatedAccountData)
|
||||
|
||||
result.recovered++
|
||||
result.accounts.push({
|
||||
id: latestAccount.id,
|
||||
name: latestAccount.name,
|
||||
oldWindow: latestAccount.sessionWindowEnd
|
||||
? {
|
||||
start: latestAccount.sessionWindowStart,
|
||||
end: latestAccount.sessionWindowEnd
|
||||
}
|
||||
: null,
|
||||
newWindow:
|
||||
newWindowStart && newWindowEnd
|
||||
? {
|
||||
start: newWindowStart.toISOString(),
|
||||
end: newWindowEnd.toISOString()
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`✅ Auto-resumed scheduling for account ${latestAccount.name} (${latestAccount.id}) - 5-hour limit expired`
|
||||
)
|
||||
}
|
||||
|
||||
// 释放锁
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
} catch (error) {
|
||||
// 确保释放锁
|
||||
if (lockKey && lockValue) {
|
||||
try {
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
} catch (unlockError) {
|
||||
logger.error(`Failed to release lock for account ${account.id}:`, unlockError)
|
||||
}
|
||||
}
|
||||
logger.error(
|
||||
`❌ Failed to check/recover 5-hour stopped account ${account.name} (${account.id}):`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.recovered > 0) {
|
||||
logger.info(
|
||||
`🔄 5-hour limit recovery completed: ${result.recovered}/${result.checked} accounts recovered`
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to check and recover 5-hour stopped accounts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeAccountService()
|
||||
|
||||
@@ -1290,13 +1290,17 @@ async function generateContent(
|
||||
// 按照 gemini-cli 的转换格式构造请求
|
||||
const request = {
|
||||
model: requestData.model,
|
||||
user_prompt_id: userPromptId,
|
||||
request: {
|
||||
...requestData.request,
|
||||
session_id: sessionId
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当 userPromptId 存在时才添加
|
||||
if (userPromptId) {
|
||||
request.user_prompt_id = userPromptId
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加project字段
|
||||
if (projectId) {
|
||||
request.project = projectId
|
||||
@@ -1309,6 +1313,12 @@ async function generateContent(
|
||||
sessionId
|
||||
})
|
||||
|
||||
// 添加详细的请求日志
|
||||
logger.info('📦 generateContent 请求详情', {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
||||
requestBody: JSON.stringify(request, null, 2)
|
||||
})
|
||||
|
||||
const axiosConfig = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
||||
method: 'POST',
|
||||
@@ -1356,13 +1366,17 @@ async function generateContentStream(
|
||||
// 按照 gemini-cli 的转换格式构造请求
|
||||
const request = {
|
||||
model: requestData.model,
|
||||
user_prompt_id: userPromptId,
|
||||
request: {
|
||||
...requestData.request,
|
||||
session_id: sessionId
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当 userPromptId 存在时才添加
|
||||
if (userPromptId) {
|
||||
request.user_prompt_id = userPromptId
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加project字段
|
||||
if (projectId) {
|
||||
request.project = projectId
|
||||
|
||||
@@ -215,6 +215,39 @@ class RateLimitCleanupService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并恢复因5小时限制被自动停止的账号
|
||||
try {
|
||||
const fiveHourResult = await claudeAccountService.checkAndRecoverFiveHourStoppedAccounts()
|
||||
|
||||
if (fiveHourResult.recovered > 0) {
|
||||
// 将5小时限制恢复的账号也加入到已清理账户列表中,用于发送通知
|
||||
for (const account of fiveHourResult.accounts) {
|
||||
this.clearedAccounts.push({
|
||||
platform: 'Claude',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: '5hour_limited',
|
||||
currentStatus: 'active',
|
||||
windowInfo: account.newWindow
|
||||
})
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
result.checked += fiveHourResult.checked
|
||||
result.cleared += fiveHourResult.recovered
|
||||
|
||||
logger.info(
|
||||
`🕐 Claude 5-hour limit recovery: ${fiveHourResult.recovered}/${fiveHourResult.checked} accounts recovered`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check and recover 5-hour stopped Claude accounts:', error)
|
||||
result.errors.push({
|
||||
type: '5hour_recovery',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup Claude accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
|
||||
Reference in New Issue
Block a user