diff --git a/VERSION b/VERSION
index 3e8f33bb..5b7cfb35 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.169
+1.1.172
diff --git a/src/app.js b/src/app.js
index 13d4d331..42e8ce26 100644
--- a/src/app.js
+++ b/src/app.js
@@ -556,6 +556,62 @@ class Application {
logger.info(
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
)
+
+ // 🔢 启动并发计数自动清理任务(Phase 1 修复:解决并发泄漏问题)
+ // 每分钟主动清理所有过期的并发项,不依赖请求触发
+ setInterval(async () => {
+ try {
+ const keys = await redis.keys('concurrency:*')
+ if (keys.length === 0) {
+ return
+ }
+
+ const now = Date.now()
+ let totalCleaned = 0
+
+ // 使用 Lua 脚本批量清理所有过期项
+ for (const key of keys) {
+ try {
+ const cleaned = await redis.client.eval(
+ `
+ local key = KEYS[1]
+ local now = tonumber(ARGV[1])
+
+ -- 清理过期项
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
+
+ -- 获取剩余计数
+ local count = redis.call('ZCARD', key)
+
+ -- 如果计数为0,删除键
+ if count <= 0 then
+ redis.call('DEL', key)
+ return 1
+ end
+
+ return 0
+ `,
+ 1,
+ key,
+ now
+ )
+ if (cleaned === 1) {
+ totalCleaned++
+ }
+ } catch (error) {
+ logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
+ }
+ }
+
+ if (totalCleaned > 0) {
+ logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
+ }
+ } catch (error) {
+ logger.error('❌ Concurrency cleanup task failed:', error)
+ }
+ }, 60000) // 每分钟执行一次
+
+ logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
}
setupGracefulShutdown() {
@@ -583,6 +639,21 @@ class Application {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
+ // 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
+ try {
+ logger.info('🔢 Cleaning up all concurrency counters...')
+ const keys = await redis.keys('concurrency:*')
+ if (keys.length > 0) {
+ await redis.client.del(...keys)
+ logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
+ } else {
+ logger.info('✅ No concurrency keys to clean')
+ }
+ } catch (error) {
+ logger.error('❌ Error cleaning up concurrency counters:', error)
+ // 不阻止退出流程
+ }
+
try {
await redis.disconnect()
logger.info('👋 Redis disconnected')
diff --git a/src/routes/admin.js b/src/routes/admin.js
index b0aad407..bf61bd26 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -2266,7 +2266,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
autoStopOnWarning,
useUnifiedUserAgent,
useUnifiedClientId,
- unifiedClientId
+ unifiedClientId,
+ expiresAt
} = req.body
if (!name) {
@@ -2309,7 +2310,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
autoStopOnWarning: autoStopOnWarning === true, // 默认为false
useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
- unifiedClientId: unifiedClientId || '' // 统一的客户端标识
+ unifiedClientId: unifiedClientId || '', // 统一的客户端标识
+ expiresAt: expiresAt || null // 账户订阅到期时间
})
// 如果是分组类型,将账户添加到分组
@@ -2396,7 +2398,14 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
}
}
- await claudeAccountService.updateAccount(accountId, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ await claudeAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated Claude account: ${accountId}`)
return res.json({ success: true, message: 'Claude account updated successfully' })
@@ -2786,7 +2795,14 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
}
}
- await claudeConsoleAccountService.updateAccount(accountId, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated Claude Console account: ${accountId}`)
return res.json({ success: true, message: 'Claude Console account updated successfully' })
@@ -3196,7 +3212,14 @@ router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => {
}
}
- await ccrAccountService.updateAccount(accountId, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ await ccrAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated CCR account: ${accountId}`)
return res.json({ success: true, message: 'CCR account updated successfully' })
@@ -3557,7 +3580,14 @@ router.put('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) =
})
}
- const result = await bedrockAccountService.updateAccount(accountId, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates)
if (!result.success) {
return res
@@ -3882,6 +3912,8 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
return {
...account,
+ // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
+ expiresAt: account.subscriptionExpiresAt || null,
groupInfos,
usage: {
daily: usageStats.daily,
@@ -3899,6 +3931,8 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
const groupInfos = await accountGroupService.getAccountGroups(account.id)
return {
...account,
+ // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
+ expiresAt: account.subscriptionExpiresAt || null,
groupInfos,
usage: {
daily: { tokens: 0, requests: 0, allTokens: 0 },
@@ -4023,7 +4057,14 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) =>
}
}
- const updatedAccount = await geminiAccountService.updateAccount(accountId, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates)
logger.success(`📝 Admin updated Gemini account: ${accountId}`)
return res.json({ success: true, data: updatedAccount })
@@ -7530,6 +7571,13 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
: currentAccount.emailVerified
}
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt (订阅过期时间)
+ // 注意:这里不影响上面 OAuth token 的 expiresAt 字段
+ if ('expiresAt' in updates && !updates.openaiOauth?.expires_in) {
+ updateData.subscriptionExpiresAt = updates.expiresAt
+ delete updateData.expiresAt
+ }
+
const updatedAccount = await openaiAccountService.updateAccount(id, updateData)
// 如果需要刷新但不强制成功(非关键更新)
@@ -7916,7 +7964,14 @@ router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) =>
const { id } = req.params
const updates = req.body
- const account = await azureOpenaiAccountService.updateAccount(id, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates)
res.json({
success: true,
@@ -8289,7 +8344,14 @@ router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res)
updates.priority = priority.toString()
}
- const result = await openaiResponsesAccountService.updateAccount(id, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates)
if (!result.success) {
return res.status(400).json(result)
@@ -8655,6 +8717,9 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
return {
...account,
+ // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
+ // OAuth token 的原始 expiresAt 保留在内部使用
+ expiresAt: account.subscriptionExpiresAt || null,
schedulable: account.schedulable === 'true',
boundApiKeysCount,
groupInfos,
@@ -8668,6 +8733,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
return {
...account,
+ // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
+ expiresAt: account.subscriptionExpiresAt || null,
boundApiKeysCount: 0,
groupInfos: [],
usage: {
@@ -8782,7 +8849,14 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
updates.accountType = targetAccountType
}
- const account = await droidAccountService.updateAccount(id, updates)
+ // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt
+ const mappedUpdates = { ...updates }
+ if ('expiresAt' in mappedUpdates) {
+ mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
+ delete mappedUpdates.expiresAt
+ }
+
+ const account = await droidAccountService.updateAccount(id, mappedUpdates)
try {
if (currentAccount.accountType === 'group' && targetAccountType !== 'group') {
diff --git a/src/routes/api.js b/src/routes/api.js
index 7d700c9d..f784cae6 100644
--- a/src/routes/api.js
+++ b/src/routes/api.js
@@ -10,30 +10,10 @@ const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger')
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
const sessionHelper = require('../utils/sessionHelper')
-const openaiToClaude = require('../services/openaiToClaude')
-const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
+const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const router = express.Router()
-// 🔍 检测模型对应的后端服务
-function detectBackendFromModel(modelName) {
- if (!modelName) {
- return 'claude'
- }
- if (modelName.startsWith('claude-')) {
- return 'claude'
- }
- if (modelName.startsWith('gpt-')) {
- return 'openai'
- }
- if (modelName.startsWith('gemini-')) {
- return 'gemini'
- }
- return 'claude' // 默认使用 Claude
-}
-
-const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
-
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
if (!rateLimitInfo) {
return Promise.resolve({ totalTokens: 0, totalCost: 0 })
@@ -742,20 +722,25 @@ router.post('/v1/messages', authenticateApiKey, handleMessagesRequest)
// 🚀 Claude API messages 端点 - /claude/v1/messages (别名)
router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
-// 📋 模型列表端点 - OpenAI 兼容,返回所有支持的模型
+// 📋 模型列表端点 - Claude Code 客户端需要
router.get('/v1/models', authenticateApiKey, async (req, res) => {
try {
- // 返回支持的模型列表(Claude + OpenAI + Gemini)
+ // 返回支持的模型列表
const models = [
- // Claude 模型
{
- id: 'claude-sonnet-4-5-20250929',
+ id: 'claude-3-5-sonnet-20241022',
object: 'model',
created: 1669599635,
owned_by: 'anthropic'
},
{
- id: 'claude-opus-4-1-20250805',
+ id: 'claude-3-5-haiku-20241022',
+ object: 'model',
+ created: 1669599635,
+ owned_by: 'anthropic'
+ },
+ {
+ id: 'claude-3-opus-20240229',
object: 'model',
created: 1669599635,
owned_by: 'anthropic'
@@ -765,92 +750,6 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
object: 'model',
created: 1669599635,
owned_by: 'anthropic'
- },
- {
- id: 'claude-opus-4-20250514',
- object: 'model',
- created: 1669599635,
- owned_by: 'anthropic'
- },
- {
- id: 'claude-3-7-sonnet-20250219',
- object: 'model',
- created: 1669599635,
- owned_by: 'anthropic'
- },
- {
- id: 'claude-3-5-sonnet-20241022',
- object: 'model',
- created: 1729036800,
- owned_by: 'anthropic'
- },
- {
- id: 'claude-3-5-haiku-20241022',
- object: 'model',
- created: 1729036800,
- owned_by: 'anthropic'
- },
- {
- id: 'claude-3-haiku-20240307',
- object: 'model',
- created: 1709251200,
- owned_by: 'anthropic'
- },
- {
- id: 'claude-3-opus-20240229',
- object: 'model',
- created: 1736726400,
- owned_by: 'anthropic'
- },
- // OpenAI 模型
- {
- id: 'gpt-4o',
- object: 'model',
- created: 1715367600,
- owned_by: 'openai'
- },
- {
- id: 'gpt-4o-mini',
- object: 'model',
- created: 1721088000,
- owned_by: 'openai'
- },
- {
- id: 'gpt-4-turbo',
- object: 'model',
- created: 1712102400,
- owned_by: 'openai'
- },
- {
- id: 'gpt-4',
- object: 'model',
- created: 1687132800,
- owned_by: 'openai'
- },
- {
- id: 'gpt-3.5-turbo',
- object: 'model',
- created: 1677649200,
- owned_by: 'openai'
- },
- // Gemini 模型
- {
- id: 'gemini-1.5-pro',
- object: 'model',
- created: 1707868800,
- owned_by: 'google'
- },
- {
- id: 'gemini-1.5-flash',
- object: 'model',
- created: 1715990400,
- owned_by: 'google'
- },
- {
- id: 'gemini-2.0-flash-exp',
- object: 'model',
- created: 1733011200,
- owned_by: 'google'
}
]
@@ -1083,385 +982,5 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
}
})
-// 🔍 验证 OpenAI chat/completions 请求参数
-function validateChatCompletionRequest(body) {
- if (!body || !body.messages || !Array.isArray(body.messages)) {
- return {
- valid: false,
- error: {
- message: 'Missing or invalid field: messages (must be an array)',
- type: 'invalid_request_error',
- code: 'invalid_request'
- }
- }
- }
-
- if (body.messages.length === 0) {
- return {
- valid: false,
- error: {
- message: 'Messages array cannot be empty',
- type: 'invalid_request_error',
- code: 'invalid_request'
- }
- }
- }
-
- return { valid: true }
-}
-
-// 🌊 处理 Claude 流式请求
-async function handleClaudeStreamRequest(
- claudeRequest,
- apiKeyData,
- req,
- res,
- accountId,
- requestedModel
-) {
- logger.info(`🌊 Processing OpenAI stream request for model: ${requestedModel}`)
-
- // 设置 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')
-
- // 创建中止控制器
- const abortController = new AbortController()
-
- // 处理客户端断开
- req.on('close', () => {
- if (abortController && !abortController.signal.aborted) {
- logger.info('🔌 Client disconnected, aborting Claude request')
- abortController.abort()
- }
- })
-
- // 获取该账号存储的 Claude Code headers
- const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
-
- // 使用转换后的响应流
- await claudeRelayService.relayStreamRequestWithUsageCapture(
- claudeRequest,
- apiKeyData,
- res,
- claudeCodeHeaders,
- (usage) => {
- // 记录使用统计
- if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
- const model = usage.model || claudeRequest.model
-
- apiKeyService
- .recordUsageWithDetails(apiKeyData.id, usage, model, accountId)
- .catch((error) => {
- logger.error('❌ Failed to record usage:', error)
- })
- }
- },
- // 流转换器:将 Claude SSE 转换为 OpenAI SSE
- (() => {
- const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
- return (chunk) => openaiToClaude.convertStreamChunk(chunk, requestedModel, sessionId)
- })(),
- {
- betaHeader:
- 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
- }
- )
-
- return { abortController }
-}
-
-// 📄 处理 Claude 非流式请求
-async function handleClaudeNonStreamRequest(
- claudeRequest,
- apiKeyData,
- req,
- res,
- accountId,
- requestedModel
-) {
- logger.info(`📄 Processing OpenAI non-stream request for model: ${requestedModel}`)
-
- // 获取该账号存储的 Claude Code headers
- const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
-
- // 发送请求到 Claude
- const claudeResponse = await claudeRelayService.relayRequest(
- claudeRequest,
- apiKeyData,
- req,
- res,
- claudeCodeHeaders,
- { betaHeader: 'oauth-2025-04-20' }
- )
-
- // 解析 Claude 响应
- let claudeData
- try {
- claudeData = JSON.parse(claudeResponse.body)
- } catch (error) {
- logger.error('❌ Failed to parse Claude response:', error)
- return {
- error: {
- status: 502,
- data: {
- error: {
- message: 'Invalid response from Claude API',
- type: 'api_error',
- code: 'invalid_response'
- }
- }
- }
- }
- }
-
- // 处理错误响应
- if (claudeResponse.statusCode >= 400) {
- return {
- error: {
- status: claudeResponse.statusCode,
- data: {
- error: {
- message: claudeData.error?.message || 'Claude API error',
- type: claudeData.error?.type || 'api_error',
- code: claudeData.error?.code || 'unknown_error'
- }
- }
- }
- }
- }
-
- // 转换为 OpenAI 格式
- const openaiResponse = openaiToClaude.convertResponse(claudeData, requestedModel)
-
- // 记录使用统计
- if (claudeData.usage) {
- const { usage } = claudeData
- apiKeyService
- .recordUsageWithDetails(apiKeyData.id, usage, claudeRequest.model, accountId)
- .catch((error) => {
- logger.error('❌ Failed to record usage:', error)
- })
- }
-
- return { success: true, data: openaiResponse }
-}
-
-// 🤖 处理 Claude 后端
-async function handleClaudeBackend(req, res, apiKeyData, requestedModel) {
- // 转换 OpenAI 请求为 Claude 格式
- const claudeRequest = openaiToClaude.convertRequest(req.body)
-
- // 检查模型限制
- if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
- if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
- return res.status(403).json({
- error: {
- message: `Model ${requestedModel} is not allowed for this API key`,
- type: 'invalid_request_error',
- code: 'model_not_allowed'
- }
- })
- }
- }
-
- // 生成会话哈希用于 sticky 会话
- const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
-
- // 选择可用的 Claude 账户
- const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
- apiKeyData,
- sessionHash,
- claudeRequest.model
- )
- const { accountId } = accountSelection
-
- // 处理流式或非流式请求
- if (claudeRequest.stream) {
- const { abortController } = await handleClaudeStreamRequest(
- claudeRequest,
- apiKeyData,
- req,
- res,
- accountId,
- requestedModel
- )
- return { abortController }
- } else {
- const result = await handleClaudeNonStreamRequest(
- claudeRequest,
- apiKeyData,
- req,
- res,
- accountId,
- requestedModel
- )
-
- if (result.error) {
- return res.status(result.error.status).json(result.error.data)
- }
-
- return res.json(result.data)
- }
-}
-
-// 🔧 处理 OpenAI 后端(未实现)
-async function handleOpenAIBackend(req, res, _apiKeyData, _requestedModel) {
- return res.status(501).json({
- error: {
- message: 'OpenAI backend not yet implemented for this endpoint',
- type: 'not_implemented',
- code: 'not_implemented'
- }
- })
-}
-
-// 💎 处理 Gemini 后端(未实现)
-async function handleGeminiBackend(req, res, _apiKeyData, _requestedModel) {
- return res.status(501).json({
- error: {
- message: 'Gemini backend not yet implemented for this endpoint',
- type: 'not_implemented',
- code: 'not_implemented'
- }
- })
-}
-
-// 🗺️ 后端处理策略映射
-const backendHandlers = {
- claude: handleClaudeBackend,
- openai: handleOpenAIBackend,
- gemini: handleGeminiBackend
-}
-
-// 🚀 OpenAI 兼容的 chat/completions 处理器(智能路由)
-async function handleChatCompletions(req, res) {
- const startTime = Date.now()
- let abortController = null
-
- try {
- const apiKeyData = req.apiKey
-
- // 验证必需参数
- const validation = validateChatCompletionRequest(req.body)
- if (!validation.valid) {
- return res.status(400).json({ error: validation.error })
- }
-
- // 检测模型对应的后端
- const requestedModel = req.body.model || 'claude-3-5-sonnet-20241022'
- const backend = detectBackendFromModel(requestedModel)
-
- logger.debug(
- `📥 Received OpenAI format request for model: ${requestedModel}, backend: ${backend}`
- )
-
- // 使用策略模式处理不同后端
- const handler = backendHandlers[backend]
- if (!handler) {
- return res.status(500).json({
- error: {
- message: `Unsupported backend: ${backend}`,
- type: 'server_error',
- code: 'unsupported_backend'
- }
- })
- }
-
- // 调用对应的后端处理器
- const result = await handler(req, res, apiKeyData, requestedModel)
-
- // 保存 abort controller(用于清理)
- if (result && result.abortController) {
- ;({ abortController } = result)
- }
-
- const duration = Date.now() - startTime
- logger.info(`✅ OpenAI chat/completions request completed in ${duration}ms`)
- return undefined
- } catch (error) {
- logger.error('❌ OpenAI chat/completions error:', error)
-
- const status = error.status || 500
- if (!res.headersSent) {
- res.status(status).json({
- error: {
- message: error.message || 'Internal server error',
- type: 'server_error',
- code: 'internal_error'
- }
- })
- }
- return undefined
- } finally {
- // 清理资源
- if (abortController) {
- abortController = null
- }
- }
-}
-
-// 🔧 OpenAI 兼容的 completions 处理器(传统格式,转换为 chat 格式)
-async function handleCompletions(req, res) {
- try {
- // 验证必需参数
- if (!req.body.prompt) {
- return res.status(400).json({
- error: {
- message: 'Prompt is required',
- type: 'invalid_request_error',
- code: 'invalid_request'
- }
- })
- }
-
- // 将传统 completions 格式转换为 chat 格式
- const chatRequest = {
- model: req.body.model || 'claude-3-5-sonnet-20241022',
- messages: [
- {
- role: 'user',
- content: req.body.prompt
- }
- ],
- max_tokens: req.body.max_tokens,
- temperature: req.body.temperature,
- top_p: req.body.top_p,
- stream: req.body.stream,
- stop: req.body.stop,
- n: req.body.n || 1,
- presence_penalty: req.body.presence_penalty,
- frequency_penalty: req.body.frequency_penalty,
- logit_bias: req.body.logit_bias,
- user: req.body.user
- }
-
- // 使用 chat/completions 处理器
- req.body = chatRequest
- await handleChatCompletions(req, res)
- return undefined
- } catch (error) {
- logger.error('❌ OpenAI completions error:', error)
- if (!res.headersSent) {
- res.status(500).json({
- error: {
- message: 'Failed to process completion request',
- type: 'server_error',
- code: 'internal_error'
- }
- })
- }
- return undefined
- }
-}
-
-// 📋 OpenAI 兼容的 chat/completions 端点
-router.post('/v1/chat/completions', authenticateApiKey, handleChatCompletions)
-
-// 🔧 OpenAI 兼容的 completions 端点(传统格式)
-router.post('/v1/completions', authenticateApiKey, handleCompletions)
-
module.exports = router
module.exports.handleMessagesRequest = handleMessagesRequest
diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js
index f931345e..365275bf 100644
--- a/src/services/claudeAccountService.js
+++ b/src/services/claudeAccountService.js
@@ -73,7 +73,8 @@ class ClaudeAccountService {
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
useUnifiedClientId = false, // 是否使用统一的客户端标识
- unifiedClientId = '' // 统一的客户端标识
+ unifiedClientId = '', // 统一的客户端标识
+ expiresAt = null // 账户订阅到期时间
} = options
const accountId = uuidv4()
@@ -113,7 +114,9 @@ class ClaudeAccountService {
? JSON.stringify(subscriptionInfo)
: claudeAiOauth.subscriptionInfo
? JSON.stringify(claudeAiOauth.subscriptionInfo)
- : ''
+ : '',
+ // 账户订阅到期时间
+ subscriptionExpiresAt: expiresAt || ''
}
} else {
// 兼容旧格式
@@ -141,7 +144,9 @@ class ClaudeAccountService {
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
// 手动设置的订阅信息
- subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : ''
+ subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '',
+ // 账户订阅到期时间
+ subscriptionExpiresAt: expiresAt || ''
}
}
@@ -486,7 +491,7 @@ class ClaudeAccountService {
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
- expiresAt: account.expiresAt,
+ expiresAt: account.subscriptionExpiresAt || null, // 账户订阅到期时间
// 添加 scopes 字段用于判断认证方式
// 处理空字符串的情况,避免返回 ['']
scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [],
@@ -528,10 +533,7 @@ class ClaudeAccountService {
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
// 添加停止原因
- stoppedReason: account.stoppedReason || null,
- // 添加 Opus 限流信息
- opusRateLimitedAt: account.opusRateLimitedAt || null,
- opusRateLimitEndAt: account.opusRateLimitEndAt || null
+ stoppedReason: account.stoppedReason || null
}
})
)
@@ -621,7 +623,8 @@ class ClaudeAccountService {
'autoStopOnWarning',
'useUnifiedUserAgent',
'useUnifiedClientId',
- 'unifiedClientId'
+ 'unifiedClientId',
+ 'subscriptionExpiresAt'
]
const updatedData = { ...accountData }
let shouldClearAutoStopFields = false
@@ -640,6 +643,9 @@ class ClaudeAccountService {
} else if (field === 'subscriptionInfo') {
// 处理订阅信息更新
updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value)
+ } else if (field === 'subscriptionExpiresAt') {
+ // 处理订阅到期时间,允许 null 值(永不过期)
+ updatedData[field] = value ? value.toString() : ''
} else if (field === 'claudeAiOauth') {
// 更新 Claude AI OAuth 数据
if (value) {
@@ -653,7 +659,7 @@ class ClaudeAccountService {
updatedData.lastRefreshAt = new Date().toISOString()
}
} else {
- updatedData[field] = value.toString()
+ updatedData[field] = value !== null && value !== undefined ? value.toString() : ''
}
}
}
@@ -772,6 +778,29 @@ class ClaudeAccountService {
}
}
+ /**
+ * 检查账户是否未过期
+ * @param {Object} account - 账户对象
+ * @returns {boolean} - 如果未设置过期时间或未过期返回 true
+ */
+ isAccountNotExpired(account) {
+ if (!account.subscriptionExpiresAt) {
+ return true // 未设置过期时间,视为永不过期
+ }
+
+ const expiryDate = new Date(account.subscriptionExpiresAt)
+ const now = new Date()
+
+ if (expiryDate <= now) {
+ logger.debug(
+ `⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}`
+ )
+ return false
+ }
+
+ return true
+ }
+
// 🎯 智能选择可用账户(支持sticky会话和模型过滤)
async selectAvailableAccount(sessionHash = null, modelName = null) {
try {
@@ -781,7 +810,8 @@ class ClaudeAccountService {
(account) =>
account.isActive === 'true' &&
account.status !== 'error' &&
- account.schedulable !== 'false'
+ account.schedulable !== 'false' &&
+ this.isAccountNotExpired(account)
)
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
@@ -876,7 +906,8 @@ class ClaudeAccountService {
boundAccount &&
boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' &&
- boundAccount.schedulable !== 'false'
+ boundAccount.schedulable !== 'false' &&
+ this.isAccountNotExpired(boundAccount)
) {
logger.info(
`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
@@ -897,7 +928,8 @@ class ClaudeAccountService {
account.isActive === 'true' &&
account.status !== 'error' &&
account.schedulable !== 'false' &&
- (account.accountType === 'shared' || !account.accountType) // 兼容旧数据
+ (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
+ this.isAccountNotExpired(account)
)
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js
index 6b97c881..9d1279bf 100644
--- a/src/services/claudeConsoleRelayService.js
+++ b/src/services/claudeConsoleRelayService.js
@@ -453,7 +453,9 @@ class ClaudeConsoleRelayService {
let buffer = ''
let finalUsageReported = false
- const collectedUsageData = {}
+ const collectedUsageData = {
+ model: body.model || account?.defaultModel || null
+ }
// 处理流数据
response.data.on('data', (chunk) => {
@@ -517,14 +519,58 @@ class ClaudeConsoleRelayService {
}
}
- if (
- data.type === 'message_delta' &&
- data.usage &&
- data.usage.output_tokens !== undefined
- ) {
- collectedUsageData.output_tokens = data.usage.output_tokens || 0
+ if (data.type === 'message_delta' && data.usage) {
+ // 提取所有usage字段,message_delta可能包含完整的usage信息
+ if (data.usage.output_tokens !== undefined) {
+ collectedUsageData.output_tokens = data.usage.output_tokens || 0
+ }
- if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) {
+ // 提取input_tokens(如果存在)
+ if (data.usage.input_tokens !== undefined) {
+ collectedUsageData.input_tokens = data.usage.input_tokens || 0
+ }
+
+ // 提取cache相关的tokens
+ if (data.usage.cache_creation_input_tokens !== undefined) {
+ collectedUsageData.cache_creation_input_tokens =
+ data.usage.cache_creation_input_tokens || 0
+ }
+ if (data.usage.cache_read_input_tokens !== undefined) {
+ collectedUsageData.cache_read_input_tokens =
+ data.usage.cache_read_input_tokens || 0
+ }
+
+ // 检查是否有详细的 cache_creation 对象
+ if (
+ data.usage.cache_creation &&
+ typeof data.usage.cache_creation === 'object'
+ ) {
+ collectedUsageData.cache_creation = {
+ ephemeral_5m_input_tokens:
+ data.usage.cache_creation.ephemeral_5m_input_tokens || 0,
+ ephemeral_1h_input_tokens:
+ data.usage.cache_creation.ephemeral_1h_input_tokens || 0
+ }
+ }
+
+ logger.info(
+ '📊 [Console] Collected usage data from message_delta:',
+ JSON.stringify(collectedUsageData)
+ )
+
+ // 如果已经收集到了完整数据,触发回调
+ if (
+ collectedUsageData.input_tokens !== undefined &&
+ collectedUsageData.output_tokens !== undefined &&
+ !finalUsageReported
+ ) {
+ if (!collectedUsageData.model) {
+ collectedUsageData.model = body.model || account?.defaultModel || null
+ }
+ logger.info(
+ '🎯 [Console] Complete usage data collected:',
+ JSON.stringify(collectedUsageData)
+ )
usageCallback({ ...collectedUsageData, accountId })
finalUsageReported = true
}
@@ -569,6 +615,41 @@ class ClaudeConsoleRelayService {
}
}
+ // 🔧 兜底逻辑:确保所有未保存的usage数据都不会丢失
+ if (!finalUsageReported) {
+ if (
+ collectedUsageData.input_tokens !== undefined ||
+ collectedUsageData.output_tokens !== undefined
+ ) {
+ // 补全缺失的字段
+ if (collectedUsageData.input_tokens === undefined) {
+ collectedUsageData.input_tokens = 0
+ logger.warn(
+ '⚠️ [Console] message_delta missing input_tokens, setting to 0. This may indicate incomplete usage data.'
+ )
+ }
+ if (collectedUsageData.output_tokens === undefined) {
+ collectedUsageData.output_tokens = 0
+ logger.warn(
+ '⚠️ [Console] message_delta missing output_tokens, setting to 0. This may indicate incomplete usage data.'
+ )
+ }
+ // 确保有 model 字段
+ if (!collectedUsageData.model) {
+ collectedUsageData.model = body.model || account?.defaultModel || null
+ }
+ logger.info(
+ `📊 [Console] Saving incomplete usage data via fallback: ${JSON.stringify(collectedUsageData)}`
+ )
+ usageCallback({ ...collectedUsageData, accountId })
+ finalUsageReported = true
+ } else {
+ logger.warn(
+ '⚠️ [Console] Stream completed but no usage data was captured! This indicates a problem with SSE parsing or API response format.'
+ )
+ }
+ }
+
// 确保流正确结束
if (!responseStream.destroyed) {
responseStream.end()
diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js
index 604aa10f..1072b869 100644
--- a/src/services/droidRelayService.js
+++ b/src/services/droidRelayService.js
@@ -10,16 +10,6 @@ const logger = require('../utils/logger')
const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.'
-const MODEL_REASONING_CONFIG = {
- 'claude-opus-4-1-20250805': 'off',
- 'claude-sonnet-4-20250514': 'medium',
- 'claude-sonnet-4-5-20250929': 'high',
- 'gpt-5-2025-08-07': 'high',
- 'gpt-5-codex': 'off'
-}
-
-const VALID_REASONING_LEVELS = new Set(['low', 'medium', 'high'])
-
/**
* Droid API 转发服务
*/
@@ -35,16 +25,7 @@ class DroidRelayService {
this.userAgent = 'factory-cli/0.19.4'
this.systemPrompt = SYSTEM_PROMPT
- this.modelReasoningMap = new Map()
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
-
- Object.entries(MODEL_REASONING_CONFIG).forEach(([modelId, level]) => {
- if (!modelId) {
- return
- }
- const normalized = typeof level === 'string' ? level.toLowerCase() : ''
- this.modelReasoningMap.set(modelId, normalized)
- })
}
_normalizeEndpointType(endpointType) {
@@ -82,7 +63,6 @@ class DroidRelayService {
logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`)
}
normalizedBody.model = mappedModel
- normalizedBody.__forceDisableThinking = true
}
}
@@ -901,9 +881,7 @@ class DroidRelayService {
headers['x-api-key'] = 'placeholder'
headers['x-api-provider'] = 'anthropic'
- // 处理 anthropic-beta 头
- const reasoningLevel = this._getReasoningLevel(requestBody)
- if (reasoningLevel) {
+ if (this._isThinkingRequested(requestBody)) {
headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14'
}
}
@@ -940,6 +918,36 @@ class DroidRelayService {
return false
}
+ /**
+ * 判断请求是否启用 Anthropic 推理模式
+ */
+ _isThinkingRequested(requestBody) {
+ const thinking = requestBody && typeof requestBody === 'object' ? requestBody.thinking : null
+ if (!thinking) {
+ return false
+ }
+
+ if (thinking === true) {
+ return true
+ }
+
+ if (typeof thinking === 'string') {
+ return thinking.trim().toLowerCase() === 'enabled'
+ }
+
+ if (typeof thinking === 'object') {
+ if (thinking.enabled === true) {
+ return true
+ }
+
+ if (typeof thinking.type === 'string') {
+ return thinking.type.trim().toLowerCase() === 'enabled'
+ }
+ }
+
+ return false
+ }
+
/**
* 处理请求体(注入 system prompt 等)
*/
@@ -950,17 +958,6 @@ class DroidRelayService {
const hasStreamField =
requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream')
- const shouldDisableThinking =
- endpointType === 'anthropic' && processedBody.__forceDisableThinking === true
-
- if ('__forceDisableThinking' in processedBody) {
- delete processedBody.__forceDisableThinking
- }
-
- if (requestBody && '__forceDisableThinking' in requestBody) {
- delete requestBody.__forceDisableThinking
- }
-
if (processedBody && Object.prototype.hasOwnProperty.call(processedBody, 'metadata')) {
delete processedBody.metadata
}
@@ -975,7 +972,7 @@ class DroidRelayService {
processedBody.stream = true
}
- // Anthropic 端点:处理 thinking 字段
+ // Anthropic 端点:仅注入系统提示
if (endpointType === 'anthropic') {
if (this.systemPrompt) {
const promptBlock = { type: 'text', text: this.systemPrompt }
@@ -990,30 +987,9 @@ class DroidRelayService {
processedBody.system = [promptBlock]
}
}
-
- const reasoningLevel = shouldDisableThinking ? null : this._getReasoningLevel(requestBody)
- if (reasoningLevel) {
- const budgetTokens = {
- low: 4096,
- medium: 12288,
- high: 24576
- }
- processedBody.thinking = {
- type: 'enabled',
- budget_tokens: budgetTokens[reasoningLevel]
- }
- } else {
- delete processedBody.thinking
- }
-
- if (shouldDisableThinking) {
- if ('thinking' in processedBody) {
- delete processedBody.thinking
- }
- }
}
- // OpenAI 端点:处理 reasoning 字段
+ // OpenAI 端点:仅前置系统提示
if (endpointType === 'openai') {
if (this.systemPrompt) {
if (processedBody.instructions) {
@@ -1024,41 +1000,21 @@ class DroidRelayService {
processedBody.instructions = this.systemPrompt
}
}
+ }
- const reasoningLevel = this._getReasoningLevel(requestBody)
- if (reasoningLevel) {
- processedBody.reasoning = {
- effort: reasoningLevel,
- summary: 'auto'
- }
- } else {
- delete processedBody.reasoning
- }
+ // 处理 temperature 和 top_p 参数
+ const hasValidTemperature =
+ processedBody.temperature !== undefined && processedBody.temperature !== null
+ const hasValidTopP = processedBody.top_p !== undefined && processedBody.top_p !== null
+
+ if (hasValidTemperature && hasValidTopP) {
+ // 仅允许 temperature 或 top_p 其一,同时优先保留 temperature
+ delete processedBody.top_p
}
return processedBody
}
- /**
- * 获取推理级别(如果在 requestBody 中配置)
- */
- _getReasoningLevel(requestBody) {
- if (!requestBody || !requestBody.model) {
- return null
- }
-
- const configured = this.modelReasoningMap.get(requestBody.model)
- if (!configured) {
- return null
- }
-
- if (!VALID_REASONING_LEVELS.has(configured)) {
- return null
- }
-
- return configured
- }
-
/**
* 处理非流式响应
*/
diff --git a/src/services/openaiToClaude.js b/src/services/openaiToClaude.js
index 1f335f0e..10c8ae24 100644
--- a/src/services/openaiToClaude.js
+++ b/src/services/openaiToClaude.js
@@ -31,25 +31,10 @@ class OpenAIToClaudeConverter {
stream: openaiRequest.stream || false
}
- // 定义 Claude Code 的默认系统提示词
+ // Claude Code 必需的系统消息
const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude."
- // 如果 OpenAI 请求中包含系统消息,提取并检查
- const systemMessage = this._extractSystemMessage(openaiRequest.messages)
- if (systemMessage && systemMessage.includes('You are currently in Xcode')) {
- // Xcode 系统提示词
- claudeRequest.system = systemMessage
- logger.info(
- `🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
- )
- logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`)
- } else {
- // 使用 Claude Code 默认系统提示词
- claudeRequest.system = claudeCodeSystemMessage
- logger.debug(
- `📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}`
- )
- }
+ claudeRequest.system = claudeCodeSystemMessage
// 处理停止序列
if (openaiRequest.stop) {
diff --git a/src/validators/clients/codexCliValidator.js b/src/validators/clients/codexCliValidator.js
index d8922bd2..aff09fbf 100644
--- a/src/validators/clients/codexCliValidator.js
+++ b/src/validators/clients/codexCliValidator.js
@@ -42,7 +42,7 @@ class CodexCliValidator {
// Codex CLI 的 UA 格式:
// - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10)
// - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal
- const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i
+ const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d\.]+/i
const uaMatch = userAgent.match(codexCliPattern)
if (!uaMatch) {
@@ -53,8 +53,7 @@ class CodexCliValidator {
// 2. 对于特定路径,进行额外的严格验证
// 对于 /openai 和 /azure 路径需要完整验证
const strictValidationPaths = ['/openai', '/azure']
- const needsStrictValidation =
- req.path && strictValidationPaths.some((path) => req.path.startsWith(path))
+ const needsStrictValidation = req.path && strictValidationPaths.some(path => req.path.startsWith(path))
if (!needsStrictValidation) {
// 其他路径,只要 User-Agent 匹配就认为是 Codex CLI
@@ -125,12 +124,8 @@ class CodexCliValidator {
const part1 = parts1[i] || 0
const part2 = parts2[i] || 0
- if (part1 < part2) {
- return -1
- }
- if (part1 > part2) {
- return 1
- }
+ if (part1 < part2) return -1
+ if (part1 > part2) return 1
}
return 0
diff --git a/src/validators/clients/geminiCliValidator.js b/src/validators/clients/geminiCliValidator.js
index 0d438384..ea8e60e7 100644
--- a/src/validators/clients/geminiCliValidator.js
+++ b/src/validators/clients/geminiCliValidator.js
@@ -53,11 +53,9 @@ class GeminiCliValidator {
// 2. 对于 /gemini 路径,检查是否包含 generateContent
if (path.includes('generateContent')) {
// 包含 generateContent 的路径需要验证 User-Agent
- const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i
+ const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
if (!geminiCliPattern.test(userAgent)) {
- logger.debug(
- `Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
- )
+ logger.debug(`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`)
return false
}
}
@@ -84,12 +82,8 @@ class GeminiCliValidator {
const part1 = parts1[i] || 0
const part2 = parts2[i] || 0
- if (part1 < part2) {
- return -1
- }
- if (part1 > part2) {
- return 1
- }
+ if (part1 < part2) return -1
+ if (part1 > part2) return 1
}
return 0
diff --git a/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue
new file mode 100644
index 00000000..046c3332
--- /dev/null
+++ b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue
@@ -0,0 +1,416 @@
+
+
+ + 将于 {{ formatExpireDate(form.expiresAt) }} 过期 +
++ + 账户永不过期 +
++ 设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 +
++ + 将于 {{ formatExpireDate(form.expiresAt) }} 过期 +
++ + 账户永不过期 +
++ 设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 +
+