diff --git a/.gitignore b/.gitignore index 10594f73..e4c9e9c1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ redis_data/ # Logs directory logs/ +logs1/ *.log startup.log app.log diff --git a/VERSION b/VERSION index 2f3faf60..80f39145 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.221 +1.1.224 diff --git a/package-lock.json b/package-lock.json index 039664e6..c6dccd11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { @@ -890,6 +891,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2998,6 +3000,7 @@ "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3079,6 +3082,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3534,6 +3538,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -4421,6 +4426,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4477,6 +4483,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7575,6 +7582,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7598,6 +7606,85 @@ "node": ">=6.0.0" } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", @@ -9014,6 +9101,7 @@ "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", diff --git a/package.json b/package.json index 72ea4720..2b7ffa25 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { diff --git a/scripts/test-official-models.js b/scripts/test-official-models.js new file mode 100644 index 00000000..d87953fa --- /dev/null +++ b/scripts/test-official-models.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * 官方模型版本识别测试 - 最终版 v2 + */ + +const { isOpus45OrNewer } = require('../src/utils/modelHelper') + +// 官方模型 +const officialModels = [ + { name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false }, + { name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false }, + { name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false }, + { name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true } +] + +// 非 Opus 模型 +const nonOpusModels = [ + { name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' }, + { name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' }, + { name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' }, + { name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' }, + { name: 'claude-3-haiku-20240307', desc: 'Haiku 3' }, + { name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' } +] + +// 其他格式测试 +const otherFormats = [ + { name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' }, + { name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' }, + { name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' }, + { name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' }, + { name: 'opus-latest', expected: true, desc: 'Opus latest' }, + { name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' }, + { name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' }, + { name: 'opus-4.0', expected: false, desc: 'Opus 4.0' }, + { name: 'opus-4.1', expected: false, desc: 'Opus 4.1' }, + { name: 'opus-4.4', expected: false, desc: 'Opus 4.4' }, + { name: 'opus-4', expected: false, desc: 'Opus 4' }, + { name: 'opus-4-0', expected: false, desc: 'Opus 4-0' }, + { name: 'opus-4-1', expected: false, desc: 'Opus 4-1' }, + { name: 'opus-4-4', expected: false, desc: 'Opus 4-4' }, + { name: 'opus', expected: false, desc: '仅 opus' }, + { name: null, expected: false, desc: 'null' }, + { name: '', expected: false, desc: '空字符串' } +] + +console.log('='.repeat(90)) +console.log('官方模型版本识别测试 - 最终版 v2') +console.log('='.repeat(90)) +console.log() + +let passed = 0 +let failed = 0 + +// 测试官方 Opus 模型 +console.log('📌 官方 Opus 模型:') +for (const m of officialModels) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL' + if (result === m.expectPro) { + passed++ + } else { + failed++ + } + const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌' + console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`) +} + +console.log() +console.log('📌 非 Opus 模型 (不受此函数影响):') +for (const m of nonOpusModels) { + const result = isOpus45OrNewer(m.name) + console.log( + ` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}` + ) + if (result) { + failed++ // 非 Opus 模型不应返回 true + } +} + +console.log() +console.log('📌 其他格式测试:') +for (const m of otherFormats) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expected ? '✅ PASS' : '❌ FAIL' + if (result === m.expected) { + passed++ + } else { + failed++ + } + const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name + console.log( + ` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}` + ) +} + +console.log() +console.log('='.repeat(90)) +console.log('测试结果:', passed, '通过,', failed, '失败') +console.log('='.repeat(90)) + +if (failed > 0) { + console.log('\n❌ 有测试失败,请检查函数逻辑') + process.exit(1) +} else { + console.log('\n✅ 所有测试通过!函数可以安全使用') + process.exit(0) +} diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js index cfdf35e0..87295d31 100644 --- a/src/handlers/geminiHandlers.js +++ b/src/handlers/geminiHandlers.js @@ -449,9 +449,8 @@ async function handleMessages(req, res) { // 添加代理配置 if (proxyConfig) { - const proxyHelper = new ProxyHelper() - axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig) - axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) } try { @@ -732,9 +731,8 @@ async function handleModels(req, res) { headers: { 'Content-Type': 'application/json' } } if (proxyConfig) { - const proxyHelper = new ProxyHelper() - axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig) - axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) } const response = await axios(axiosConfig) models = (response.data.models || []).map((m) => ({ @@ -1234,9 +1232,8 @@ async function handleCountTokens(req, res) { } if (proxyConfig) { - const proxyHelper = new ProxyHelper() - axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig) - axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) } try { @@ -1963,9 +1960,8 @@ async function handleStandardGenerateContent(req, res) { } if (proxyConfig) { - const proxyHelper = new ProxyHelper() - axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig) - axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) } try { @@ -2246,9 +2242,8 @@ async function handleStandardStreamGenerateContent(req, res) { } if (proxyConfig) { - const proxyHelper = new ProxyHelper() - axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig) - axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) } try { diff --git a/src/middleware/auth.js b/src/middleware/auth.js index c39655e0..9c34fd5e 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -226,8 +226,18 @@ const authenticateApiKey = async (req, res, next) => { ) if (currentConcurrency > concurrencyLimit) { - // 如果超过限制,立即减少计数 - await redis.decrConcurrency(validation.keyData.id, requestId) + // 如果超过限制,立即减少计数(添加 try-catch 防止异常导致并发泄漏) + try { + const newCount = await redis.decrConcurrency(validation.keyData.id, requestId) + logger.api( + `📉 Decremented concurrency (429 rejected) for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}` + ) + } catch (error) { + logger.error( + `Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`, + error + ) + } logger.security( `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${ validation.keyData.name @@ -249,7 +259,38 @@ const authenticateApiKey = async (req, res, next) => { let leaseRenewInterval = null if (renewIntervalMs > 0) { + // 🔴 关键修复:添加最大刷新次数限制,防止租约永不过期 + // 默认最大生存时间为 10 分钟,可通过环境变量配置 + const maxLifetimeMinutes = parseInt(process.env.CONCURRENCY_MAX_LIFETIME_MINUTES) || 10 + const maxRefreshCount = Math.ceil((maxLifetimeMinutes * 60 * 1000) / renewIntervalMs) + let refreshCount = 0 + leaseRenewInterval = setInterval(() => { + refreshCount++ + + // 超过最大刷新次数,强制停止并清理 + if (refreshCount > maxRefreshCount) { + logger.warn( + `⚠️ Lease refresh exceeded max count (${maxRefreshCount}) for key ${validation.keyData.id} (${validation.keyData.name}), forcing cleanup after ${maxLifetimeMinutes} minutes` + ) + // 清理定时器 + if (leaseRenewInterval) { + clearInterval(leaseRenewInterval) + leaseRenewInterval = null + } + // 强制减少并发计数(如果还没减少) + if (!concurrencyDecremented) { + concurrencyDecremented = true + redis.decrConcurrency(validation.keyData.id, requestId).catch((error) => { + logger.error( + `Failed to decrement concurrency after max refresh for key ${validation.keyData.id}:`, + error + ) + }) + } + return + } + redis .refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds) .catch((error) => { diff --git a/src/models/redis.js b/src/models/redis.js index 10f10e7a..2393f3b3 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -284,7 +284,8 @@ class RedisClient { isActive = '', sortBy = 'createdAt', sortOrder = 'desc', - excludeDeleted = true // 默认排除已删除的 API Keys + excludeDeleted = true, // 默认排除已删除的 API Keys + modelFilter = [] } = options // 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞) @@ -332,6 +333,15 @@ class RedisClient { } } + // 模型筛选 + if (modelFilter.length > 0) { + const keyIdsWithModels = await this.getKeyIdsWithModels( + filteredKeys.map((k) => k.id), + modelFilter + ) + filteredKeys = filteredKeys.filter((k) => keyIdsWithModels.has(k.id)) + } + // 4. 排序 filteredKeys.sort((a, b) => { // status 排序实际上使用 isActive 字段(API Key 没有 status 字段) @@ -781,6 +791,58 @@ class RedisClient { await Promise.all(operations) } + /** + * 获取使用了指定模型的 Key IDs(OR 逻辑) + */ + async getKeyIdsWithModels(keyIds, models) { + if (!keyIds.length || !models.length) { + return new Set() + } + + const client = this.getClientSafe() + const result = new Set() + + // 批量检查每个 keyId 是否使用过任意一个指定模型 + for (const keyId of keyIds) { + for (const model of models) { + // 检查是否有该模型的使用记录(daily 或 monthly) + const pattern = `usage:${keyId}:model:*:${model}:*` + const keys = await client.keys(pattern) + if (keys.length > 0) { + result.add(keyId) + break // 找到一个就够了(OR 逻辑) + } + } + } + + return result + } + + /** + * 获取所有被使用过的模型列表 + */ + async getAllUsedModels() { + const client = this.getClientSafe() + const models = new Set() + + // 扫描所有模型使用记录 + const pattern = 'usage:*:model:daily:*' + let cursor = '0' + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000) + cursor = nextCursor + for (const key of keys) { + // 从 key 中提取模型名: usage:{keyId}:model:daily:{model}:{date} + const match = key.match(/usage:[^:]+:model:daily:([^:]+):/) + if (match) { + models.add(match[1]) + } + } + } while (cursor !== '0') + + return [...models].sort() + } + async getUsageStats(keyId) { const totalKey = `usage:${keyId}` const today = getDateStringInTimezone() @@ -2034,6 +2096,246 @@ class RedisClient { return await this.getConcurrency(compositeKey) } + // 🔧 并发管理方法(用于管理员手动清理) + + /** + * 获取所有并发状态 + * @returns {Promise} 并发状态列表 + */ + async getAllConcurrencyStatus() { + try { + const client = this.getClientSafe() + const keys = await client.keys('concurrency:*') + const now = Date.now() + const results = [] + + for (const key of keys) { + // 提取 apiKeyId(去掉 concurrency: 前缀) + const apiKeyId = key.replace('concurrency:', '') + + // 获取所有成员和分数(过期时间) + const members = await client.zrangebyscore(key, now, '+inf', 'WITHSCORES') + + // 解析成员和过期时间 + const activeRequests = [] + for (let i = 0; i < members.length; i += 2) { + const requestId = members[i] + const expireAt = parseInt(members[i + 1]) + const remainingSeconds = Math.max(0, Math.round((expireAt - now) / 1000)) + activeRequests.push({ + requestId, + expireAt: new Date(expireAt).toISOString(), + remainingSeconds + }) + } + + // 获取过期的成员数量 + const expiredCount = await client.zcount(key, '-inf', now) + + results.push({ + apiKeyId, + key, + activeCount: activeRequests.length, + expiredCount, + activeRequests + }) + } + + return results + } catch (error) { + logger.error('❌ Failed to get all concurrency status:', error) + throw error + } + } + + /** + * 获取特定 API Key 的并发状态详情 + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 并发状态详情 + */ + async getConcurrencyStatus(apiKeyId) { + try { + const client = this.getClientSafe() + const key = `concurrency:${apiKeyId}` + const now = Date.now() + + // 检查 key 是否存在 + const exists = await client.exists(key) + if (!exists) { + return { + apiKeyId, + key, + activeCount: 0, + expiredCount: 0, + activeRequests: [], + exists: false + } + } + + // 获取所有成员和分数 + const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES') + + const activeRequests = [] + const expiredRequests = [] + + for (let i = 0; i < allMembers.length; i += 2) { + const requestId = allMembers[i] + const expireAt = parseInt(allMembers[i + 1]) + const remainingSeconds = Math.round((expireAt - now) / 1000) + + const requestInfo = { + requestId, + expireAt: new Date(expireAt).toISOString(), + remainingSeconds + } + + if (expireAt > now) { + activeRequests.push(requestInfo) + } else { + expiredRequests.push(requestInfo) + } + } + + return { + apiKeyId, + key, + activeCount: activeRequests.length, + expiredCount: expiredRequests.length, + activeRequests, + expiredRequests, + exists: true + } + } catch (error) { + logger.error(`❌ Failed to get concurrency status for ${apiKeyId}:`, error) + throw error + } + } + + /** + * 强制清理特定 API Key 的并发计数(忽略租约) + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 清理结果 + */ + async forceClearConcurrency(apiKeyId) { + try { + const client = this.getClientSafe() + const key = `concurrency:${apiKeyId}` + + // 获取清理前的状态 + const beforeCount = await client.zcard(key) + + // 删除整个 key + await client.del(key) + + logger.warn( + `🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries` + ) + + return { + apiKeyId, + key, + clearedCount: beforeCount, + success: true + } + } catch (error) { + logger.error(`❌ Failed to force clear concurrency for ${apiKeyId}:`, error) + throw error + } + } + + /** + * 强制清理所有并发计数 + * @returns {Promise} 清理结果 + */ + async forceClearAllConcurrency() { + try { + const client = this.getClientSafe() + const keys = await client.keys('concurrency:*') + + let totalCleared = 0 + const clearedKeys = [] + + for (const key of keys) { + const count = await client.zcard(key) + await client.del(key) + totalCleared += count + clearedKeys.push({ + key, + clearedCount: count + }) + } + + logger.warn( + `🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries` + ) + + return { + keysCleared: keys.length, + totalEntriesCleared: totalCleared, + clearedKeys, + success: true + } + } catch (error) { + logger.error('❌ Failed to force clear all concurrency:', error) + throw error + } + } + + /** + * 清理过期的并发条目(不影响活跃请求) + * @param {string} apiKeyId - API Key ID(可选,不传则清理所有) + * @returns {Promise} 清理结果 + */ + async cleanupExpiredConcurrency(apiKeyId = null) { + try { + const client = this.getClientSafe() + const now = Date.now() + let keys + + if (apiKeyId) { + keys = [`concurrency:${apiKeyId}`] + } else { + keys = await client.keys('concurrency:*') + } + + let totalCleaned = 0 + const cleanedKeys = [] + + for (const key of keys) { + // 只清理过期的条目 + const cleaned = await client.zremrangebyscore(key, '-inf', now) + if (cleaned > 0) { + totalCleaned += cleaned + cleanedKeys.push({ + key, + cleanedCount: cleaned + }) + } + + // 如果 key 为空,删除它 + const remaining = await client.zcard(key) + if (remaining === 0) { + await client.del(key) + } + } + + logger.info( + `🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys` + ) + + return { + keysProcessed: keys.length, + keysCleaned: cleanedKeys.length, + totalEntriesCleaned: totalCleaned, + cleanedKeys, + success: true + } + } catch (error) { + logger.error('❌ Failed to cleanup expired concurrency:', error) + throw error + } + } + // 🔧 Basic Redis operations wrapper methods for convenience async get(key) { const client = this.getClientSafe() diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index 3a45feb0..9a22cb50 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -103,6 +103,17 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => } }) +// 获取所有被使用过的模型列表 +router.get('/api-keys/used-models', authenticateAdmin, async (req, res) => { + try { + const models = await redis.getAllUsedModels() + return res.json({ success: true, data: models }) + } catch (error) { + logger.error('❌ Failed to get used models:', error) + return res.status(500).json({ error: 'Failed to get used models', message: error.message }) + } +}) + // 获取所有API Keys router.get('/api-keys', authenticateAdmin, async (req, res) => { try { @@ -116,6 +127,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { // 筛选参数 tag = '', isActive = '', + models = '', // 模型筛选(逗号分隔) // 排序参数 sortBy = 'createdAt', sortOrder = 'desc', @@ -127,6 +139,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { timeRange = 'all' } = req.query + // 解析模型筛选参数 + const modelFilter = models ? models.split(',').filter((m) => m.trim()) : [] + // 验证分页参数 const pageNum = Math.max(1, parseInt(page) || 1) const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20 @@ -217,7 +232,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { search, searchMode, tag, - isActive + isActive, + modelFilter }) costSortStatus = { @@ -250,7 +266,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { search, searchMode, tag, - isActive + isActive, + modelFilter }) costSortStatus.isRealTimeCalculation = false @@ -265,7 +282,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { tag, isActive, sortBy: validSortBy, - sortOrder: validSortOrder + sortOrder: validSortOrder, + modelFilter }) } @@ -322,7 +340,17 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { * 使用预计算索引进行费用排序的分页查询 */ async function getApiKeysSortedByCostPrecomputed(options) { - const { page, pageSize, sortOrder, costTimeRange, search, searchMode, tag, isActive } = options + const { + page, + pageSize, + sortOrder, + costTimeRange, + search, + searchMode, + tag, + isActive, + modelFilter = [] + } = options const costRankService = require('../../services/costRankService') // 1. 获取排序后的全量 keyId 列表 @@ -369,6 +397,15 @@ async function getApiKeysSortedByCostPrecomputed(options) { } } + // 模型筛选 + if (modelFilter.length > 0) { + const keyIdsWithModels = await redis.getKeyIdsWithModels( + orderedKeys.map((k) => k.id), + modelFilter + ) + orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id)) + } + // 5. 收集所有可用标签 const allTags = new Set() for (const key of allKeys) { @@ -411,8 +448,18 @@ async function getApiKeysSortedByCostPrecomputed(options) { * 使用实时计算进行 custom 时间范围的费用排序 */ async function getApiKeysSortedByCostCustom(options) { - const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } = - options + const { + page, + pageSize, + sortOrder, + startDate, + endDate, + search, + searchMode, + tag, + isActive, + modelFilter = [] + } = options const costRankService = require('../../services/costRankService') // 1. 实时计算所有 Keys 的费用 @@ -427,9 +474,9 @@ async function getApiKeysSortedByCostCustom(options) { } // 2. 转换为数组并排序 - const sortedEntries = [...costs.entries()].sort((a, b) => { - return sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1] - }) + const sortedEntries = [...costs.entries()].sort((a, b) => + sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1] + ) const rankedKeyIds = sortedEntries.map(([keyId]) => keyId) // 3. 批量获取 API Key 基础数据 @@ -465,6 +512,15 @@ async function getApiKeysSortedByCostCustom(options) { } } + // 模型筛选 + if (modelFilter.length > 0) { + const keyIdsWithModels = await redis.getKeyIdsWithModels( + orderedKeys.map((k) => k.id), + modelFilter + ) + orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id)) + } + // 6. 收集所有可用标签 const allTags = new Set() for (const key of allKeys) { diff --git a/src/routes/admin/concurrency.js b/src/routes/admin/concurrency.js new file mode 100644 index 00000000..80fee22c --- /dev/null +++ b/src/routes/admin/concurrency.js @@ -0,0 +1,145 @@ +/** + * 并发管理 API 路由 + * 提供并发状态查看和手动清理功能 + */ + +const express = require('express') +const router = express.Router() +const redis = require('../../models/redis') +const logger = require('../../utils/logger') + +/** + * GET /admin/concurrency + * 获取所有并发状态 + */ +router.get('/concurrency', async (req, res) => { + try { + const status = await redis.getAllConcurrencyStatus() + + // 计算汇总统计 + const summary = { + totalKeys: status.length, + totalActiveRequests: status.reduce((sum, s) => sum + s.activeCount, 0), + totalExpiredRequests: status.reduce((sum, s) => sum + s.expiredCount, 0) + } + + res.json({ + success: true, + summary, + concurrencyStatus: status + }) + } catch (error) { + logger.error('❌ Failed to get concurrency status:', error) + res.status(500).json({ + success: false, + error: 'Failed to get concurrency status', + message: error.message + }) + } +}) + +/** + * GET /admin/concurrency/:apiKeyId + * 获取特定 API Key 的并发状态详情 + */ +router.get('/concurrency/:apiKeyId', async (req, res) => { + try { + const { apiKeyId } = req.params + const status = await redis.getConcurrencyStatus(apiKeyId) + + res.json({ + success: true, + concurrencyStatus: status + }) + } catch (error) { + logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error) + res.status(500).json({ + success: false, + error: 'Failed to get concurrency status', + message: error.message + }) + } +}) + +/** + * DELETE /admin/concurrency/:apiKeyId + * 强制清理特定 API Key 的并发计数 + */ +router.delete('/concurrency/:apiKeyId', async (req, res) => { + try { + const { apiKeyId } = req.params + const result = await redis.forceClearConcurrency(apiKeyId) + + logger.warn( + `🧹 Admin ${req.admin?.username || 'unknown'} force cleared concurrency for key ${apiKeyId}` + ) + + res.json({ + success: true, + message: `Successfully cleared concurrency for API key ${apiKeyId}`, + result + }) + } catch (error) { + logger.error(`❌ Failed to clear concurrency for ${req.params.apiKeyId}:`, error) + res.status(500).json({ + success: false, + error: 'Failed to clear concurrency', + message: error.message + }) + } +}) + +/** + * DELETE /admin/concurrency + * 强制清理所有并发计数 + */ +router.delete('/concurrency', async (req, res) => { + try { + const result = await redis.forceClearAllConcurrency() + + logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} force cleared ALL concurrency`) + + res.json({ + success: true, + message: 'Successfully cleared all concurrency', + result + }) + } catch (error) { + logger.error('❌ Failed to clear all concurrency:', error) + res.status(500).json({ + success: false, + error: 'Failed to clear all concurrency', + message: error.message + }) + } +}) + +/** + * POST /admin/concurrency/cleanup + * 清理过期的并发条目(不影响活跃请求) + */ +router.post('/concurrency/cleanup', async (req, res) => { + try { + const { apiKeyId } = req.body + const result = await redis.cleanupExpiredConcurrency(apiKeyId || null) + + logger.info(`🧹 Admin ${req.admin?.username || 'unknown'} cleaned up expired concurrency`) + + res.json({ + success: true, + message: apiKeyId + ? `Successfully cleaned up expired concurrency for API key ${apiKeyId}` + : 'Successfully cleaned up all expired concurrency', + result + }) + } catch (error) { + logger.error('❌ Failed to cleanup expired concurrency:', error) + res.status(500).json({ + success: false, + error: 'Failed to cleanup expired concurrency', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index 32b77db6..3e133c50 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -22,6 +22,7 @@ const droidAccountsRoutes = require('./droidAccounts') const dashboardRoutes = require('./dashboard') const usageStatsRoutes = require('./usageStats') const systemRoutes = require('./system') +const concurrencyRoutes = require('./concurrency') // 挂载所有子路由 // 使用完整路径的模块(直接挂载到根路径) @@ -35,6 +36,7 @@ router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) router.use('/', systemRoutes) +router.use('/', concurrencyRoutes) // 使用相对路径的模块(需要指定基础路径前缀) router.use('/account-groups', accountGroupsRoutes) diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js index f44a1b4d..bf35f8a0 100644 --- a/src/routes/admin/usageStats.js +++ b/src/routes/admin/usageStats.js @@ -1,8 +1,10 @@ const express = require('express') const apiKeyService = require('../../services/apiKeyService') +const ccrAccountService = require('../../services/ccrAccountService') const claudeAccountService = require('../../services/claudeAccountService') const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService') const geminiAccountService = require('../../services/geminiAccountService') +const geminiApiAccountService = require('../../services/geminiApiAccountService') const openaiAccountService = require('../../services/openaiAccountService') const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const droidAccountService = require('../../services/droidAccountService') @@ -14,6 +16,65 @@ const pricingService = require('../../services/pricingService') const router = express.Router() +const accountTypeNames = { + claude: 'Claude官方', + 'claude-console': 'Claude Console', + ccr: 'Claude Console Relay', + openai: 'OpenAI', + 'openai-responses': 'OpenAI Responses', + gemini: 'Gemini', + 'gemini-api': 'Gemini API', + droid: 'Droid', + unknown: '未知渠道' +} + +const resolveAccountByPlatform = async (accountId, platform) => { + const serviceMap = { + claude: claudeAccountService, + 'claude-console': claudeConsoleAccountService, + gemini: geminiAccountService, + 'gemini-api': geminiApiAccountService, + openai: openaiAccountService, + 'openai-responses': openaiResponsesAccountService, + droid: droidAccountService, + ccr: ccrAccountService + } + + if (platform && serviceMap[platform]) { + try { + const account = await serviceMap[platform].getAccount(accountId) + if (account) { + return { ...account, platform } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`) + } + } + + for (const [platformName, service] of Object.entries(serviceMap)) { + try { + const account = await service.getAccount(accountId) + if (account) { + return { ...account, platform: platformName } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`) + } + } + + return null +} + +const getApiKeyName = async (keyId) => { + try { + const keyData = await redis.getApiKey(keyId) + return keyData?.name || keyData?.label || keyId + } catch (error) { + logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`) + return keyId + } +} + // 📊 账户使用统计 // 获取所有账户的使用统计 @@ -148,7 +209,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, accountData = await geminiAccountService.getAccount(accountId) break case 'gemini-api': { - const geminiApiAccountService = require('../../services/geminiApiAccountService') accountData = await geminiApiAccountService.getAccount(accountId) break } @@ -369,7 +429,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { logger.info(` endDate (raw): ${endDate}`) logger.info(` startTime (parsed): ${startTime.toISOString()}`) logger.info(` endTime (parsed): ${endTime.toISOString()}`) - logger.info(` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`) + logger.info( + ` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}` + ) } else { // 默认最近24小时 endTime = new Date() @@ -890,7 +952,6 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => { }) ] } else if (group === 'gemini') { - const geminiApiAccountService = require('../../services/geminiApiAccountService') const [geminiAccounts, geminiApiAccounts] = await Promise.all([ geminiAccountService.getAllAccounts(), geminiApiAccountService.getAllAccounts(true) @@ -1818,4 +1879,628 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { } }) +// 获取 API Key 的请求记录时间线 +router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { + page = 1, + pageSize = 50, + startDate, + endDate, + model, + accountId, + sortOrder = 'desc' + } = req.query + + const pageNumber = Math.max(parseInt(page, 10) || 1, 1) + const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200) + const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc' + + const startTime = startDate ? new Date(startDate) : null + const endTime = endDate ? new Date(endDate) : null + + if ( + (startDate && Number.isNaN(startTime?.getTime())) || + (endDate && Number.isNaN(endTime?.getTime())) + ) { + return res.status(400).json({ success: false, error: 'Invalid date range' }) + } + + if (startTime && endTime && startTime > endTime) { + return res + .status(400) + .json({ success: false, error: 'Start date must be before or equal to end date' }) + } + + const apiKeyInfo = await redis.getApiKey(keyId) + if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) { + return res.status(404).json({ success: false, error: 'API key not found' }) + } + + const rawRecords = await redis.getUsageRecords(keyId, 5000) + + const accountServices = [ + { type: 'claude', getter: (id) => claudeAccountService.getAccount(id) }, + { type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) }, + { type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) }, + { type: 'openai', getter: (id) => openaiAccountService.getAccount(id) }, + { type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) }, + { type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) }, + { type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) }, + { type: 'droid', getter: (id) => droidAccountService.getAccount(id) } + ] + + const accountCache = new Map() + const resolveAccountInfo = async (id, type) => { + if (!id) { + return null + } + + const cacheKey = `${type || 'any'}:${id}` + if (accountCache.has(cacheKey)) { + return accountCache.get(cacheKey) + } + + let servicesToTry = type + ? accountServices.filter((svc) => svc.type === type) + : accountServices + + // 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号 + if (!servicesToTry.length) { + servicesToTry = accountServices + } + + for (const service of servicesToTry) { + try { + const account = await service.getter(id) + if (account) { + const info = { + id, + name: account.name || account.email || id, + type: service.type, + status: account.status || account.isActive + } + accountCache.set(cacheKey, info) + return info + } + } catch (error) { + logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`) + } + } + + accountCache.set(cacheKey, null) + return null + } + + const toUsageObject = (record) => ({ + input_tokens: record.inputTokens || 0, + output_tokens: record.outputTokens || 0, + cache_creation_input_tokens: record.cacheCreateTokens || 0, + cache_read_input_tokens: record.cacheReadTokens || 0, + cache_creation: record.cacheCreation || record.cache_creation || null + }) + + const withinRange = (record) => { + if (!record.timestamp) { + return false + } + const ts = new Date(record.timestamp) + if (Number.isNaN(ts.getTime())) { + return false + } + if (startTime && ts < startTime) { + return false + } + if (endTime && ts > endTime) { + return false + } + return true + } + + const filteredRecords = rawRecords.filter((record) => { + if (!withinRange(record)) { + return false + } + if (model && record.model !== model) { + return false + } + if (accountId && record.accountId !== accountId) { + return false + } + return true + }) + + filteredRecords.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return 0 + } + return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime + }) + + const summary = { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0 + } + + const modelSet = new Set() + const accountOptionMap = new Map() + let earliestTimestamp = null + let latestTimestamp = null + + for (const record of filteredRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + summary.totalRequests += 1 + summary.inputTokens += usage.input_tokens + summary.outputTokens += usage.output_tokens + summary.cacheCreateTokens += usage.cache_creation_input_tokens + summary.cacheReadTokens += usage.cache_read_input_tokens + summary.totalTokens += totalTokens + summary.totalCost += computedCost + + if (record.model) { + modelSet.add(record.model) + } + + if (record.accountId) { + const normalizedType = record.accountType || 'unknown' + if (!accountOptionMap.has(record.accountId)) { + accountOptionMap.set(record.accountId, { + id: record.accountId, + accountTypes: new Set([normalizedType]) + }) + } else { + accountOptionMap.get(record.accountId).accountTypes.add(normalizedType) + } + } + + if (record.timestamp) { + const ts = new Date(record.timestamp) + if (!Number.isNaN(ts.getTime())) { + if (!earliestTimestamp || ts < earliestTimestamp) { + earliestTimestamp = ts + } + if (!latestTimestamp || ts > latestTimestamp) { + latestTimestamp = ts + } + } + } + } + + const totalRecords = filteredRecords.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0 + const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1 + const startIndex = (safePage - 1) * pageSizeNumber + const pageRecords = + totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber) + + const enrichedRecords = [] + for (const record of pageRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + const accountInfo = await resolveAccountInfo(record.accountId, record.accountType) + const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown' + + enrichedRecords.push({ + timestamp: record.timestamp, + model: record.model || 'unknown', + accountId: record.accountId || null, + accountName: accountInfo?.name || null, + accountStatus: accountInfo?.status ?? null, + accountType: resolvedAccountType, + accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道', + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + ephemeral5mTokens: record.ephemeral5mTokens || 0, + ephemeral1hTokens: record.ephemeral1hTokens || 0, + totalTokens, + isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, + cost: Number(computedCost.toFixed(6)), + costFormatted: + record.costFormatted || + costData?.formatted?.total || + CostCalculator.formatCost(computedCost), + costBreakdown: record.costBreakdown || { + input: costData?.costs?.input || 0, + output: costData?.costs?.output || 0, + cacheCreate: costData?.costs?.cacheWrite || 0, + cacheRead: costData?.costs?.cacheRead || 0, + total: costData?.costs?.total || computedCost + }, + responseTime: record.responseTime || null + }) + } + + const accountOptions = [] + for (const option of accountOptionMap.values()) { + const types = Array.from(option.accountTypes || []) + + // 优先按历史出现的 accountType 解析,若失败则回退全量解析 + let resolvedInfo = null + for (const type of types) { + resolvedInfo = await resolveAccountInfo(option.id, type) + if (resolvedInfo && resolvedInfo.name) { + break + } + } + if (!resolvedInfo) { + resolvedInfo = await resolveAccountInfo(option.id) + } + + const chosenType = resolvedInfo?.type || types[0] || 'unknown' + const chosenTypeName = accountTypeNames[chosenType] || '未知渠道' + + if (!resolvedInfo) { + logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`) + } + + accountOptions.push({ + id: option.id, + name: resolvedInfo?.name || option.id, + accountType: chosenType, + accountTypeName: chosenTypeName, + rawTypes: types + }) + } + + return res.json({ + success: true, + data: { + records: enrichedRecords, + pagination: { + currentPage: safePage, + pageSize: pageSizeNumber, + totalRecords, + totalPages, + hasNextPage: totalPages > 0 && safePage < totalPages, + hasPreviousPage: totalPages > 0 && safePage > 1 + }, + filters: { + startDate: startTime ? startTime.toISOString() : null, + endDate: endTime ? endTime.toISOString() : null, + model: model || null, + accountId: accountId || null, + sortOrder: normalizedSortOrder + }, + apiKeyInfo: { + id: keyId, + name: apiKeyInfo.name || apiKeyInfo.label || keyId + }, + summary: { + ...summary, + totalCost: Number(summary.totalCost.toFixed(6)), + avgCost: + summary.totalRequests > 0 + ? Number((summary.totalCost / summary.totalRequests).toFixed(6)) + : 0 + }, + availableFilters: { + models: Array.from(modelSet), + accounts: accountOptions, + dateRange: { + earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null, + latest: latestTimestamp ? latestTimestamp.toISOString() : null + } + } + } + }) + } catch (error) { + logger.error('❌ Failed to get API key usage records:', error) + return res + .status(500) + .json({ error: 'Failed to get API key usage records', message: error.message }) + } +}) + +// 获取账户的请求记录时间线 +router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { + platform, + page = 1, + pageSize = 50, + startDate, + endDate, + model, + apiKeyId, + sortOrder = 'desc' + } = req.query + + const pageNumber = Math.max(parseInt(page, 10) || 1, 1) + const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200) + const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc' + + const startTime = startDate ? new Date(startDate) : null + const endTime = endDate ? new Date(endDate) : null + + if ( + (startDate && Number.isNaN(startTime?.getTime())) || + (endDate && Number.isNaN(endTime?.getTime())) + ) { + return res.status(400).json({ success: false, error: 'Invalid date range' }) + } + + if (startTime && endTime && startTime > endTime) { + return res + .status(400) + .json({ success: false, error: 'Start date must be before or equal to end date' }) + } + + const accountInfo = await resolveAccountByPlatform(accountId, platform) + if (!accountInfo) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + const allApiKeys = await apiKeyService.getAllApiKeys(true) + const apiKeyNameCache = new Map( + allApiKeys.map((key) => [key.id, key.name || key.label || key.id]) + ) + + let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys + if (apiKeyId && keysToUse.length === 0) { + keysToUse = [{ id: apiKeyId }] + } + + const toUsageObject = (record) => ({ + input_tokens: record.inputTokens || 0, + output_tokens: record.outputTokens || 0, + cache_creation_input_tokens: record.cacheCreateTokens || 0, + cache_read_input_tokens: record.cacheReadTokens || 0, + cache_creation: record.cacheCreation || record.cache_creation || null + }) + + const withinRange = (record) => { + if (!record.timestamp) { + return false + } + const ts = new Date(record.timestamp) + if (Number.isNaN(ts.getTime())) { + return false + } + if (startTime && ts < startTime) { + return false + } + if (endTime && ts > endTime) { + return false + } + return true + } + + const filteredRecords = [] + const modelSet = new Set() + const apiKeyOptionMap = new Map() + let earliestTimestamp = null + let latestTimestamp = null + + const batchSize = 10 + for (let i = 0; i < keysToUse.length; i += batchSize) { + const batch = keysToUse.slice(i, i + batchSize) + const batchResults = await Promise.all( + batch.map(async (key) => { + try { + const records = await redis.getUsageRecords(key.id, 5000) + return { keyId: key.id, records: records || [] } + } catch (error) { + logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`) + return { keyId: key.id, records: [] } + } + }) + ) + + for (const { keyId, records } of batchResults) { + const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId)) + for (const record of records) { + if (record.accountId !== accountId) { + continue + } + if (!withinRange(record)) { + continue + } + if (model && record.model !== model) { + continue + } + + const accountType = record.accountType || accountInfo.platform || 'unknown' + const normalizedModel = record.model || 'unknown' + + modelSet.add(normalizedModel) + apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName }) + + if (record.timestamp) { + const ts = new Date(record.timestamp) + if (!Number.isNaN(ts.getTime())) { + if (!earliestTimestamp || ts < earliestTimestamp) { + earliestTimestamp = ts + } + if (!latestTimestamp || ts > latestTimestamp) { + latestTimestamp = ts + } + } + } + + filteredRecords.push({ + ...record, + model: normalizedModel, + accountType, + apiKeyId: keyId, + apiKeyName + }) + } + } + } + + filteredRecords.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return 0 + } + return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime + }) + + const summary = { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0 + } + + for (const record of filteredRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + summary.totalRequests += 1 + summary.inputTokens += usage.input_tokens + summary.outputTokens += usage.output_tokens + summary.cacheCreateTokens += usage.cache_creation_input_tokens + summary.cacheReadTokens += usage.cache_read_input_tokens + summary.totalTokens += totalTokens + summary.totalCost += computedCost + } + + const totalRecords = filteredRecords.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0 + const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1 + const startIndex = (safePage - 1) * pageSizeNumber + const pageRecords = + totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber) + + const enrichedRecords = [] + for (const record of pageRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + enrichedRecords.push({ + timestamp: record.timestamp, + model: record.model || 'unknown', + apiKeyId: record.apiKeyId, + apiKeyName: record.apiKeyName, + accountId, + accountName: accountInfo.name || accountInfo.email || accountId, + accountType: record.accountType, + accountTypeName: accountTypeNames[record.accountType] || '未知渠道', + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + ephemeral5mTokens: record.ephemeral5mTokens || 0, + ephemeral1hTokens: record.ephemeral1hTokens || 0, + totalTokens, + isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, + cost: Number(computedCost.toFixed(6)), + costFormatted: + record.costFormatted || + costData?.formatted?.total || + CostCalculator.formatCost(computedCost), + costBreakdown: record.costBreakdown || { + input: costData?.costs?.input || 0, + output: costData?.costs?.output || 0, + cacheCreate: costData?.costs?.cacheWrite || 0, + cacheRead: costData?.costs?.cacheRead || 0, + total: costData?.costs?.total || computedCost + }, + responseTime: record.responseTime || null + }) + } + + return res.json({ + success: true, + data: { + records: enrichedRecords, + pagination: { + currentPage: safePage, + pageSize: pageSizeNumber, + totalRecords, + totalPages, + hasNextPage: totalPages > 0 && safePage < totalPages, + hasPreviousPage: totalPages > 0 && safePage > 1 + }, + filters: { + startDate: startTime ? startTime.toISOString() : null, + endDate: endTime ? endTime.toISOString() : null, + model: model || null, + apiKeyId: apiKeyId || null, + platform: accountInfo.platform, + sortOrder: normalizedSortOrder + }, + accountInfo: { + id: accountId, + name: accountInfo.name || accountInfo.email || accountId, + platform: accountInfo.platform || platform || 'unknown', + status: accountInfo.status ?? accountInfo.isActive ?? null + }, + summary: { + ...summary, + totalCost: Number(summary.totalCost.toFixed(6)), + avgCost: + summary.totalRequests > 0 + ? Number((summary.totalCost / summary.totalRequests).toFixed(6)) + : 0 + }, + availableFilters: { + models: Array.from(modelSet), + apiKeys: Array.from(apiKeyOptionMap.values()), + dateRange: { + earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null, + latest: latestTimestamp ? latestTimestamp.toISOString() : null + } + } + } + }) + } catch (error) { + logger.error('❌ Failed to get account usage records:', error) + return res + .status(500) + .json({ error: 'Failed to get account usage records', message: error.message }) + } +}) + module.exports = router diff --git a/src/routes/admin/utils.js b/src/routes/admin/utils.js index 47b68123..49c02015 100644 --- a/src/routes/admin/utils.js +++ b/src/routes/admin/utils.js @@ -33,7 +33,9 @@ function mapExpiryField(updates, accountType, accountId) { if ('expiresAt' in mappedUpdates) { mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt delete mappedUpdates.expiresAt - logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`) + logger.info( + `Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}` + ) } return mappedUpdates } diff --git a/src/routes/api.js b/src/routes/api.js index ce31440d..b99cf6cc 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -824,7 +824,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { // 可选:根据 API Key 的模型限制过滤 let filteredModels = models if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) { - filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id)) + // 将 restrictedModels 视为黑名单:过滤掉受限模型 + filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id)) } res.json({ diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index 032c242f..2bb7cc09 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -15,6 +15,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const sessionHelper = require('../utils/sessionHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const pricingService = require('../services/pricingService') +const { getEffectiveModel } = require('../utils/modelHelper') // 🔧 辅助函数:检查 API Key 权限 function checkPermissions(apiKeyData, requiredPermission = 'claude') { @@ -75,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { } ] - // 如果启用了模型限制,过滤模型列表 + // 如果启用了模型限制,视为黑名单:过滤掉受限模型 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { - models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) + models = models.filter((model) => !apiKeyData.restrictedModels.includes(model.id)) } res.json({ @@ -114,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { }) } - // 检查模型限制 + // 模型限制(黑名单):命中则直接拒绝 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { - if (!apiKeyData.restrictedModels.includes(modelId)) { + if (apiKeyData.restrictedModels.includes(modelId)) { return res.status(404).json({ error: { message: `Model '${modelId}' not found`, @@ -199,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) { // 转换 OpenAI 请求为 Claude 格式 const claudeRequest = openaiToClaude.convertRequest(req.body) - // 检查模型限制 + // 模型限制(黑名单):命中受限模型则拒绝 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { - if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) { + const effectiveModel = getEffectiveModel(claudeRequest.model || '') + if (apiKeyData.restrictedModels.includes(effectiveModel)) { return res.status(403).json({ error: { message: `Model ${req.body.model} is not allowed for this API key`, diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 41fa1977..7faf9e87 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -247,9 +247,11 @@ const handleResponses = async (req, res) => { // 从请求体中提取模型和流式标志 let requestedModel = req.body?.model || null + const isCodexModel = + typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex') - // 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5 - if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') { + // 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),并且不是 Codex 系列,则覆盖为 gpt-5 + if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) { logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`) requestedModel = 'gpt-5' req.body.model = 'gpt-5' // 同时更新请求体中的模型 diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 29a7821e..77630364 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -16,6 +16,22 @@ const { const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') +const { isOpus45OrNewer } = require('../utils/modelHelper') + +/** + * Check if account is Pro (not Max) + * Compatible with both API real-time data (hasClaudePro) and local config (accountType) + * @param {Object} info - Subscription info object + * @returns {boolean} + */ +function isProAccount(info) { + // API real-time status takes priority + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return true + } + // Local configured account type + return info.accountType === 'claude_pro' +} class ClaudeAccountService { constructor() { @@ -852,31 +868,39 @@ class ClaudeAccountService { !this.isSubscriptionExpired(account) ) - // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + // Filter Opus models based on account type and model version if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + activeAccounts = activeAccounts.filter((account) => { - // 检查账号的订阅信息 if (account.subscriptionInfo) { try { const info = JSON.parse(account.subscriptionInfo) - // Pro 和 Free 账号不支持 Opus - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return false // Claude Pro 不支持 Opus + + // Free account: does not support any Opus model + if (info.accountType === 'free') { + return false } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - return false // 明确标记为 Pro 或 Free 的账号不支持 + + // Pro account: only supports Opus 4.5+ + if (isProAccount(info)) { + return isNewOpus } + + // Max account: supports all Opus versions + return true } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // Parse failed, assume legacy data (Max), default support return true } } - // 没有订阅信息的账号,默认当作支持(兼容旧数据) + // Account without subscription info, default to supported (legacy data compatibility) return true }) if (activeAccounts.length === 0) { - throw new Error('No Claude accounts available that support Opus model') + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No Claude accounts available that support ${modelDesc} model`) } } @@ -970,31 +994,39 @@ class ClaudeAccountService { !this.isSubscriptionExpired(account) ) - // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + // Filter Opus models based on account type and model version if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + sharedAccounts = sharedAccounts.filter((account) => { - // 检查账号的订阅信息 if (account.subscriptionInfo) { try { const info = JSON.parse(account.subscriptionInfo) - // Pro 和 Free 账号不支持 Opus - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return false // Claude Pro 不支持 Opus + + // Free account: does not support any Opus model + if (info.accountType === 'free') { + return false } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - return false // 明确标记为 Pro 或 Free 的账号不支持 + + // Pro account: only supports Opus 4.5+ + if (isProAccount(info)) { + return isNewOpus } + + // Max account: supports all Opus versions + return true } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // Parse failed, assume legacy data (Max), default support return true } } - // 没有订阅信息的账号,默认当作支持(兼容旧数据) + // Account without subscription info, default to supported (legacy data compatibility) return true }) if (sharedAccounts.length === 0) { - throw new Error('No shared Claude accounts available that support Opus model') + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No shared Claude accounts available that support ${modelDesc} model`) } } diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 9feeae0d..166d575b 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -1948,7 +1948,13 @@ class ClaudeRelayService { } // 🛠️ 统一的错误处理方法 - async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') { + async _handleServerError( + accountId, + statusCode, + sessionHash = null, + context = '', + accountType = 'claude-official' + ) { try { await claudeAccountService.recordServerError(accountId, statusCode) const errorCount = await claudeAccountService.getServerErrorCount(accountId) @@ -1962,6 +1968,18 @@ class ClaudeRelayService { `⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}` ) + // 标记账户为临时不可用(5分钟) + try { + await unifiedClaudeScheduler.markAccountTemporarilyUnavailable( + accountId, + accountType, + sessionHash, + 300 + ) + } catch (markError) { + logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, markError) + } + if (errorCount > threshold) { const errorTypeLabel = isTimeout ? 'timeout' : '5xx' // ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停 diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index e68d607e..aac79971 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService') const accountGroupService = require('./accountGroupService') const redis = require('../models/redis') const logger = require('../utils/logger') -const { parseVendorPrefixedModel } = require('../utils/modelHelper') +const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper') + +/** + * Check if account is Pro (not Max) + * + * ACCOUNT TYPE LOGIC (as of 2025-12-05): + * Pro accounts can be identified by either: + * 1. API real-time data: hasClaudePro=true && hasClaudeMax=false + * 2. Local config data: accountType='claude_pro' + * + * Account type restrictions for Opus models: + * - Free account: No Opus access at all + * - Pro account: Only Opus 4.5+ (new versions) + * - Max account: All Opus versions (legacy 3.x, 4.0, 4.1 and new 4.5+) + * + * Compatible with both API real-time data (hasClaudePro) and local config (accountType) + * @param {Object} info - Subscription info object + * @returns {boolean} - true if Pro account (not Free, not Max) + */ +function isProAccount(info) { + // API real-time status takes priority + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return true + } + // Local configured account type + return info.accountType === 'claude_pro' +} class UnifiedClaudeScheduler { constructor() { @@ -46,8 +72,14 @@ class UnifiedClaudeScheduler { return false } - // 2. Opus 模型的订阅级别检查 + // 2. Opus model subscription level check + // VERSION RESTRICTION LOGIC: + // - Free: No Opus models + // - Pro: Only Opus 4.5+ (isOpus45OrNewer = true) + // - Max: All Opus versions if (requestedModel.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(requestedModel) + if (account.subscriptionInfo) { try { const info = @@ -55,27 +87,36 @@ class UnifiedClaudeScheduler { ? JSON.parse(account.subscriptionInfo) : account.subscriptionInfo - // Pro 和 Free 账号不支持 Opus - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + // Free account: does not support any Opus model + if (info.accountType === 'free') { logger.info( - `🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}` + `🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}` ) return false } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - logger.info( - `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}` - ) - return false + + // Pro account: only supports Opus 4.5+ + // Reject legacy Opus (3.x, 4.0-4.4) but allow new Opus (4.5+) + if (isProAccount(info)) { + if (!isNewOpus) { + logger.info( + `🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}` + ) + return false + } + // Opus 4.5+ supported + return true } + + // Max account: supports all Opus versions (no restriction) } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // Parse failed, assume legacy data (Max), default support logger.debug( `Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max` ) } } - // 没有订阅信息的账号,默认当作支持(兼容旧数据) + // Account without subscription info, default to supported (legacy data compatibility) } } @@ -177,30 +218,41 @@ class UnifiedClaudeScheduler { // 普通专属账户 const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { - const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) - if (isRateLimited) { - const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) - const error = new Error('Dedicated Claude account is rate limited') - error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' - error.accountId = boundAccount.id - error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null - throw error - } - - if (!this._isSchedulable(boundAccount.schedulable)) { + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + boundAccount.id, + 'claude-official' + ) + if (isTempUnavailable) { logger.warn( - `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool` + `⏱️ Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool` ) } else { - if (isOpusRequest) { - await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error } - logger.info( - `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` - ) - return { - accountId: apiKeyData.claudeAccountId, - accountType: 'claude-official' + + if (!this._isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool` + ) + } else { + if (isOpusRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + } + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeAccountId, + accountType: 'claude-official' + } } } } else { @@ -221,12 +273,23 @@ class UnifiedClaudeScheduler { boundConsoleAccount.status === 'active' && this._isSchedulable(boundConsoleAccount.schedulable) ) { - logger.info( - `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + boundConsoleAccount.id, + 'claude-console' ) - return { - accountId: apiKeyData.claudeConsoleAccountId, - accountType: 'claude-console' + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeConsoleAccountId, + accountType: 'claude-console' + } } } else { logger.warn( @@ -245,12 +308,23 @@ class UnifiedClaudeScheduler { boundBedrockAccountResult.data.isActive === true && this._isSchedulable(boundBedrockAccountResult.data.schedulable) ) { - logger.info( - `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + apiKeyData.bedrockAccountId, + 'bedrock' ) - return { - accountId: apiKeyData.bedrockAccountId, - accountType: 'bedrock' + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.bedrockAccountId, + accountType: 'bedrock' + } } } else { logger.warn( @@ -496,6 +570,18 @@ class UnifiedClaudeScheduler { continue } + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + account.id, + 'claude-official' + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Claude Official account ${account.name} - temporarily unavailable` + ) + continue + } + // 检查是否被限流 const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) if (isRateLimited) { @@ -584,6 +670,18 @@ class UnifiedClaudeScheduler { // 继续处理该账号 } + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + currentAccount.id, + 'claude-console' + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable` + ) + continue + } + // 检查是否被限流 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( currentAccount.id @@ -682,7 +780,15 @@ class UnifiedClaudeScheduler { account.accountType === 'shared' && this._isSchedulable(account.schedulable) ) { - // 检查是否可调度 + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + account.id, + 'bedrock' + ) + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping Bedrock account ${account.name} - temporarily unavailable`) + continue + } availableAccounts.push({ ...account, @@ -731,6 +837,13 @@ class UnifiedClaudeScheduler { continue } + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable(account.id, 'ccr') + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping CCR account ${account.name} - temporarily unavailable`) + continue + } + // 检查是否被限流 const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) @@ -1099,6 +1212,42 @@ class UnifiedClaudeScheduler { } } + // ⏱️ 标记账户为临时不可用状态(用于5xx等临时故障,默认5分钟后自动恢复) + async markAccountTemporarilyUnavailable( + accountId, + accountType, + sessionHash = null, + ttlSeconds = 300 + ) { + try { + const client = redis.getClientSafe() + const key = `temp_unavailable:${accountType}:${accountId}` + await client.setex(key, ttlSeconds, '1') + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + logger.warn( + `⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error) + return { success: false } + } + } + + // 🔍 检查账户是否临时不可用 + async isAccountTemporarilyUnavailable(accountId, accountType) { + try { + const client = redis.getClientSafe() + const key = `temp_unavailable:${accountType}:${accountId}` + return (await client.exists(key)) === 1 + } catch (error) { + logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error) + return false + } + } + // 🚫 标记账户为限流状态 async markAccountRateLimited( accountId, diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js index cc954cc2..a42ee317 100644 --- a/src/utils/modelHelper.js +++ b/src/utils/modelHelper.js @@ -70,9 +70,119 @@ function getVendorType(modelStr) { return vendor } +/** + * Check if the model is Opus 4.5 or newer. + * + * VERSION LOGIC (as of 2025-12-05): + * - Opus 4.5+ (including 5.0, 6.0, etc.) → returns true (Pro account eligible) + * - Opus 4.4 and below (including 3.x, 4.0, 4.1) → returns false (Max account only) + * + * Supported naming formats: + * - New format: claude-opus-{major}[-{minor}][-date], e.g., claude-opus-4-5-20251101 + * - New format: claude-opus-{major}.{minor}, e.g., claude-opus-4.5 + * - Old format: claude-{version}-opus[-date], e.g., claude-3-opus-20240229 + * - Special: opus-latest, claude-opus-latest → always returns true + * + * @param {string} modelName - Model name + * @returns {boolean} - Whether the model is Opus 4.5 or newer + */ +function isOpus45OrNewer(modelName) { + if (!modelName) { + return false + } + + const lowerModel = modelName.toLowerCase() + if (!lowerModel.includes('opus')) { + return false + } + + // Handle 'latest' special case + if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { + return true + } + + // Old format: claude-{version}-opus (version before opus) + // e.g., claude-3-opus-20240229, claude-3.5-opus + const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/) + if (oldFormatMatch) { + const majorVersion = parseInt(oldFormatMatch[1], 10) + const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 + + // Old format version refers to Claude major version + // majorVersion > 4: 5.x, 6.x, ... → true + // majorVersion === 4 && minorVersion >= 5: 4.5, 4.6, ... → true + // Others (3.x, 4.0-4.4): → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // New format 1: opus-{major}.{minor} (dot-separated) + // e.g., claude-opus-4.5, opus-4.5 + const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/) + if (dotFormatMatch) { + const majorVersion = parseInt(dotFormatMatch[1], 10) + const minorVersion = parseInt(dotFormatMatch[2], 10) + + // Same version logic as old format + // opus-5.0, opus-6.0 → true + // opus-4.5, opus-4.6 → true + // opus-4.0, opus-4.4 → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // New format 2: opus-{major}[-{minor}][-date] (hyphen-separated) + // e.g., claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805 + // If opus-{major} is followed by 8-digit date, there's no minor version + + // Extract content after 'opus' + const opusIndex = lowerModel.indexOf('opus') + const afterOpus = lowerModel.substring(opusIndex + 4) + + // Match: -{major}-{minor}-{date} or -{major}-{date} or -{major} + // IMPORTANT: Minor version regex is (\d{1,2}) not (\d+) + // This prevents matching 8-digit dates as minor version + // Example: opus-4-20250514 → major=4, minor=undefined (not 20250514) + // Example: opus-4-5-20251101 → major=4, minor=5 + // Future-proof: Supports up to 2-digit minor versions (0-99) + const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d{1,2})(?=[- ]\d{8}|$))?/) + + if (versionMatch) { + const majorVersion = parseInt(versionMatch[1], 10) + const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 + + // Same version logic: >= 4.5 returns true + // opus-5-0-date, opus-6-date → true + // opus-4-5-date, opus-4-10-date → true (supports 2-digit minor) + // opus-4-date (no minor, treated as 4.0) → false + // opus-4-1-date, opus-4-4-date → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // Other cases containing 'opus' but cannot parse version, assume legacy + return false +} + module.exports = { parseVendorPrefixedModel, hasVendorPrefix, getEffectiveModel, - getVendorType + getVendorType, + isOpus45OrNewer } diff --git a/src/utils/unstableUpstreamHelper.js b/src/utils/unstableUpstreamHelper.js new file mode 100644 index 00000000..6fa58aca --- /dev/null +++ b/src/utils/unstableUpstreamHelper.js @@ -0,0 +1,81 @@ +const logger = require('./logger') + +function parseList(envValue) { + if (!envValue) { + return [] + } + return envValue + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) +} + +const unstableTypes = new Set(parseList(process.env.UNSTABLE_ERROR_TYPES)) +const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS) +const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522]) + +function normalizeErrorPayload(payload) { + if (!payload) { + return {} + } + + if (typeof payload === 'string') { + try { + return normalizeErrorPayload(JSON.parse(payload)) + } catch (e) { + return { message: payload } + } + } + + if (payload.error && typeof payload.error === 'object') { + return { + type: payload.error.type || payload.error.error || payload.error.code, + code: payload.error.code || payload.error.error || payload.error.type, + message: payload.error.message || payload.error.msg || payload.message || payload.error.error + } + } + + return { + type: payload.type || payload.code, + code: payload.code || payload.type, + message: payload.message || '' + } +} + +function isUnstableUpstreamError(statusCode, payload) { + const normalizedStatus = Number(statusCode) + if (Number.isFinite(normalizedStatus) && normalizedStatus >= 500) { + return true + } + if (Number.isFinite(normalizedStatus) && unstableStatusCodes.has(normalizedStatus)) { + return true + } + + const { type, code, message } = normalizeErrorPayload(payload) + const lowerType = (type || '').toString().toLowerCase() + const lowerCode = (code || '').toString().toLowerCase() + const lowerMessage = (message || '').toString().toLowerCase() + + if (lowerType === 'server_error' || lowerCode === 'server_error') { + return true + } + if (unstableTypes.has(lowerType) || unstableTypes.has(lowerCode)) { + return true + } + if (unstableKeywords.length > 0) { + return unstableKeywords.some((kw) => lowerMessage.includes(kw)) + } + + return false +} + +function logUnstable(accountLabel, statusCode) { + logger.warn( + `Detected unstable upstream error (${statusCode}) for account ${accountLabel}, marking temporarily unavailable` + ) +} + +module.exports = { + isUnstableUpstreamError, + logUnstable +} diff --git a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue index f9b6083c..09da703b 100644 --- a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue +++ b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue @@ -44,12 +44,20 @@

- +
+ + +
@@ -325,6 +333,7 @@ + + diff --git a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue index 905f1581..846263f2 100644 --- a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue +++ b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue @@ -231,6 +231,9 @@
+ @@ -256,7 +259,7 @@ const props = defineProps({ } }) -const emit = defineEmits(['close']) +const emit = defineEmits(['close', 'open-timeline']) // 计算属性 const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0) @@ -320,6 +323,10 @@ const formatTokenCount = (count) => { const close = () => { emit('close') } + +const openTimeline = () => { + emit('open-timeline', props.apiKey?.id) +}