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
-
+
+
+
+
@@ -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)
+}