mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 17:39:16 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95870883a1 | ||
|
|
aa71c58400 | ||
|
|
698f3d7daa | ||
|
|
5af5e55d80 | ||
|
|
5a18f54abd | ||
|
|
6d3b51510a | ||
|
|
c79fdc4d71 | ||
|
|
659072075d | ||
|
|
cf93128a96 | ||
|
|
909b5ad37f | ||
|
|
bab7073822 | ||
|
|
0035f8cb4f | ||
|
|
d49cc0cec8 | ||
|
|
c4d6ab97f2 | ||
|
|
7053d5f1ac | ||
|
|
24796fc889 | ||
|
|
201d95c84e | ||
|
|
b978d864e3 | ||
|
|
175c041e5a | ||
|
|
b441506199 | ||
|
|
eb2341fb16 | ||
|
|
e89e2964e7 | ||
|
|
b3e27e9f15 | ||
|
|
d0b397b45a | ||
|
|
195e42e0a5 | ||
|
|
ebecee4c6f | ||
|
|
0607322cc7 | ||
|
|
0828746281 | ||
|
|
e1df90684a | ||
|
|
f74f77ef65 | ||
|
|
b63c3217bc | ||
|
|
d81a16b98d | ||
|
|
30727be92f | ||
|
|
b8a6cc627a | ||
|
|
01c63bf5df | ||
|
|
4317962955 | ||
|
|
b66fd7f655 | ||
|
|
ac280ef563 | ||
|
|
c70070d912 | ||
|
|
849d8e047b | ||
|
|
065aa6d35e | ||
|
|
10a1d61427 | ||
|
|
cfdcc97cc7 | ||
|
|
ea053c6a16 | ||
|
|
84a8fdeaba | ||
|
|
c1c941aa4c | ||
|
|
f78e376dea | ||
|
|
530d38e4a4 | ||
|
|
0bf7bfae04 | ||
|
|
fbb660138c | ||
|
|
9c970fda3b | ||
|
|
bfa3f528a2 | ||
|
|
9b0d0bee96 | ||
|
|
ff30bfab82 | ||
|
|
93497cc13c | ||
|
|
2429bad2b7 | ||
|
|
a03753030c | ||
|
|
6ab91c0c75 | ||
|
|
675e7b9111 | ||
|
|
f82db11e7d | ||
|
|
06b18b7186 | ||
|
|
12cb841a64 | ||
|
|
dc868522cf | ||
|
|
b1dc27b5d7 | ||
|
|
b94bd2b822 | ||
|
|
827c0f6207 | ||
|
|
0b3cf5112b | ||
|
|
3db268fff7 | ||
|
|
81971436e6 | ||
|
|
69a1006f4c | ||
|
|
4cf1762467 | ||
|
|
0d64d40654 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@ redis_data/
|
|||||||
|
|
||||||
# Logs directory
|
# Logs directory
|
||||||
logs/
|
logs/
|
||||||
|
logs1/
|
||||||
*.log
|
*.log
|
||||||
startup.log
|
startup.log
|
||||||
app.log
|
app.log
|
||||||
|
|||||||
29
Dockerfile
29
Dockerfile
@@ -1,4 +1,17 @@
|
|||||||
# 🎯 前端构建阶段
|
# 🎯 后端依赖阶段 (与前端构建并行)
|
||||||
|
FROM node:18-alpine AS backend-deps
|
||||||
|
|
||||||
|
# 📁 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 📦 复制 package 文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 🔽 安装依赖 (生产环境) - 使用 BuildKit 缓存加速
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --only=production
|
||||||
|
|
||||||
|
# 🎯 前端构建阶段 (与后端依赖并行)
|
||||||
FROM node:18-alpine AS frontend-builder
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
# 📁 设置工作目录
|
# 📁 设置工作目录
|
||||||
@@ -7,8 +20,9 @@ WORKDIR /app/web/admin-spa
|
|||||||
# 📦 复制前端依赖文件
|
# 📦 复制前端依赖文件
|
||||||
COPY web/admin-spa/package*.json ./
|
COPY web/admin-spa/package*.json ./
|
||||||
|
|
||||||
# 🔽 安装前端依赖
|
# 🔽 安装前端依赖 - 使用 BuildKit 缓存加速
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci
|
||||||
|
|
||||||
# 📋 复制前端源代码
|
# 📋 复制前端源代码
|
||||||
COPY web/admin-spa/ ./
|
COPY web/admin-spa/ ./
|
||||||
@@ -34,17 +48,16 @@ RUN apk add --no-cache \
|
|||||||
# 📁 设置工作目录
|
# 📁 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 📦 复制 package 文件
|
# 📦 复制 package 文件 (用于版本信息等)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# 🔽 安装依赖 (生产环境)
|
# 📦 从后端依赖阶段复制 node_modules (已预装好)
|
||||||
RUN npm ci --only=production && \
|
COPY --from=backend-deps /app/node_modules ./node_modules
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
# 📋 复制应用代码
|
# 📋 复制应用代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 📦 从构建阶段复制前端产物
|
# 📦 从前端构建阶段复制前端产物
|
||||||
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
||||||
|
|
||||||
# 🔧 复制并设置启动脚本权限
|
# 🔧 复制并设置启动脚本权限
|
||||||
|
|||||||
80
package-lock.json
generated
80
package-lock.json
generated
@@ -44,6 +44,7 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7605,6 +7606,85 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/pretty-format": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
|
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
108
scripts/test-official-models.js
Normal file
108
scripts/test-official-models.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ const logger = require('../utils/logger')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
||||||
const ClientValidator = require('../validators/clientValidator')
|
const ClientValidator = require('../validators/clientValidator')
|
||||||
|
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
|
|
||||||
const FALLBACK_CONCURRENCY_CONFIG = {
|
const FALLBACK_CONCURRENCY_CONFIG = {
|
||||||
leaseSeconds: 300,
|
leaseSeconds: 300,
|
||||||
@@ -201,6 +203,53 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑)
|
||||||
|
// 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages)
|
||||||
|
if (!skipKeyRestrictions) {
|
||||||
|
const normalizedPath = (req.originalUrl || req.path || '').toLowerCase()
|
||||||
|
const isClaudeMessagesEndpoint =
|
||||||
|
normalizedPath.includes('/v1/messages') &&
|
||||||
|
(normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude'))
|
||||||
|
|
||||||
|
if (isClaudeMessagesEndpoint) {
|
||||||
|
try {
|
||||||
|
const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled()
|
||||||
|
|
||||||
|
// API Key 级别的 Claude Code 限制
|
||||||
|
const keyClaudeCodeOnly =
|
||||||
|
validation.keyData.enableClientRestriction &&
|
||||||
|
Array.isArray(validation.keyData.allowedClients) &&
|
||||||
|
validation.keyData.allowedClients.length === 1 &&
|
||||||
|
validation.keyData.allowedClients.includes('claude_code')
|
||||||
|
|
||||||
|
// OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code
|
||||||
|
if (globalClaudeCodeOnly || keyClaudeCodeOnly) {
|
||||||
|
const isClaudeCode = ClaudeCodeValidator.validate(req)
|
||||||
|
|
||||||
|
if (!isClaudeCode) {
|
||||||
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
|
logger.api(
|
||||||
|
`❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'client_validation_error',
|
||||||
|
message: 'This endpoint only accepts requests from Claude Code CLI'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error checking Claude Code restriction:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查并发限制
|
// 检查并发限制
|
||||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
||||||
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
||||||
@@ -226,8 +275,18 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (currentConcurrency > concurrencyLimit) {
|
if (currentConcurrency > concurrencyLimit) {
|
||||||
// 如果超过限制,立即减少计数
|
// 如果超过限制,立即减少计数(添加 try-catch 防止异常导致并发泄漏)
|
||||||
await redis.decrConcurrency(validation.keyData.id, requestId)
|
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(
|
logger.security(
|
||||||
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
||||||
validation.keyData.name
|
validation.keyData.name
|
||||||
@@ -249,7 +308,38 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
let leaseRenewInterval = null
|
let leaseRenewInterval = null
|
||||||
|
|
||||||
if (renewIntervalMs > 0) {
|
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(() => {
|
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
|
redis
|
||||||
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
|
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ class RedisClient {
|
|||||||
isActive = '',
|
isActive = '',
|
||||||
sortBy = 'createdAt',
|
sortBy = 'createdAt',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
excludeDeleted = true // 默认排除已删除的 API Keys
|
excludeDeleted = true, // 默认排除已删除的 API Keys
|
||||||
|
modelFilter = []
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞)
|
// 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. 排序
|
// 4. 排序
|
||||||
filteredKeys.sort((a, b) => {
|
filteredKeys.sort((a, b) => {
|
||||||
// status 排序实际上使用 isActive 字段(API Key 没有 status 字段)
|
// status 排序实际上使用 isActive 字段(API Key 没有 status 字段)
|
||||||
@@ -781,6 +791,58 @@ class RedisClient {
|
|||||||
await Promise.all(operations)
|
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) {
|
async getUsageStats(keyId) {
|
||||||
const totalKey = `usage:${keyId}`
|
const totalKey = `usage:${keyId}`
|
||||||
const today = getDateStringInTimezone()
|
const today = getDateStringInTimezone()
|
||||||
@@ -2034,6 +2096,246 @@ class RedisClient {
|
|||||||
return await this.getConcurrency(compositeKey)
|
return await this.getConcurrency(compositeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 并发管理方法(用于管理员手动清理)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有并发状态
|
||||||
|
* @returns {Promise<Array>} 并发状态列表
|
||||||
|
*/
|
||||||
|
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<Object>} 并发状态详情
|
||||||
|
*/
|
||||||
|
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<Object>} 清理结果
|
||||||
|
*/
|
||||||
|
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<Object>} 清理结果
|
||||||
|
*/
|
||||||
|
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<Object>} 清理结果
|
||||||
|
*/
|
||||||
|
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
|
// 🔧 Basic Redis operations wrapper methods for convenience
|
||||||
async get(key) {
|
async get(key) {
|
||||||
const client = this.getClientSafe()
|
const client = this.getClientSafe()
|
||||||
|
|||||||
@@ -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
|
// 获取所有API Keys
|
||||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -116,6 +127,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
// 筛选参数
|
// 筛选参数
|
||||||
tag = '',
|
tag = '',
|
||||||
isActive = '',
|
isActive = '',
|
||||||
|
models = '', // 模型筛选(逗号分隔)
|
||||||
// 排序参数
|
// 排序参数
|
||||||
sortBy = 'createdAt',
|
sortBy = 'createdAt',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
@@ -127,6 +139,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
timeRange = 'all'
|
timeRange = 'all'
|
||||||
} = req.query
|
} = req.query
|
||||||
|
|
||||||
|
// 解析模型筛选参数
|
||||||
|
const modelFilter = models ? models.split(',').filter((m) => m.trim()) : []
|
||||||
|
|
||||||
// 验证分页参数
|
// 验证分页参数
|
||||||
const pageNum = Math.max(1, parseInt(page) || 1)
|
const pageNum = Math.max(1, parseInt(page) || 1)
|
||||||
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
|
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,
|
search,
|
||||||
searchMode,
|
searchMode,
|
||||||
tag,
|
tag,
|
||||||
isActive
|
isActive,
|
||||||
|
modelFilter
|
||||||
})
|
})
|
||||||
|
|
||||||
costSortStatus = {
|
costSortStatus = {
|
||||||
@@ -250,7 +266,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
search,
|
search,
|
||||||
searchMode,
|
searchMode,
|
||||||
tag,
|
tag,
|
||||||
isActive
|
isActive,
|
||||||
|
modelFilter
|
||||||
})
|
})
|
||||||
|
|
||||||
costSortStatus.isRealTimeCalculation = false
|
costSortStatus.isRealTimeCalculation = false
|
||||||
@@ -265,7 +282,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
tag,
|
tag,
|
||||||
isActive,
|
isActive,
|
||||||
sortBy: validSortBy,
|
sortBy: validSortBy,
|
||||||
sortOrder: validSortOrder
|
sortOrder: validSortOrder,
|
||||||
|
modelFilter
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +340,17 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
* 使用预计算索引进行费用排序的分页查询
|
* 使用预计算索引进行费用排序的分页查询
|
||||||
*/
|
*/
|
||||||
async function getApiKeysSortedByCostPrecomputed(options) {
|
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')
|
const costRankService = require('../../services/costRankService')
|
||||||
|
|
||||||
// 1. 获取排序后的全量 keyId 列表
|
// 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. 收集所有可用标签
|
// 5. 收集所有可用标签
|
||||||
const allTags = new Set()
|
const allTags = new Set()
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
@@ -411,8 +448,18 @@ async function getApiKeysSortedByCostPrecomputed(options) {
|
|||||||
* 使用实时计算进行 custom 时间范围的费用排序
|
* 使用实时计算进行 custom 时间范围的费用排序
|
||||||
*/
|
*/
|
||||||
async function getApiKeysSortedByCostCustom(options) {
|
async function getApiKeysSortedByCostCustom(options) {
|
||||||
const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } =
|
const {
|
||||||
options
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortOrder,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
search,
|
||||||
|
searchMode,
|
||||||
|
tag,
|
||||||
|
isActive,
|
||||||
|
modelFilter = []
|
||||||
|
} = options
|
||||||
const costRankService = require('../../services/costRankService')
|
const costRankService = require('../../services/costRankService')
|
||||||
|
|
||||||
// 1. 实时计算所有 Keys 的费用
|
// 1. 实时计算所有 Keys 的费用
|
||||||
@@ -427,9 +474,9 @@ async function getApiKeysSortedByCostCustom(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 转换为数组并排序
|
// 2. 转换为数组并排序
|
||||||
const sortedEntries = [...costs.entries()].sort((a, b) => {
|
const sortedEntries = [...costs.entries()].sort((a, b) =>
|
||||||
return sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
|
sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
|
||||||
})
|
)
|
||||||
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
|
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
|
||||||
|
|
||||||
// 3. 批量获取 API Key 基础数据
|
// 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. 收集所有可用标签
|
// 6. 收集所有可用标签
|
||||||
const allTags = new Set()
|
const allTags = new Set()
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
@@ -863,6 +919,62 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
// 去重(避免日数据和月数据重复计算)
|
// 去重(避免日数据和月数据重复计算)
|
||||||
const uniqueKeys = [...new Set(allKeys)]
|
const uniqueKeys = [...new Set(allKeys)]
|
||||||
|
|
||||||
|
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
|
||||||
|
let dailyCost = 0
|
||||||
|
let currentWindowCost = 0
|
||||||
|
let windowRemainingSeconds = null
|
||||||
|
let windowStartTime = null
|
||||||
|
let windowEndTime = null
|
||||||
|
let allTimeCost = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先获取 API Key 配置,判断是否需要查询限制相关数据
|
||||||
|
const apiKey = await redis.getApiKey(keyId)
|
||||||
|
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||||||
|
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
|
||||||
|
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
|
||||||
|
|
||||||
|
// 只在启用了每日费用限制时查询
|
||||||
|
if (dailyCostLimit > 0) {
|
||||||
|
dailyCost = await redis.getDailyCost(keyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在启用了总费用限制时查询
|
||||||
|
if (totalCostLimit > 0) {
|
||||||
|
const totalCostKey = `usage:cost:total:${keyId}`
|
||||||
|
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在启用了窗口限制时查询窗口数据
|
||||||
|
if (rateLimitWindow > 0) {
|
||||||
|
const costCountKey = `rate_limit:cost:${keyId}`
|
||||||
|
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||||
|
|
||||||
|
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
||||||
|
|
||||||
|
// 获取窗口开始时间和计算剩余时间
|
||||||
|
const windowStart = await client.get(windowStartKey)
|
||||||
|
if (windowStart) {
|
||||||
|
const now = Date.now()
|
||||||
|
windowStartTime = parseInt(windowStart)
|
||||||
|
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||||
|
windowEndTime = windowStartTime + windowDuration
|
||||||
|
|
||||||
|
// 如果窗口还有效
|
||||||
|
if (now < windowEndTime) {
|
||||||
|
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||||
|
} else {
|
||||||
|
// 窗口已过期
|
||||||
|
windowRemainingSeconds = 0
|
||||||
|
currentWindowCost = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有使用数据,返回零值但包含窗口数据
|
||||||
if (uniqueKeys.length === 0) {
|
if (uniqueKeys.length === 0) {
|
||||||
return {
|
return {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
@@ -872,7 +984,14 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
formattedCost: '$0.00'
|
formattedCost: '$0.00',
|
||||||
|
// 实时限制数据(始终返回,不受时间范围影响)
|
||||||
|
dailyCost,
|
||||||
|
currentWindowCost,
|
||||||
|
windowRemainingSeconds,
|
||||||
|
windowStartTime,
|
||||||
|
windowEndTime,
|
||||||
|
allTimeCost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,52 +1092,6 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
|
|
||||||
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
|
||||||
// 获取实时限制数据
|
|
||||||
let dailyCost = 0
|
|
||||||
let currentWindowCost = 0
|
|
||||||
let windowRemainingSeconds = null
|
|
||||||
let windowStartTime = null
|
|
||||||
let windowEndTime = null
|
|
||||||
let allTimeCost = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取当日费用
|
|
||||||
dailyCost = await redis.getDailyCost(keyId)
|
|
||||||
|
|
||||||
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
|
|
||||||
const totalCostKey = `usage:cost:total:${keyId}`
|
|
||||||
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
|
||||||
|
|
||||||
// 获取 API Key 配置信息以判断是否需要窗口数据
|
|
||||||
const apiKey = await redis.getApiKey(keyId)
|
|
||||||
if (apiKey && apiKey.rateLimitWindow > 0) {
|
|
||||||
const costCountKey = `rate_limit:cost:${keyId}`
|
|
||||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
|
||||||
|
|
||||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
|
||||||
|
|
||||||
// 获取窗口开始时间和计算剩余时间
|
|
||||||
const windowStart = await client.get(windowStartKey)
|
|
||||||
if (windowStart) {
|
|
||||||
const now = Date.now()
|
|
||||||
windowStartTime = parseInt(windowStart)
|
|
||||||
const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒
|
|
||||||
windowEndTime = windowStartTime + windowDuration
|
|
||||||
|
|
||||||
// 如果窗口还有效
|
|
||||||
if (now < windowEndTime) {
|
|
||||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
|
||||||
} else {
|
|
||||||
// 窗口已过期
|
|
||||||
windowRemainingSeconds = 0
|
|
||||||
currentWindowCost = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests: totalRequests,
|
requests: totalRequests,
|
||||||
tokens,
|
tokens,
|
||||||
|
|||||||
130
src/routes/admin/claudeRelayConfig.js
Normal file
130
src/routes/admin/claudeRelayConfig.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置 API 路由
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config
|
||||||
|
* 获取 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = await claudeRelayConfigService.getConfig()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
config
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /admin/claude-relay-config
|
||||||
|
* 更新 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled,
|
||||||
|
sessionBindingErrorMessage,
|
||||||
|
sessionBindingTtlDays
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
// 验证输入
|
||||||
|
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
globalSessionBindingEnabled !== undefined &&
|
||||||
|
typeof globalSessionBindingEnabled !== 'boolean'
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingErrorMessage !== undefined) {
|
||||||
|
if (typeof sessionBindingErrorMessage !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
|
||||||
|
}
|
||||||
|
if (sessionBindingErrorMessage.length > 500) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingTtlDays !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof sessionBindingTtlDays !== 'number' ||
|
||||||
|
sessionBindingTtlDays < 1 ||
|
||||||
|
sessionBindingTtlDays > 365
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {}
|
||||||
|
if (claudeCodeOnlyEnabled !== undefined)
|
||||||
|
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
|
||||||
|
if (globalSessionBindingEnabled !== undefined)
|
||||||
|
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
|
||||||
|
if (sessionBindingErrorMessage !== undefined)
|
||||||
|
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
|
||||||
|
if (sessionBindingTtlDays !== undefined)
|
||||||
|
updateData.sessionBindingTtlDays = sessionBindingTtlDays
|
||||||
|
|
||||||
|
const updatedConfig = await claudeRelayConfigService.updateConfig(
|
||||||
|
updateData,
|
||||||
|
req.admin?.username || 'unknown'
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Configuration updated successfully',
|
||||||
|
config: updatedConfig
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config/session-bindings
|
||||||
|
* 获取会话绑定统计
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await claudeRelayConfigService.getSessionBindingStats()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get session binding statistics',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
145
src/routes/admin/concurrency.js
Normal file
145
src/routes/admin/concurrency.js
Normal file
@@ -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
|
||||||
@@ -22,6 +22,8 @@ const droidAccountsRoutes = require('./droidAccounts')
|
|||||||
const dashboardRoutes = require('./dashboard')
|
const dashboardRoutes = require('./dashboard')
|
||||||
const usageStatsRoutes = require('./usageStats')
|
const usageStatsRoutes = require('./usageStats')
|
||||||
const systemRoutes = require('./system')
|
const systemRoutes = require('./system')
|
||||||
|
const concurrencyRoutes = require('./concurrency')
|
||||||
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -35,6 +37,8 @@ router.use('/', droidAccountsRoutes)
|
|||||||
router.use('/', dashboardRoutes)
|
router.use('/', dashboardRoutes)
|
||||||
router.use('/', usageStatsRoutes)
|
router.use('/', usageStatsRoutes)
|
||||||
router.use('/', systemRoutes)
|
router.use('/', systemRoutes)
|
||||||
|
router.use('/', concurrencyRoutes)
|
||||||
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
|
|
||||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
router.use('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
@@ -16,6 +16,65 @@ const pricingService = require('../../services/pricingService')
|
|||||||
|
|
||||||
const router = express.Router()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 账户使用统计
|
// 📊 账户使用统计
|
||||||
|
|
||||||
// 获取所有账户的使用统计
|
// 获取所有账户的使用统计
|
||||||
@@ -1861,18 +1920,6 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
|||||||
|
|
||||||
const rawRecords = await redis.getUsageRecords(keyId, 5000)
|
const rawRecords = await redis.getUsageRecords(keyId, 5000)
|
||||||
|
|
||||||
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 accountServices = [
|
const accountServices = [
|
||||||
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
|
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
|
||||||
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
|
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
|
||||||
@@ -1895,10 +1942,15 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
|||||||
return accountCache.get(cacheKey)
|
return accountCache.get(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const servicesToTry = type
|
let servicesToTry = type
|
||||||
? accountServices.filter((svc) => svc.type === type)
|
? accountServices.filter((svc) => svc.type === type)
|
||||||
: accountServices
|
: accountServices
|
||||||
|
|
||||||
|
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
|
||||||
|
if (!servicesToTry.length) {
|
||||||
|
servicesToTry = accountServices
|
||||||
|
}
|
||||||
|
|
||||||
for (const service of servicesToTry) {
|
for (const service of servicesToTry) {
|
||||||
try {
|
try {
|
||||||
const account = await service.getter(id)
|
const account = await service.getter(id)
|
||||||
@@ -2008,12 +2060,14 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (record.accountId) {
|
if (record.accountId) {
|
||||||
const key = `${record.accountId}:${record.accountType || 'unknown'}`
|
const normalizedType = record.accountType || 'unknown'
|
||||||
if (!accountOptionMap.has(key)) {
|
if (!accountOptionMap.has(record.accountId)) {
|
||||||
accountOptionMap.set(key, {
|
accountOptionMap.set(record.accountId, {
|
||||||
id: record.accountId,
|
id: record.accountId,
|
||||||
accountType: record.accountType || 'unknown'
|
accountTypes: new Set([normalizedType])
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2087,13 +2141,33 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
|||||||
|
|
||||||
const accountOptions = []
|
const accountOptions = []
|
||||||
for (const option of accountOptionMap.values()) {
|
for (const option of accountOptionMap.values()) {
|
||||||
const info = await resolveAccountInfo(option.id, option.accountType)
|
const types = Array.from(option.accountTypes || [])
|
||||||
const resolvedType = info?.type || option.accountType || 'unknown'
|
|
||||||
|
// 优先按历史出现的 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({
|
accountOptions.push({
|
||||||
id: option.id,
|
id: option.id,
|
||||||
name: info?.name || option.id,
|
name: resolvedInfo?.name || option.id,
|
||||||
accountType: resolvedType,
|
accountType: chosenType,
|
||||||
accountTypeName: accountTypeNames[resolvedType] || '未知渠道'
|
accountTypeName: chosenTypeName,
|
||||||
|
rawTypes: types
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2146,4 +2220,287 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取账户的请求记录时间线
|
||||||
|
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
|
module.exports = router
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const logger = require('../utils/logger')
|
|||||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -37,6 +38,73 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为旧会话(污染的会话)
|
||||||
|
* Claude Code 发送的请求特点:
|
||||||
|
* - messages 数组通常只有 1 个元素
|
||||||
|
* - 历史对话记录嵌套在单个 message 的 content 数组中
|
||||||
|
* - content 数组中包含 <system-reminder> 开头的系统注入内容
|
||||||
|
*
|
||||||
|
* 污染会话的特征:
|
||||||
|
* 1. messages.length > 1
|
||||||
|
* 2. messages.length === 1 但 content 中有多个用户输入
|
||||||
|
* 3. "warmup" 请求:单条简单消息 + 无 tools(真正新会话会带 tools)
|
||||||
|
*
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @returns {boolean} 是否为旧会话
|
||||||
|
*/
|
||||||
|
function isOldSession(body) {
|
||||||
|
const messages = body?.messages
|
||||||
|
const tools = body?.tools
|
||||||
|
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 多条消息 = 旧会话
|
||||||
|
if (messages.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 单条消息,分析 content
|
||||||
|
const firstMessage = messages[0]
|
||||||
|
const content = firstMessage?.content
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是字符串,只有一条输入,需要检查 tools
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
// 有 tools = 正常新会话,无 tools = 可疑
|
||||||
|
return !tools || tools.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是数组,统计非 system-reminder 的元素
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const userInputs = content.filter((item) => {
|
||||||
|
if (item.type !== 'text') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const text = item.text || ''
|
||||||
|
// 剔除以 <system-reminder> 开头的
|
||||||
|
return !text.trimStart().startsWith('<system-reminder>')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 多个用户输入 = 旧会话
|
||||||
|
if (userInputs.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup 检测:单个消息 + 无 tools = 旧会话
|
||||||
|
if (userInputs.length === 1 && (!tools || tools.length === 0)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 共享的消息处理函数
|
// 🔧 共享的消息处理函数
|
||||||
async function handleMessagesRequest(req, res) {
|
async function handleMessagesRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -141,6 +209,56 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证
|
||||||
|
let forcedAccount = null
|
||||||
|
let needSessionBinding = false
|
||||||
|
let originalSessionIdForBinding = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已有绑定,使用绑定的账户
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccount = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记需要在调度成功后建立绑定
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBinding = true
|
||||||
|
originalSessionIdForBinding = originalSessionId
|
||||||
|
logger.api(`📝 New session detected, will create binding: ${originalSessionId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -149,10 +267,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccount
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 处理会话绑定账户不可用的错误
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -170,6 +299,40 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBinding &&
|
||||||
|
originalSessionIdForBinding &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBinding,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding:`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务并调用
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
@@ -503,6 +666,55 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证(非流式)
|
||||||
|
let forcedAccountNonStream = null
|
||||||
|
let needSessionBindingNonStream = false
|
||||||
|
let originalSessionIdForBindingNonStream = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccountNonStream = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBindingNonStream = true
|
||||||
|
originalSessionIdForBindingNonStream = originalSessionId
|
||||||
|
logger.api(
|
||||||
|
`📝 New session detected (non-stream), will create binding: ${originalSessionId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check (non-stream):', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -511,10 +723,20 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccountNonStream
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -527,6 +749,40 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBindingNonStream &&
|
||||||
|
originalSessionIdForBindingNonStream &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBindingNonStream,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务
|
// 根据账号类型选择对应的转发服务
|
||||||
let response
|
let response
|
||||||
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
||||||
@@ -824,7 +1080,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
// 可选:根据 API Key 的模型限制过滤
|
// 可选:根据 API Key 的模型限制过滤
|
||||||
let filteredModels = models
|
let filteredModels = models
|
||||||
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
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({
|
res.json({
|
||||||
@@ -965,6 +1222,41 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!sessionValidation.valid) {
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: sessionValidation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查
|
||||||
|
if (sessionValidation.isNewSession && originalSessionId) {
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||||
|
)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
||||||
|
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
|||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const pricingService = require('../services/pricingService')
|
const pricingService = require('../services/pricingService')
|
||||||
|
const { getEffectiveModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
// 🔧 辅助函数:检查 API Key 权限
|
// 🔧 辅助函数:检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||||
@@ -75,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// 如果启用了模型限制,过滤模型列表
|
// 如果启用了模型限制,视为黑名单:过滤掉受限模型
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
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({
|
res.json({
|
||||||
@@ -114,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查模型限制
|
// 模型限制(黑名单):命中则直接拒绝
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
if (apiKeyData.restrictedModels.includes(modelId)) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Model '${modelId}' not found`,
|
message: `Model '${modelId}' not found`,
|
||||||
@@ -199,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
// 转换 OpenAI 请求为 Claude 格式
|
// 转换 OpenAI 请求为 Claude 格式
|
||||||
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
||||||
|
|
||||||
// 检查模型限制
|
// 模型限制(黑名单):命中受限模型则拒绝
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
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({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: `Model ${req.body.model} is not allowed for this API key`,
|
message: `Model ${req.body.model} is not allowed for this API key`,
|
||||||
|
|||||||
@@ -247,9 +247,11 @@ const handleResponses = async (req, res) => {
|
|||||||
|
|
||||||
// 从请求体中提取模型和流式标志
|
// 从请求体中提取模型和流式标志
|
||||||
let requestedModel = req.body?.model || null
|
let requestedModel = req.body?.model || null
|
||||||
|
const isCodexModel =
|
||||||
|
typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex')
|
||||||
|
|
||||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5
|
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),并且不是 Codex 系列,则覆盖为 gpt-5
|
||||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') {
|
if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) {
|
||||||
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
|
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
|
||||||
requestedModel = 'gpt-5'
|
requestedModel = 'gpt-5'
|
||||||
req.body.model = 'gpt-5' // 同时更新请求体中的模型
|
req.body.model = 'gpt-5' // 同时更新请求体中的模型
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ const {
|
|||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
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 {
|
class ClaudeAccountService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -852,31 +868,39 @@ class ClaudeAccountService {
|
|||||||
!this.isSubscriptionExpired(account)
|
!this.isSubscriptionExpired(account)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
// Filter Opus models based on account type and model version
|
||||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||||
|
const isNewOpus = isOpus45OrNewer(modelName)
|
||||||
|
|
||||||
activeAccounts = activeAccounts.filter((account) => {
|
activeAccounts = activeAccounts.filter((account) => {
|
||||||
// 检查账号的订阅信息
|
|
||||||
if (account.subscriptionInfo) {
|
if (account.subscriptionInfo) {
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(account.subscriptionInfo)
|
const info = JSON.parse(account.subscriptionInfo)
|
||||||
// Pro 和 Free 账号不支持 Opus
|
|
||||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
// Free account: does not support any Opus model
|
||||||
return false // Claude Pro 不支持 Opus
|
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) {
|
} catch (e) {
|
||||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
// Parse failed, assume legacy data (Max), default support
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
// Account without subscription info, default to supported (legacy data compatibility)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (activeAccounts.length === 0) {
|
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)
|
!this.isSubscriptionExpired(account)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
// Filter Opus models based on account type and model version
|
||||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||||
|
const isNewOpus = isOpus45OrNewer(modelName)
|
||||||
|
|
||||||
sharedAccounts = sharedAccounts.filter((account) => {
|
sharedAccounts = sharedAccounts.filter((account) => {
|
||||||
// 检查账号的订阅信息
|
|
||||||
if (account.subscriptionInfo) {
|
if (account.subscriptionInfo) {
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(account.subscriptionInfo)
|
const info = JSON.parse(account.subscriptionInfo)
|
||||||
// Pro 和 Free 账号不支持 Opus
|
|
||||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
// Free account: does not support any Opus model
|
||||||
return false // Claude Pro 不支持 Opus
|
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) {
|
} catch (e) {
|
||||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
// Parse failed, assume legacy data (Max), default support
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
// Account without subscription info, default to supported (legacy data compatibility)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (sharedAccounts.length === 0) {
|
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`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
438
src/services/claudeRelayConfigService.js
Normal file
438
src/services/claudeRelayConfigService.js
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置服务
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'claude_relay_config'
|
||||||
|
const SESSION_BINDING_PREFIX = 'original_session_binding:'
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
claudeCodeOnlyEnabled: false,
|
||||||
|
globalSessionBindingEnabled: false,
|
||||||
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存缓存(避免频繁 Redis 查询)
|
||||||
|
let configCache = null
|
||||||
|
let configCacheTime = 0
|
||||||
|
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
|
||||||
|
|
||||||
|
class ClaudeRelayConfigService {
|
||||||
|
/**
|
||||||
|
* 从 metadata.user_id 中提取原始 sessionId
|
||||||
|
* 格式: user_{64位十六进制}_account__session_{uuid}
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @returns {string|null} 原始 sessionId 或 null
|
||||||
|
*/
|
||||||
|
extractOriginalSessionId(requestBody) {
|
||||||
|
if (!requestBody?.metadata?.user_id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = requestBody.metadata.user_id
|
||||||
|
const match = userId.match(/session_([a-f0-9-]{36})$/i)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置(带缓存)
|
||||||
|
* @returns {Promise<Object>} 配置对象
|
||||||
|
*/
|
||||||
|
async getConfig() {
|
||||||
|
try {
|
||||||
|
// 检查缓存
|
||||||
|
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
|
||||||
|
return configCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
logger.warn('⚠️ Redis not connected, using default config')
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await client.get(CONFIG_KEY)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||||
|
} else {
|
||||||
|
configCache = { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
return configCache
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配置
|
||||||
|
* @param {Object} newConfig - 新配置
|
||||||
|
* @param {string} updatedBy - 更新者
|
||||||
|
* @returns {Promise<Object>} 更新后的配置
|
||||||
|
*/
|
||||||
|
async updateConfig(newConfig, updatedBy) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const currentConfig = await this.getConfig()
|
||||||
|
|
||||||
|
const updatedConfig = {
|
||||||
|
...currentConfig,
|
||||||
|
...newConfig,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
configCache = updatedConfig
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
|
||||||
|
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
|
||||||
|
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
return updatedConfig
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局 Claude Code 限制
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isClaudeCodeOnlyEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.claudeCodeOnlyEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局会话绑定
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isGlobalSessionBindingEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.globalSessionBindingEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定错误信息
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingErrorMessage() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object|null>} 绑定信息或 null
|
||||||
|
*/
|
||||||
|
async getOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
const data = await client.get(key)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型
|
||||||
|
* @returns {Promise<Object>} 绑定信息
|
||||||
|
*/
|
||||||
|
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
|
||||||
|
if (!originalSessionId || !accountId || !accountType) {
|
||||||
|
throw new Error('Invalid parameters for session binding')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
const binding = {
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return binding
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新会话绑定的最后使用时间(续期)
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async touchOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
if (!binding) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.lastUsedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查原始会话是否已绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isOriginalSessionBound(originalSessionId) {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
return binding !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证绑定的账户是否可用
|
||||||
|
* @param {Object} binding - 绑定信息
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async validateBoundAccount(binding) {
|
||||||
|
if (!binding || !binding.accountId || !binding.accountType) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accountType } = binding
|
||||||
|
const { accountId } = binding
|
||||||
|
|
||||||
|
let accountService
|
||||||
|
switch (accountType) {
|
||||||
|
case 'claude-official':
|
||||||
|
accountService = require('./claudeAccountService')
|
||||||
|
break
|
||||||
|
case 'claude-console':
|
||||||
|
accountService = require('./claudeConsoleAccountService')
|
||||||
|
break
|
||||||
|
case 'bedrock':
|
||||||
|
accountService = require('./bedrockAccountService')
|
||||||
|
break
|
||||||
|
case 'ccr':
|
||||||
|
accountService = require('./ccrAccountService')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await accountService.getAccount(accountId)
|
||||||
|
|
||||||
|
// getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountData = account
|
||||||
|
|
||||||
|
// 检查账户是否激活
|
||||||
|
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户状态(如果存在)
|
||||||
|
if (accountData.status && accountData.status === 'error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证新会话请求
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||||
|
*/
|
||||||
|
async validateNewSession(requestBody, originalSessionId) {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
|
||||||
|
if (!cfg.globalSessionBindingEnabled) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端)
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 如果会话已存在绑定
|
||||||
|
if (existingBinding) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (existingBinding.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
|
||||||
|
)
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountValid = await this.validateBoundAccount(existingBinding)
|
||||||
|
|
||||||
|
if (!accountValid) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: cfg.sessionBindingErrorMessage,
|
||||||
|
code: 'SESSION_BINDING_INVALID'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 续期
|
||||||
|
await this.touchOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 已有绑定,允许继续(这是正常的会话延续)
|
||||||
|
return { valid: true, binding: existingBinding }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有绑定,是新会话
|
||||||
|
// 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
|
||||||
|
// 绑定会在调度后创建,仅针对 claude-official 账户
|
||||||
|
return { valid: true, isNewSession: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async deleteOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
await client.del(key)
|
||||||
|
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定统计
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingStats() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = '0'
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
`${SESSION_BINDING_PREFIX}*`,
|
||||||
|
'COUNT',
|
||||||
|
100
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
count += keys.length
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBindings: count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除配置缓存(用于测试或强制刷新)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
configCache = null
|
||||||
|
configCacheTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ClaudeRelayConfigService()
|
||||||
@@ -18,7 +18,7 @@ const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
|||||||
|
|
||||||
class ClaudeRelayService {
|
class ClaudeRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.claudeApiUrl = config.claude.apiUrl
|
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
||||||
this.apiVersion = config.claude.apiVersion
|
this.apiVersion = config.claude.apiVersion
|
||||||
this.betaHeader = config.claude.betaHeader
|
this.betaHeader = config.claude.betaHeader
|
||||||
this.systemPrompt = config.claude.systemPrompt
|
this.systemPrompt = config.claude.systemPrompt
|
||||||
@@ -878,11 +878,102 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 🔧 过滤客户端请求头
|
// 🔧 过滤客户端请求头
|
||||||
_filterClientHeaders(clientHeaders) {
|
_filterClientHeaders(clientHeaders) {
|
||||||
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers
|
// 使用统一的 headerFilter 工具类
|
||||||
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
|
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
|
||||||
return filterForClaude(clientHeaders)
|
return filterForClaude(clientHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 准备请求头和 payload(抽离公共逻辑)
|
||||||
|
async _prepareRequestHeadersAndPayload(
|
||||||
|
body,
|
||||||
|
clientHeaders,
|
||||||
|
accountId,
|
||||||
|
accessToken,
|
||||||
|
options = {}
|
||||||
|
) {
|
||||||
|
const { account, accountType, sessionHash, requestOptions = {}, isStream = false } = options
|
||||||
|
|
||||||
|
// 获取统一的 User-Agent
|
||||||
|
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
||||||
|
|
||||||
|
// 获取过滤后的客户端 headers
|
||||||
|
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||||
|
|
||||||
|
// 判断是否是真实的 Claude Code 请求
|
||||||
|
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
||||||
|
|
||||||
|
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||||
|
let finalHeaders = { ...filteredHeaders }
|
||||||
|
let requestPayload = body
|
||||||
|
|
||||||
|
if (!isRealClaudeCode) {
|
||||||
|
// 获取该账号存储的 Claude Code headers
|
||||||
|
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||||
|
|
||||||
|
// 只添加客户端没有提供的 headers
|
||||||
|
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||||
|
const lowerKey = key.toLowerCase()
|
||||||
|
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
||||||
|
finalHeaders[key] = claudeCodeHeaders[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用请求身份转换
|
||||||
|
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
sessionHash,
|
||||||
|
clientHeaders,
|
||||||
|
requestOptions,
|
||||||
|
isStream
|
||||||
|
})
|
||||||
|
|
||||||
|
if (extensionResult.abortResponse) {
|
||||||
|
return { abortResponse: extensionResult.abortResponse }
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPayload = extensionResult.body
|
||||||
|
finalHeaders = extensionResult.headers
|
||||||
|
|
||||||
|
// 序列化请求体,计算 content-length
|
||||||
|
const bodyString = JSON.stringify(requestPayload)
|
||||||
|
const contentLength = Buffer.byteLength(bodyString, 'utf8')
|
||||||
|
|
||||||
|
// 构建最终请求头(包含认证、版本、User-Agent、Beta 等)
|
||||||
|
const headers = {
|
||||||
|
host: 'api.anthropic.com',
|
||||||
|
connection: 'keep-alive',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': String(contentLength),
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
'anthropic-version': this.apiVersion,
|
||||||
|
...finalHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||||
|
const userAgent = unifiedUA || headers['user-agent'] || 'claude-cli/1.0.119 (external, cli)'
|
||||||
|
const acceptHeader = headers['accept'] || 'application/json'
|
||||||
|
delete headers['user-agent']
|
||||||
|
delete headers['accept']
|
||||||
|
headers['User-Agent'] = userAgent
|
||||||
|
headers['Accept'] = acceptHeader
|
||||||
|
|
||||||
|
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||||
|
|
||||||
|
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||||
|
const modelId = requestPayload?.model || body?.model
|
||||||
|
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||||
|
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||||
|
return {
|
||||||
|
requestPayload,
|
||||||
|
bodyString,
|
||||||
|
headers,
|
||||||
|
isRealClaudeCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_applyRequestIdentityTransform(body, headers, context = {}) {
|
_applyRequestIdentityTransform(body, headers, context = {}) {
|
||||||
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
|
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
|
||||||
|
|
||||||
@@ -928,46 +1019,24 @@ class ClaudeRelayService {
|
|||||||
// 获取账户信息用于统一 User-Agent
|
// 获取账户信息用于统一 User-Agent
|
||||||
const account = await claudeAccountService.getAccount(accountId)
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
|
||||||
// 获取统一的 User-Agent
|
// 使用公共方法准备请求头和 payload
|
||||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
const prepared = await this._prepareRequestHeadersAndPayload(
|
||||||
|
body,
|
||||||
// 获取过滤后的客户端 headers
|
|
||||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
|
||||||
|
|
||||||
// 判断是否是真实的 Claude Code 请求
|
|
||||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
|
||||||
|
|
||||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
|
||||||
let finalHeaders = { ...filteredHeaders }
|
|
||||||
let requestPayload = body
|
|
||||||
|
|
||||||
if (!isRealClaudeCode) {
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
|
||||||
|
|
||||||
// 只添加客户端没有提供的 headers
|
|
||||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
|
||||||
const lowerKey = key.toLowerCase()
|
|
||||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
|
||||||
finalHeaders[key] = claudeCodeHeaders[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
|
||||||
account,
|
|
||||||
accountId,
|
|
||||||
clientHeaders,
|
clientHeaders,
|
||||||
|
accountId,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
account,
|
||||||
requestOptions,
|
requestOptions,
|
||||||
isStream: false
|
isStream: false
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (extensionResult.abortResponse) {
|
if (prepared.abortResponse) {
|
||||||
return extensionResult.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPayload = extensionResult.body
|
const { bodyString, headers } = prepared
|
||||||
finalHeaders = extensionResult.headers
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 支持自定义路径(如 count_tokens)
|
// 支持自定义路径(如 count_tokens)
|
||||||
@@ -981,30 +1050,14 @@ class ClaudeRelayService {
|
|||||||
const options = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: url.port || 443,
|
port: url.port || 443,
|
||||||
path: requestPath,
|
path: requestPath + (url.search || ''),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'anthropic-version': this.apiVersion,
|
|
||||||
...finalHeaders
|
|
||||||
},
|
|
||||||
agent: proxyAgent,
|
agent: proxyAgent,
|
||||||
timeout: config.requestTimeout || 600000
|
timeout: config.requestTimeout || 600000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
console.log(options.path)
|
||||||
if (!options.headers['user-agent'] || unifiedUA !== null) {
|
|
||||||
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
|
|
||||||
options.headers['user-agent'] = userAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
|
||||||
|
|
||||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
|
||||||
const modelId = requestPayload?.model || body?.model
|
|
||||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
|
||||||
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
let responseData = Buffer.alloc(0)
|
let responseData = Buffer.alloc(0)
|
||||||
@@ -1015,32 +1068,32 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
let bodyString = ''
|
let responseBody = ''
|
||||||
|
|
||||||
// 根据Content-Encoding处理响应数据
|
// 根据Content-Encoding处理响应数据
|
||||||
const contentEncoding = res.headers['content-encoding']
|
const contentEncoding = res.headers['content-encoding']
|
||||||
if (contentEncoding === 'gzip') {
|
if (contentEncoding === 'gzip') {
|
||||||
try {
|
try {
|
||||||
bodyString = zlib.gunzipSync(responseData).toString('utf8')
|
responseBody = zlib.gunzipSync(responseData).toString('utf8')
|
||||||
} catch (unzipError) {
|
} catch (unzipError) {
|
||||||
logger.error('❌ Failed to decompress gzip response:', unzipError)
|
logger.error('❌ Failed to decompress gzip response:', unzipError)
|
||||||
bodyString = responseData.toString('utf8')
|
responseBody = responseData.toString('utf8')
|
||||||
}
|
}
|
||||||
} else if (contentEncoding === 'deflate') {
|
} else if (contentEncoding === 'deflate') {
|
||||||
try {
|
try {
|
||||||
bodyString = zlib.inflateSync(responseData).toString('utf8')
|
responseBody = zlib.inflateSync(responseData).toString('utf8')
|
||||||
} catch (unzipError) {
|
} catch (unzipError) {
|
||||||
logger.error('❌ Failed to decompress deflate response:', unzipError)
|
logger.error('❌ Failed to decompress deflate response:', unzipError)
|
||||||
bodyString = responseData.toString('utf8')
|
responseBody = responseData.toString('utf8')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bodyString = responseData.toString('utf8')
|
responseBody = responseData.toString('utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
headers: res.headers,
|
headers: res.headers,
|
||||||
body: bodyString
|
body: responseBody
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`🔗 Claude API response: ${res.statusCode}`)
|
logger.debug(`🔗 Claude API response: ${res.statusCode}`)
|
||||||
@@ -1095,7 +1148,7 @@ class ClaudeRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(JSON.stringify(requestPayload))
|
req.write(bodyString)
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1248,79 +1301,39 @@ class ClaudeRelayService {
|
|||||||
const isOpusModelRequest =
|
const isOpusModelRequest =
|
||||||
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
|
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
|
||||||
|
|
||||||
// 获取统一的 User-Agent
|
// 使用公共方法准备请求头和 payload
|
||||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
const prepared = await this._prepareRequestHeadersAndPayload(
|
||||||
|
body,
|
||||||
// 获取过滤后的客户端 headers
|
clientHeaders,
|
||||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
|
||||||
|
|
||||||
// 判断是否是真实的 Claude Code 请求
|
|
||||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
|
||||||
|
|
||||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
|
||||||
let finalHeaders = { ...filteredHeaders }
|
|
||||||
let requestPayload = body
|
|
||||||
|
|
||||||
if (!isRealClaudeCode) {
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
|
||||||
|
|
||||||
// 只添加客户端没有提供的 headers
|
|
||||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
|
||||||
const lowerKey = key.toLowerCase()
|
|
||||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
|
||||||
finalHeaders[key] = claudeCodeHeaders[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
|
||||||
account,
|
|
||||||
accountId,
|
accountId,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
account,
|
||||||
accountType,
|
accountType,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
clientHeaders,
|
|
||||||
requestOptions,
|
requestOptions,
|
||||||
isStream: true
|
isStream: true
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (extensionResult.abortResponse) {
|
if (prepared.abortResponse) {
|
||||||
return extensionResult.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPayload = extensionResult.body
|
const { bodyString, headers } = prepared
|
||||||
finalHeaders = extensionResult.headers
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(this.claudeApiUrl)
|
const url = new URL(this.claudeApiUrl)
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: url.port || 443,
|
port: url.port || 443,
|
||||||
path: url.pathname,
|
path: url.pathname + (url.search || ''),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'anthropic-version': this.apiVersion,
|
|
||||||
...finalHeaders
|
|
||||||
},
|
|
||||||
agent: proxyAgent,
|
agent: proxyAgent,
|
||||||
timeout: config.requestTimeout || 600000
|
timeout: config.requestTimeout || 600000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
|
||||||
if (!options.headers['user-agent'] || unifiedUA !== null) {
|
|
||||||
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
|
|
||||||
options.headers['user-agent'] = userAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
|
||||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
|
||||||
const modelId = body?.model
|
|
||||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
|
||||||
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
|
||||||
|
|
||||||
const req = https.request(options, async (res) => {
|
const req = https.request(options, async (res) => {
|
||||||
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
|
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
|
||||||
|
|
||||||
@@ -1766,15 +1779,15 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 提取5小时会话窗口状态
|
// 提取5小时会话窗口状态
|
||||||
// 使用大小写不敏感的方式获取响应头
|
// 使用大小写不敏感的方式获取响应头
|
||||||
const get5hStatus = (headers) => {
|
const get5hStatus = (resHeaders) => {
|
||||||
if (!headers) {
|
if (!resHeaders) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// HTTP头部名称不区分大小写,需要处理不同情况
|
// HTTP头部名称不区分大小写,需要处理不同情况
|
||||||
return (
|
return (
|
||||||
headers['anthropic-ratelimit-unified-5h-status'] ||
|
resHeaders['anthropic-ratelimit-unified-5h-status'] ||
|
||||||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
resHeaders['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
||||||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
resHeaders['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1942,13 +1955,19 @@ class ClaudeRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(JSON.stringify(requestPayload))
|
req.write(bodyString)
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🛠️ 统一的错误处理方法
|
// 🛠️ 统一的错误处理方法
|
||||||
async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') {
|
async _handleServerError(
|
||||||
|
accountId,
|
||||||
|
statusCode,
|
||||||
|
sessionHash = null,
|
||||||
|
context = '',
|
||||||
|
accountType = 'claude-official'
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await claudeAccountService.recordServerError(accountId, statusCode)
|
await claudeAccountService.recordServerError(accountId, statusCode)
|
||||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||||
@@ -1962,6 +1981,18 @@ class ClaudeRelayService {
|
|||||||
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
|
`⏱️ ${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) {
|
if (errorCount > threshold) {
|
||||||
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
||||||
// ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停
|
// ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停
|
||||||
|
|||||||
@@ -556,7 +556,8 @@ class DroidAccountService {
|
|||||||
tokenType = 'Bearer',
|
tokenType = 'Bearer',
|
||||||
authenticationMethod = '',
|
authenticationMethod = '',
|
||||||
expiresIn = null,
|
expiresIn = null,
|
||||||
apiKeys = []
|
apiKeys = [],
|
||||||
|
userAgent = '' // 自定义 User-Agent
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -832,7 +833,8 @@ class DroidAccountService {
|
|||||||
: '',
|
: '',
|
||||||
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
||||||
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
||||||
apiKeyStrategy: hasApiKeys ? 'random_sticky' : ''
|
apiKeyStrategy: hasApiKeys ? 'random_sticky' : '',
|
||||||
|
userAgent: userAgent || '' // 自定义 User-Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.setDroidAccount(accountId, accountData)
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
@@ -931,6 +933,11 @@ class DroidAccountService {
|
|||||||
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
|
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 userAgent 字段
|
||||||
|
if (typeof sanitizedUpdates.userAgent === 'string') {
|
||||||
|
sanitizedUpdates.userAgent = sanitizedUpdates.userAgent.trim()
|
||||||
|
}
|
||||||
|
|
||||||
const parseProxyConfig = (value) => {
|
const parseProxyConfig = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class DroidRelayService {
|
|||||||
comm: '/o/v1/chat/completions'
|
comm: '/o/v1/chat/completions'
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userAgent = 'factory-cli/0.19.12'
|
this.userAgent = 'factory-cli/0.32.1'
|
||||||
this.systemPrompt = SYSTEM_PROMPT
|
this.systemPrompt = SYSTEM_PROMPT
|
||||||
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,8 @@ class DroidRelayService {
|
|||||||
accessToken,
|
accessToken,
|
||||||
normalizedRequestBody,
|
normalizedRequestBody,
|
||||||
normalizedEndpoint,
|
normalizedEndpoint,
|
||||||
clientHeaders
|
clientHeaders,
|
||||||
|
account
|
||||||
)
|
)
|
||||||
|
|
||||||
if (selectedApiKey) {
|
if (selectedApiKey) {
|
||||||
@@ -737,6 +738,14 @@ class DroidRelayService {
|
|||||||
currentUsageData.output_tokens = 0
|
currentUsageData.output_tokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture cache tokens from OpenAI format
|
||||||
|
currentUsageData.cache_read_input_tokens =
|
||||||
|
data.usage.input_tokens_details?.cached_tokens || 0
|
||||||
|
currentUsageData.cache_creation_input_tokens =
|
||||||
|
data.usage.input_tokens_details?.cache_creation_input_tokens ||
|
||||||
|
data.usage.cache_creation_input_tokens ||
|
||||||
|
0
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +767,14 @@ class DroidRelayService {
|
|||||||
currentUsageData.output_tokens = 0
|
currentUsageData.output_tokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture cache tokens from OpenAI Response API format
|
||||||
|
currentUsageData.cache_read_input_tokens =
|
||||||
|
usage.input_tokens_details?.cached_tokens || 0
|
||||||
|
currentUsageData.cache_creation_input_tokens =
|
||||||
|
usage.input_tokens_details?.cache_creation_input_tokens ||
|
||||||
|
usage.cache_creation_input_tokens ||
|
||||||
|
0
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
@@ -966,11 +983,13 @@ class DroidRelayService {
|
|||||||
/**
|
/**
|
||||||
* 构建请求头
|
* 构建请求头
|
||||||
*/
|
*/
|
||||||
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) {
|
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}, account = null) {
|
||||||
|
// 使用账户配置的 userAgent 或默认值
|
||||||
|
const userAgent = account?.userAgent || this.userAgent
|
||||||
const headers = {
|
const headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
authorization: `Bearer ${accessToken}`,
|
authorization: `Bearer ${accessToken}`,
|
||||||
'user-agent': this.userAgent,
|
'user-agent': userAgent,
|
||||||
'x-factory-client': 'cli',
|
'x-factory-client': 'cli',
|
||||||
connection: 'keep-alive'
|
connection: 'keep-alive'
|
||||||
}
|
}
|
||||||
@@ -987,10 +1006,16 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI 特定头
|
// OpenAI 特定头 - 根据模型动态选择 provider
|
||||||
if (endpointType === 'openai') {
|
if (endpointType === 'openai') {
|
||||||
|
const model = (requestBody?.model || '').toLowerCase()
|
||||||
|
// -max 模型使用 openai provider,其他使用 azure_openai
|
||||||
|
if (model.includes('-max')) {
|
||||||
|
headers['x-api-provider'] = 'openai'
|
||||||
|
} else {
|
||||||
headers['x-api-provider'] = 'azure_openai'
|
headers['x-api-provider'] = 'azure_openai'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Comm 端点根据模型动态设置 provider
|
// Comm 端点根据模型动态设置 provider
|
||||||
if (endpointType === 'comm') {
|
if (endpointType === 'comm') {
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
|
|||||||
'x-stainless-runtime',
|
'x-stainless-runtime',
|
||||||
'x-stainless-runtime-version'
|
'x-stainless-runtime-version'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
|
||||||
|
const STAINLESS_HEADER_CASE_MAP = {
|
||||||
|
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
|
||||||
|
'x-stainless-timeout': 'X-Stainless-Timeout',
|
||||||
|
'x-stainless-lang': 'X-Stainless-Lang',
|
||||||
|
'x-stainless-package-version': 'X-Stainless-Package-Version',
|
||||||
|
'x-stainless-os': 'X-Stainless-OS',
|
||||||
|
'x-stainless-arch': 'X-Stainless-Arch',
|
||||||
|
'x-stainless-runtime': 'X-Stainless-Runtime',
|
||||||
|
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
|
||||||
|
}
|
||||||
const MIN_FINGERPRINT_FIELDS = 4
|
const MIN_FINGERPRINT_FIELDS = 4
|
||||||
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
||||||
|
|
||||||
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
removeHeaderCaseInsensitive(nextHeaders, key)
|
removeHeaderCaseInsensitive(nextHeaders, key)
|
||||||
nextHeaders[key] = fingerprint[key]
|
// 使用正确的大小写格式返回给上游
|
||||||
|
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
|
||||||
|
nextHeaders[properCaseKey] = fingerprint[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
return nextHeaders
|
return nextHeaders
|
||||||
|
|||||||
@@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService')
|
|||||||
const accountGroupService = require('./accountGroupService')
|
const accountGroupService = require('./accountGroupService')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
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 {
|
class UnifiedClaudeScheduler {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -46,8 +72,14 @@ class UnifiedClaudeScheduler {
|
|||||||
return false
|
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')) {
|
if (requestedModel.toLowerCase().includes('opus')) {
|
||||||
|
const isNewOpus = isOpus45OrNewer(requestedModel)
|
||||||
|
|
||||||
if (account.subscriptionInfo) {
|
if (account.subscriptionInfo) {
|
||||||
try {
|
try {
|
||||||
const info =
|
const info =
|
||||||
@@ -55,27 +87,36 @@ class UnifiedClaudeScheduler {
|
|||||||
? JSON.parse(account.subscriptionInfo)
|
? JSON.parse(account.subscriptionInfo)
|
||||||
: account.subscriptionInfo
|
: account.subscriptionInfo
|
||||||
|
|
||||||
// Pro 和 Free 账号不支持 Opus
|
// Free account: does not support any Opus model
|
||||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
if (info.accountType === 'free') {
|
||||||
logger.info(
|
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
|
return false
|
||||||
}
|
}
|
||||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
|
||||||
|
// 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(
|
logger.info(
|
||||||
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
|
`🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}`
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// Opus 4.5+ supported
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max account: supports all Opus versions (no restriction)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
// Parse failed, assume legacy data (Max), default support
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
|
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
// Account without subscription info, default to supported (legacy data compatibility)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +180,56 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 统一调度Claude账号(官方和Console)
|
// 🎯 统一调度Claude账号(官方和Console)
|
||||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
async selectAccountForApiKey(
|
||||||
|
apiKeyData,
|
||||||
|
sessionHash = null,
|
||||||
|
requestedModel = null,
|
||||||
|
forcedAccount = null
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
|
||||||
|
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (forcedAccount.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
|
||||||
|
)
|
||||||
|
// 不使用 forcedAccount,继续走下面的正常调度逻辑
|
||||||
|
} else {
|
||||||
|
// claude-official 类型需要检查可用性并强制使用
|
||||||
|
logger.info(
|
||||||
|
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAvailable = await this._isAccountAvailableForSessionBinding(
|
||||||
|
forcedAccount.accountId,
|
||||||
|
forcedAccount.accountType,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAvailable) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
accountId: forcedAccount.accountId,
|
||||||
|
accountType: forcedAccount.accountType
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 绑定账户不可用,抛出特定错误(不 fallback)
|
||||||
|
logger.warn(
|
||||||
|
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
const error = new Error('Session binding account unavailable')
|
||||||
|
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
|
||||||
|
error.accountId = forcedAccount.accountId
|
||||||
|
error.accountType = forcedAccount.accountType
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析供应商前缀
|
// 解析供应商前缀
|
||||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||||
@@ -177,6 +266,16 @@ class UnifiedClaudeScheduler {
|
|||||||
// 普通专属账户
|
// 普通专属账户
|
||||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||||
|
// 检查是否临时不可用
|
||||||
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
boundAccount.id,
|
||||||
|
'claude-official'
|
||||||
|
)
|
||||||
|
if (isTempUnavailable) {
|
||||||
|
logger.warn(
|
||||||
|
`⏱️ Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
||||||
@@ -203,6 +302,7 @@ class UnifiedClaudeScheduler {
|
|||||||
accountType: 'claude-official'
|
accountType: 'claude-official'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}), falling back to pool`
|
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}), falling back to pool`
|
||||||
@@ -221,6 +321,16 @@ class UnifiedClaudeScheduler {
|
|||||||
boundConsoleAccount.status === 'active' &&
|
boundConsoleAccount.status === 'active' &&
|
||||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||||
) {
|
) {
|
||||||
|
// 检查是否临时不可用
|
||||||
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
boundConsoleAccount.id,
|
||||||
|
'claude-console'
|
||||||
|
)
|
||||||
|
if (isTempUnavailable) {
|
||||||
|
logger.warn(
|
||||||
|
`⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
@@ -228,6 +338,7 @@ class UnifiedClaudeScheduler {
|
|||||||
accountId: apiKeyData.claudeConsoleAccountId,
|
accountId: apiKeyData.claudeConsoleAccountId,
|
||||||
accountType: 'claude-console'
|
accountType: 'claude-console'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool`
|
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool`
|
||||||
@@ -245,6 +356,16 @@ class UnifiedClaudeScheduler {
|
|||||||
boundBedrockAccountResult.data.isActive === true &&
|
boundBedrockAccountResult.data.isActive === true &&
|
||||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||||
) {
|
) {
|
||||||
|
// 检查是否临时不可用
|
||||||
|
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||||
|
apiKeyData.bedrockAccountId,
|
||||||
|
'bedrock'
|
||||||
|
)
|
||||||
|
if (isTempUnavailable) {
|
||||||
|
logger.warn(
|
||||||
|
`⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
@@ -252,6 +373,7 @@ class UnifiedClaudeScheduler {
|
|||||||
accountId: apiKeyData.bedrockAccountId,
|
accountId: apiKeyData.bedrockAccountId,
|
||||||
accountType: 'bedrock'
|
accountType: 'bedrock'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool`
|
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool`
|
||||||
@@ -496,6 +618,18 @@ class UnifiedClaudeScheduler {
|
|||||||
continue
|
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)
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
@@ -584,6 +718,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(
|
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||||
currentAccount.id
|
currentAccount.id
|
||||||
@@ -682,7 +828,15 @@ class UnifiedClaudeScheduler {
|
|||||||
account.accountType === 'shared' &&
|
account.accountType === 'shared' &&
|
||||||
this._isSchedulable(account.schedulable)
|
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({
|
availableAccounts.push({
|
||||||
...account,
|
...account,
|
||||||
@@ -731,6 +885,13 @@ class UnifiedClaudeScheduler {
|
|||||||
continue
|
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 isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||||
@@ -1099,6 +1260,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(
|
async markAccountRateLimited(
|
||||||
accountId,
|
accountId,
|
||||||
@@ -1562,6 +1759,67 @@ class UnifiedClaudeScheduler {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 检查 claude-official 账户是否可用于会话绑定
|
||||||
|
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型(应为 'claude-official')
|
||||||
|
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
|
||||||
|
try {
|
||||||
|
// 此方法仅处理 claude-official 类型
|
||||||
|
if (accountType !== 'claude-official') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
|
||||||
|
)
|
||||||
|
return true // 非 claude-official 类型不受限制
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = account.isActive === 'true' || account.isActive === true
|
||||||
|
const { status } = account
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error' || status === 'temp_error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
if (await claudeAccountService.isAccountRateLimited(accountId)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查临时不可用
|
||||||
|
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new UnifiedClaudeScheduler()
|
module.exports = new UnifiedClaudeScheduler()
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ const PROMPT_DEFINITIONS = {
|
|||||||
title: 'Claude Code Compact System Prompt',
|
title: 'Claude Code Compact System Prompt',
|
||||||
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
||||||
},
|
},
|
||||||
|
exploreAgentSystemPrompt: {
|
||||||
|
category: 'system',
|
||||||
|
title: 'Claude Code Explore Agent System Prompt',
|
||||||
|
text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
},
|
||||||
outputStyleInsightsPrompt: {
|
outputStyleInsightsPrompt: {
|
||||||
category: 'output_style',
|
category: 'output_style',
|
||||||
title: 'Output Style Insights Addendum',
|
title: 'Output Style Insights Addendum',
|
||||||
|
|||||||
@@ -52,50 +52,38 @@ function filterForOpenAI(headers) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 为 Claude/Anthropic API 过滤 headers
|
* 为 Claude/Anthropic API 过滤 headers
|
||||||
* 在原有逻辑基础上添加 CDN headers 到敏感列表
|
* 使用白名单模式,只允许指定的 headers 通过
|
||||||
*/
|
*/
|
||||||
function filterForClaude(headers) {
|
function filterForClaude(headers) {
|
||||||
const sensitiveHeaders = [
|
// 白名单模式:只允许以下 headers
|
||||||
'content-type',
|
const allowedHeaders = [
|
||||||
'user-agent',
|
|
||||||
'x-api-key',
|
|
||||||
'authorization',
|
|
||||||
'x-authorization',
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'connection',
|
|
||||||
'proxy-authorization',
|
|
||||||
'content-encoding',
|
|
||||||
'transfer-encoding',
|
|
||||||
...cdnHeaders // 添加 CDN headers
|
|
||||||
]
|
|
||||||
|
|
||||||
const browserHeaders = [
|
|
||||||
'origin',
|
|
||||||
'referer',
|
|
||||||
'sec-fetch-mode',
|
|
||||||
'sec-fetch-site',
|
|
||||||
'sec-fetch-dest',
|
|
||||||
'sec-ch-ua',
|
|
||||||
'sec-ch-ua-mobile',
|
|
||||||
'sec-ch-ua-platform',
|
|
||||||
'accept-language',
|
|
||||||
'accept-encoding',
|
|
||||||
'accept',
|
'accept',
|
||||||
'cache-control',
|
'x-stainless-retry-count',
|
||||||
'pragma',
|
'x-stainless-timeout',
|
||||||
'anthropic-dangerous-direct-browser-access'
|
'x-stainless-lang',
|
||||||
|
'x-stainless-package-version',
|
||||||
|
'x-stainless-os',
|
||||||
|
'x-stainless-arch',
|
||||||
|
'x-stainless-runtime',
|
||||||
|
'x-stainless-runtime-version',
|
||||||
|
'x-stainless-helper-method',
|
||||||
|
'anthropic-dangerous-direct-browser-access',
|
||||||
|
'anthropic-version',
|
||||||
|
'x-app',
|
||||||
|
'anthropic-beta',
|
||||||
|
'accept-language',
|
||||||
|
'sec-fetch-mode',
|
||||||
|
'accept-encoding',
|
||||||
|
'user-agent',
|
||||||
|
'content-type',
|
||||||
|
'connection'
|
||||||
]
|
]
|
||||||
|
|
||||||
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
|
|
||||||
|
|
||||||
const filtered = {}
|
const filtered = {}
|
||||||
Object.keys(headers || {}).forEach((key) => {
|
Object.keys(headers || {}).forEach((key) => {
|
||||||
const lowerKey = key.toLowerCase()
|
const lowerKey = key.toLowerCase()
|
||||||
if (allowedHeaders.includes(lowerKey)) {
|
if (allowedHeaders.includes(lowerKey)) {
|
||||||
filtered[key] = headers[key]
|
filtered[key] = headers[key]
|
||||||
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
|
||||||
filtered[key] = headers[key]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,119 @@ function getVendorType(modelStr) {
|
|||||||
return vendor
|
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 = {
|
module.exports = {
|
||||||
parseVendorPrefixedModel,
|
parseVendorPrefixedModel,
|
||||||
hasVendorPrefix,
|
hasVendorPrefix,
|
||||||
getEffectiveModel,
|
getEffectiveModel,
|
||||||
getVendorType
|
getVendorType,
|
||||||
|
isOpus45OrNewer
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/utils/unstableUpstreamHelper.js
Normal file
81
src/utils/unstableUpstreamHelper.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -65,8 +65,9 @@ class ClaudeCodeValidator {
|
|||||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||||
if (bestScore < threshold) {
|
if (bestScore < threshold) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
|
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||||
)
|
)
|
||||||
|
logger.warn(`Claude system prompt detail: ${rawText}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,7 +1157,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1351,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1589,7 +1587,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3063,15 +3060,13 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -3623,7 +3618,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3770,7 +3764,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -4035,7 +4028,6 @@
|
|||||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4533,7 +4525,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4924,7 +4915,6 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -5125,7 +5115,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.18",
|
"@vue/compiler-dom": "3.5.18",
|
||||||
"@vue/compiler-sfc": "3.5.18",
|
"@vue/compiler-sfc": "3.5.18",
|
||||||
|
|||||||
@@ -1944,6 +1944,22 @@
|
|||||||
rows="4"
|
rows="4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 (OAuth/Manual 模式) -->
|
||||||
|
<div v-if="form.platform === 'droid'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Key 模式输入 -->
|
<!-- API Key 模式输入 -->
|
||||||
@@ -1989,6 +2005,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
||||||
>
|
>
|
||||||
@@ -3639,6 +3671,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 (编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'droid'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 代理设置 -->
|
<!-- 代理设置 -->
|
||||||
<ProxyConfig v-model="form.proxy" />
|
<ProxyConfig v-model="form.proxy" />
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-xs font-semibold text-purple-700 transition hover:bg-purple-200 dark:bg-purple-500/10 dark:text-purple-200 dark:hover:bg-purple-500/20"
|
||||||
|
@click="goTimeline"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock" /> 请求时间线
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
@@ -51,6 +58,7 @@
|
|||||||
<i class="fas fa-times" />
|
<i class="fas fa-times" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-4 sm:px-6">
|
<div class="flex-1 overflow-y-auto px-5 py-4 sm:px-6">
|
||||||
@@ -325,6 +333,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import Chart from 'chart.js/auto'
|
import Chart from 'chart.js/auto'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
@@ -343,6 +352,7 @@ const emit = defineEmits(['close'])
|
|||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const { isDarkMode } = storeToRefs(themeStore)
|
const { isDarkMode } = storeToRefs(themeStore)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const chartCanvas = ref(null)
|
const chartCanvas = ref(null)
|
||||||
let chartInstance = null
|
let chartInstance = null
|
||||||
@@ -579,6 +589,14 @@ const handleClose = () => {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goTimeline = () => {
|
||||||
|
if (!props.account?.id) return
|
||||||
|
router.push({
|
||||||
|
path: `/accounts/${props.account.id}/usage-records`,
|
||||||
|
query: { platform: props.account.platform || props.account.accountType }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
|
|||||||
@@ -77,7 +77,21 @@ const getActionClass = (action) => {
|
|||||||
return colorMap[action.color] || colorMap.gray
|
return colorMap[action.color] || colorMap.gray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const instanceId = Symbol('action-dropdown')
|
||||||
|
const handleGlobalOpen = (event) => {
|
||||||
|
if (event?.detail?.id !== instanceId) {
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleDropdown = async () => {
|
const toggleDropdown = async () => {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('action-dropdown-open', {
|
||||||
|
detail: { id: instanceId }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
isOpen.value = !isOpen.value
|
isOpen.value = !isOpen.value
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -102,7 +116,8 @@ const updateDropdownPosition = () => {
|
|||||||
|
|
||||||
const trigger = triggerRef.value.getBoundingClientRect()
|
const trigger = triggerRef.value.getBoundingClientRect()
|
||||||
const dropdownHeight = 200 // 预估高度
|
const dropdownHeight = 200 // 预估高度
|
||||||
const dropdownWidth = 160 // 预估宽度
|
const dropdownWidth = 180 // 预估宽度,略大以减少遮挡
|
||||||
|
const gap = 8
|
||||||
const spaceBelow = window.innerHeight - trigger.bottom
|
const spaceBelow = window.innerHeight - trigger.bottom
|
||||||
const spaceAbove = trigger.top
|
const spaceAbove = trigger.top
|
||||||
const spaceRight = window.innerWidth - trigger.right
|
const spaceRight = window.innerWidth - trigger.right
|
||||||
@@ -112,16 +127,16 @@ const updateDropdownPosition = () => {
|
|||||||
|
|
||||||
// 计算垂直位置
|
// 计算垂直位置
|
||||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||||
top = trigger.bottom + 4
|
top = trigger.bottom + gap
|
||||||
} else {
|
} else {
|
||||||
top = trigger.top - dropdownHeight - 4
|
top = trigger.top - dropdownHeight - gap
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算水平位置 - 优先显示在左侧(因为按钮在右侧固定列)
|
// 计算水平位置 - 优先向右展开,避免遮挡左侧内容
|
||||||
if (spaceLeft >= dropdownWidth) {
|
if (spaceRight >= dropdownWidth + gap) {
|
||||||
left = trigger.left - dropdownWidth + trigger.width
|
left = trigger.right + gap
|
||||||
} else if (spaceRight >= dropdownWidth) {
|
} else if (spaceLeft >= dropdownWidth + gap) {
|
||||||
left = trigger.left
|
left = trigger.left - dropdownWidth - gap + trigger.width
|
||||||
} else {
|
} else {
|
||||||
left = window.innerWidth - dropdownWidth - 10
|
left = window.innerWidth - dropdownWidth - 10
|
||||||
}
|
}
|
||||||
@@ -164,11 +179,13 @@ onMounted(() => {
|
|||||||
window.addEventListener('scroll', handleScroll, true)
|
window.addEventListener('scroll', handleScroll, true)
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
window.addEventListener('action-dropdown-open', handleGlobalOpen)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('scroll', handleScroll, true)
|
window.removeEventListener('scroll', handleScroll, true)
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
window.removeEventListener('action-dropdown-open', handleGlobalOpen)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -41,19 +41,25 @@
|
|||||||
<div
|
<div
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150"
|
class="flex cursor-pointer items-center gap-2 whitespace-nowrap py-2 text-sm transition-colors duration-150"
|
||||||
:class="[
|
:class="[
|
||||||
option.value === modelValue
|
isSelected(option.value)
|
||||||
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
: option.isGroup
|
||||||
|
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
|
||||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
]"
|
]"
|
||||||
|
:style="{
|
||||||
|
paddingLeft: option.indent ? `${12 + option.indent * 16}px` : '12px',
|
||||||
|
paddingRight: '12px'
|
||||||
|
}"
|
||||||
@click="selectOption(option)"
|
@click="selectOption(option)"
|
||||||
>
|
>
|
||||||
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
|
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
|
||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
<i
|
<i
|
||||||
v-if="option.value === modelValue"
|
v-if="isSelected(option.value)"
|
||||||
class="fas fa-check ml-auto pl-3 text-xs text-blue-600"
|
class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400"
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +74,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [String, Number],
|
type: [String, Number, Array],
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@@ -86,6 +92,10 @@ const props = defineProps({
|
|||||||
iconColor: {
|
iconColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'text-gray-500'
|
default: 'text-gray-500'
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,7 +106,18 @@ const triggerRef = ref(null)
|
|||||||
const dropdownRef = ref(null)
|
const dropdownRef = ref(null)
|
||||||
const dropdownStyle = ref({})
|
const dropdownStyle = ref({})
|
||||||
|
|
||||||
|
const isSelected = (value) => {
|
||||||
|
if (props.multiple) {
|
||||||
|
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
|
||||||
|
}
|
||||||
|
return props.modelValue === value
|
||||||
|
}
|
||||||
|
|
||||||
const selectedLabel = computed(() => {
|
const selectedLabel = computed(() => {
|
||||||
|
if (props.multiple) {
|
||||||
|
const count = Array.isArray(props.modelValue) ? props.modelValue.length : 0
|
||||||
|
return count > 0 ? `已选 ${count} 个` : ''
|
||||||
|
}
|
||||||
const selected = props.options.find((opt) => opt.value === props.modelValue)
|
const selected = props.options.find((opt) => opt.value === props.modelValue)
|
||||||
return selected ? selected.label : ''
|
return selected ? selected.label : ''
|
||||||
})
|
})
|
||||||
@@ -114,10 +135,22 @@ const closeDropdown = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (option) => {
|
const selectOption = (option) => {
|
||||||
|
if (props.multiple) {
|
||||||
|
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||||
|
const idx = current.indexOf(option.value)
|
||||||
|
if (idx >= 0) {
|
||||||
|
current.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
current.push(option.value)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', current)
|
||||||
|
emit('change', current)
|
||||||
|
} else {
|
||||||
emit('update:modelValue', option.value)
|
emit('update:modelValue', option.value)
|
||||||
emit('change', option.value)
|
emit('change', option.value)
|
||||||
closeDropdown()
|
closeDropdown()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateDropdownPosition = () => {
|
const updateDropdownPosition = () => {
|
||||||
if (!triggerRef.value || !isOpen.value) return
|
if (!triggerRef.value || !isOpen.value) return
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DashboardView = () => import('@/views/DashboardView.vue')
|
|||||||
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||||
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
|
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
|
||||||
const AccountsView = () => import('@/views/AccountsView.vue')
|
const AccountsView = () => import('@/views/AccountsView.vue')
|
||||||
|
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
|
||||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||||
@@ -110,6 +111,18 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/accounts/:accountId/usage-records',
|
||||||
|
component: MainLayout,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'AccountUsageRecords',
|
||||||
|
component: AccountUsageRecordsView
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/tutorial',
|
path: '/tutorial',
|
||||||
component: MainLayout,
|
component: MainLayout,
|
||||||
|
|||||||
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4 p-4 lg:p-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||||
|
账户请求详情时间线
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ accountDisplayName }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ accountId }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">渠道:{{ platformDisplayName }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-clock text-blue-500" />
|
||||||
|
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||||
|
<span v-else>显示近 5000 条记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(summary.totalRequests) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(summary.totalTokens) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{{ formatCost(summary.totalCost) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCost(summary.avgCost) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.dateRange"
|
||||||
|
class="max-w-[320px]"
|
||||||
|
clearable
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
type="datetimerange"
|
||||||
|
unlink-panels
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filters.model"
|
||||||
|
class="w-[180px]"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="所有模型"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="modelOption in availableModels"
|
||||||
|
:key="modelOption"
|
||||||
|
:label="modelOption"
|
||||||
|
:value="modelOption"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filters.apiKeyId"
|
||||||
|
class="w-[220px]"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="所有 API Key"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="apiKey in availableApiKeys"
|
||||||
|
:key="apiKey.id"
|
||||||
|
:label="apiKey.name || apiKey.id"
|
||||||
|
:value="apiKey.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||||
|
<el-option label="时间降序" value="desc" />
|
||||||
|
<el-option label="时间升序" value="asc" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||||
|
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||||
|
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-if="records.length === 0"
|
||||||
|
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-inbox text-2xl" />
|
||||||
|
<p>暂无记录</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="hidden overflow-x-auto md:block">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
时间
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
API Key
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
模型
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
输入
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
输出
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
缓存(创/读)
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
总 Token
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
费用
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatDate(record.timestamp) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
ID: {{ record.apiKeyId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ record.model }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||||
|
{{ formatNumber(record.inputTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||||
|
>
|
||||||
|
{{ formatNumber(record.outputTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||||
|
{{ formatNumber(record.cacheReadTokens) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatNumber(record.totalTokens) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||||
|
>
|
||||||
|
{{ record.costFormatted || formatCost(record.cost) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||||
|
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 md:hidden">
|
||||||
|
<div
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record.timestamp + record.model"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
ID: {{ record.apiKeyId }} · {{ formatDate(record.timestamp) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
渠道:{{ platformDisplayName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<div>模型:{{ record.model }}</div>
|
||||||
|
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||||
|
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||||
|
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||||
|
<div>
|
||||||
|
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||||
|
{{ formatNumber(record.cacheReadTokens) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-yellow-600 dark:text-yellow-400">
|
||||||
|
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between px-4 pb-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
共 {{ pagination.totalRecords }} 条记录
|
||||||
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
:current-page="pagination.currentPage"
|
||||||
|
layout="prev, pager, next, sizes"
|
||||||
|
:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[20, 50, 100, 200]"
|
||||||
|
:total="pagination.totalRecords"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
import { formatNumber } from '@/utils/format'
|
||||||
|
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const accountId = computed(() => route.params.accountId)
|
||||||
|
const platform = computed(() => route.query.platform)
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const records = ref([])
|
||||||
|
const availableModels = ref([])
|
||||||
|
const availableApiKeys = ref([])
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
totalRecords: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
dateRange: null,
|
||||||
|
model: '',
|
||||||
|
apiKeyId: '',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = reactive({
|
||||||
|
totalRequests: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
avgCost: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountInfo = reactive({
|
||||||
|
id: accountId.value,
|
||||||
|
name: '',
|
||||||
|
platform: platform.value || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const activeRecord = ref(null)
|
||||||
|
|
||||||
|
const accountDisplayName = computed(() => accountInfo.name || accountInfo.id || accountId.value)
|
||||||
|
const platformDisplayName = computed(() => {
|
||||||
|
const map = {
|
||||||
|
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 key = accountInfo.platform || platform.value || 'unknown'
|
||||||
|
return map[key] || '未知渠道'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateRangeHint = computed(() => {
|
||||||
|
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||||
|
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '--'
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCost = (value) => {
|
||||||
|
const num = typeof value === 'number' ? value : 0
|
||||||
|
if (num >= 1) return `$${num.toFixed(2)}`
|
||||||
|
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||||
|
return `$${num.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildParams = (page) => {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortOrder: filters.sortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.model) params.model = filters.model
|
||||||
|
if (filters.apiKeyId) params.apiKeyId = filters.apiKeyId
|
||||||
|
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||||
|
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||||
|
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||||
|
}
|
||||||
|
if (platform.value) {
|
||||||
|
params.platform = platform.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncResponseState = (data) => {
|
||||||
|
records.value = data.records || []
|
||||||
|
|
||||||
|
const pageInfo = data.pagination || {}
|
||||||
|
pagination.currentPage = pageInfo.currentPage || 1
|
||||||
|
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||||
|
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||||
|
|
||||||
|
const filterEcho = data.filters || {}
|
||||||
|
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||||
|
if (filterEcho.apiKeyId !== undefined) filters.apiKeyId = filterEcho.apiKeyId || ''
|
||||||
|
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||||
|
if (filterEcho.startDate && filterEcho.endDate) {
|
||||||
|
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||||
|
const currentRange = filters.dateRange || []
|
||||||
|
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||||
|
filters.dateRange = nextRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryData = data.summary || {}
|
||||||
|
summary.totalRequests = summaryData.totalRequests || 0
|
||||||
|
summary.totalTokens = summaryData.totalTokens || 0
|
||||||
|
summary.totalCost = summaryData.totalCost || 0
|
||||||
|
summary.avgCost = summaryData.avgCost || 0
|
||||||
|
|
||||||
|
accountInfo.id = data.accountInfo?.id || accountId.value
|
||||||
|
accountInfo.name = data.accountInfo?.name || ''
|
||||||
|
accountInfo.platform = data.accountInfo?.platform || platform.value || ''
|
||||||
|
|
||||||
|
availableModels.value = data.availableFilters?.models || []
|
||||||
|
availableApiKeys.value = data.availableFilters?.apiKeys || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecords = async (page = pagination.currentPage) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||||
|
params: buildParams(page)
|
||||||
|
})
|
||||||
|
syncResponseState(response.data || {})
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
pagination.currentPage = page
|
||||||
|
fetchRecords(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
pagination.pageSize = size
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.model = ''
|
||||||
|
filters.apiKeyId = ''
|
||||||
|
filters.dateRange = null
|
||||||
|
filters.sortOrder = 'desc'
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetail = (record) => {
|
||||||
|
activeRecord.value = record
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
detailVisible.value = false
|
||||||
|
activeRecord.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/accounts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportCsv = async () => {
|
||||||
|
if (exporting.value) return
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const aggregated = []
|
||||||
|
let page = 1
|
||||||
|
let totalPages = 1
|
||||||
|
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||||
|
|
||||||
|
while (page <= totalPages && page <= maxPages) {
|
||||||
|
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||||
|
params: { ...buildParams(page), pageSize: 200 }
|
||||||
|
})
|
||||||
|
const payload = response.data || {}
|
||||||
|
aggregated.push(...(payload.records || []))
|
||||||
|
totalPages = payload.pagination?.totalPages || 1
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregated.length === 0) {
|
||||||
|
showToast('没有可导出的记录', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'时间',
|
||||||
|
'API Key',
|
||||||
|
'模型',
|
||||||
|
'输入Token',
|
||||||
|
'输出Token',
|
||||||
|
'缓存创建Token',
|
||||||
|
'缓存读取Token',
|
||||||
|
'总Token',
|
||||||
|
'费用'
|
||||||
|
]
|
||||||
|
|
||||||
|
const csvRows = [headers.join(',')]
|
||||||
|
aggregated.forEach((record) => {
|
||||||
|
const row = [
|
||||||
|
formatDate(record.timestamp),
|
||||||
|
record.apiKeyName || record.apiKeyId || '',
|
||||||
|
record.model || '',
|
||||||
|
record.inputTokens || 0,
|
||||||
|
record.outputTokens || 0,
|
||||||
|
record.cacheCreateTokens || 0,
|
||||||
|
record.cacheReadTokens || 0,
|
||||||
|
record.totalTokens || 0,
|
||||||
|
record.costFormatted || formatCost(record.cost)
|
||||||
|
]
|
||||||
|
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||||
|
})
|
||||||
|
|
||||||
|
const blob = new Blob([csvRows.join('\n')], {
|
||||||
|
type: 'text/csv;charset=utf-8;'
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `account-${accountId.value}-usage-records.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
showToast('导出 CSV 成功', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [filters.model, filters.apiKeyId, filters.sortOrder],
|
||||||
|
() => {
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filters.dateRange,
|
||||||
|
() => {
|
||||||
|
pagination.currentPage = 1
|
||||||
|
fetchRecords(1)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -58,6 +58,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态筛选器 -->
|
||||||
|
<div class="group relative min-w-[120px]">
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-green-500 to-emerald-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<CustomDropdown
|
||||||
|
v-model="statusFilter"
|
||||||
|
icon="fa-check-circle"
|
||||||
|
icon-color="text-green-500"
|
||||||
|
:options="statusOptions"
|
||||||
|
placeholder="选择状态"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<div class="group relative min-w-[200px]">
|
<div class="group relative min-w-[200px]">
|
||||||
<div
|
<div
|
||||||
@@ -83,6 +97,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:gap-3">
|
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<!-- 账户统计按钮 -->
|
||||||
|
<div class="relative">
|
||||||
|
<el-tooltip content="查看账户统计汇总" effect="dark" placement="bottom">
|
||||||
|
<button
|
||||||
|
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||||
|
@click="showAccountStatsModal = true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-violet-500 to-purple-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<i class="fas fa-chart-bar relative text-violet-500" />
|
||||||
|
<span class="relative">统计</span>
|
||||||
|
</button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
@@ -1668,7 +1698,6 @@
|
|||||||
<i class="fas fa-chart-line" />
|
<i class="fas fa-chart-line" />
|
||||||
详情
|
详情
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="canTestAccount(account)"
|
v-if="canTestAccount(account)"
|
||||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-cyan-50 px-3 py-2 text-xs text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
|
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-cyan-50 px-3 py-2 text-xs text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
|
||||||
@@ -1850,6 +1879,146 @@
|
|||||||
:show="showAccountTestModal"
|
:show="showAccountTestModal"
|
||||||
@close="closeAccountTestModal"
|
@close="closeAccountTestModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 账户统计弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showAccountStatsModal"
|
||||||
|
:style="{ maxWidth: '1200px' }"
|
||||||
|
title="账户统计汇总"
|
||||||
|
width="90%"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse text-sm" style="min-width: 1000px">
|
||||||
|
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-left dark:border-gray-600">
|
||||||
|
平台类型
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
正常
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
不可调度
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
限流0-1h
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
限流1-5h
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
限流5-12h
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
限流12-24h
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
限流>24h
|
||||||
|
</th>
|
||||||
|
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
其他
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="border border-gray-300 bg-blue-50 px-4 py-2 text-center font-bold dark:border-gray-600 dark:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
合计
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="stat in accountStats" :key="stat.platform">
|
||||||
|
<td class="border border-gray-300 px-4 py-2 font-medium dark:border-gray-600">
|
||||||
|
{{ stat.platformLabel }}
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-green-600 dark:text-green-400">{{ stat.normal }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400">{{ stat.unschedulable }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit0_1h }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit1_5h }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
stat.rateLimit5_12h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
stat.rateLimit12_24h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
stat.rateLimitOver24h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-red-600 dark:text-red-400">{{ stat.other }}</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="border border-gray-300 bg-blue-50 px-4 py-2 text-center font-bold dark:border-gray-600 dark:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
{{ stat.total }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-blue-50 font-bold dark:bg-blue-900/30">
|
||||||
|
<td class="border border-gray-300 px-4 py-2 dark:border-gray-600">合计</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-green-600 dark:text-green-400">{{
|
||||||
|
accountStatsTotal.normal
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400">{{
|
||||||
|
accountStatsTotal.unschedulable
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
accountStatsTotal.rateLimit0_1h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
accountStatsTotal.rateLimit1_5h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
accountStatsTotal.rateLimit5_12h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
accountStatsTotal.rateLimit12_24h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{
|
||||||
|
accountStatsTotal.rateLimitOver24h
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
<span class="text-red-600 dark:text-red-400">{{ accountStatsTotal.other }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||||
|
{{ accountStatsTotal.total }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
注:限流时间列表示剩余限流时间在指定范围内的账户数量
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1880,6 +2049,7 @@ const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X
|
|||||||
const accountGroups = ref([])
|
const accountGroups = ref([])
|
||||||
const groupFilter = ref('all')
|
const groupFilter = ref('all')
|
||||||
const platformFilter = ref('all')
|
const platformFilter = ref('all')
|
||||||
|
const statusFilter = ref('normal') // 状态过滤 (normal/rateLimited/other/all)
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
|
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
|
||||||
const getInitialPageSize = () => {
|
const getInitialPageSize = () => {
|
||||||
@@ -1929,6 +2099,9 @@ const expiryEditModalRef = ref(null)
|
|||||||
const showAccountTestModal = ref(false)
|
const showAccountTestModal = ref(false)
|
||||||
const testingAccount = ref(null)
|
const testingAccount = ref(null)
|
||||||
|
|
||||||
|
// 账户统计弹窗状态
|
||||||
|
const showAccountStatsModal = ref(false)
|
||||||
|
|
||||||
// 表格横向滚动检测
|
// 表格横向滚动检测
|
||||||
const tableContainerRef = ref(null)
|
const tableContainerRef = ref(null)
|
||||||
const needsHorizontalScroll = ref(false)
|
const needsHorizontalScroll = ref(false)
|
||||||
@@ -1946,21 +2119,102 @@ const sortOptions = ref([
|
|||||||
{ value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' },
|
{ value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' },
|
||||||
{ value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' },
|
{ value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' },
|
||||||
{ value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' },
|
{ value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' },
|
||||||
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' }
|
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' },
|
||||||
|
{ value: 'rateLimitTime', label: '按限流时间排序', icon: 'fa-hourglass' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const platformOptions = ref([
|
// 平台层级结构定义
|
||||||
{ value: 'all', label: '所有平台', icon: 'fa-globe' },
|
const platformHierarchy = [
|
||||||
{ value: 'claude', label: 'Claude', icon: 'fa-brain' },
|
{
|
||||||
|
value: 'group-claude',
|
||||||
|
label: 'Claude(全部)',
|
||||||
|
icon: 'fa-brain',
|
||||||
|
children: [
|
||||||
|
{ value: 'claude', label: 'Claude 官方/OAuth', icon: 'fa-brain' },
|
||||||
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
||||||
{ value: 'gemini', label: 'Gemini', icon: 'fab fa-google' },
|
|
||||||
{ value: 'gemini-api', label: 'Gemini API', icon: 'fa-key' },
|
|
||||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
|
||||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
|
||||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
||||||
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
|
{ value: 'ccr', label: 'CCR Relay', icon: 'fa-code-branch' }
|
||||||
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' },
|
]
|
||||||
{ value: 'droid', label: 'Droid', icon: 'fa-robot' }
|
},
|
||||||
|
{
|
||||||
|
value: 'group-openai',
|
||||||
|
label: 'Codex / OpenAI(全部)',
|
||||||
|
icon: 'fa-openai',
|
||||||
|
children: [
|
||||||
|
{ value: 'openai', label: 'OpenAI 官方', icon: 'fa-openai' },
|
||||||
|
{ value: 'openai-responses', label: 'OpenAI-Responses (Codex)', icon: 'fa-server' },
|
||||||
|
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'group-gemini',
|
||||||
|
label: 'Gemini(全部)',
|
||||||
|
icon: 'fab fa-google',
|
||||||
|
children: [
|
||||||
|
{ value: 'gemini', label: 'Gemini OAuth', icon: 'fab fa-google' },
|
||||||
|
{ value: 'gemini-api', label: 'Gemini API', icon: 'fa-key' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'group-droid',
|
||||||
|
label: 'Droid(全部)',
|
||||||
|
icon: 'fa-robot',
|
||||||
|
children: [{ value: 'droid', label: 'Droid', icon: 'fa-robot' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 平台分组映射
|
||||||
|
const platformGroupMap = {
|
||||||
|
'group-claude': ['claude', 'claude-console', 'bedrock', 'ccr'],
|
||||||
|
'group-openai': ['openai', 'openai-responses', 'azure_openai'],
|
||||||
|
'group-gemini': ['gemini', 'gemini-api'],
|
||||||
|
'group-droid': ['droid']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台请求处理器
|
||||||
|
const platformRequestHandlers = {
|
||||||
|
claude: (params) => apiClient.get('/admin/claude-accounts', { params }),
|
||||||
|
'claude-console': (params) => apiClient.get('/admin/claude-console-accounts', { params }),
|
||||||
|
bedrock: (params) => apiClient.get('/admin/bedrock-accounts', { params }),
|
||||||
|
gemini: (params) => apiClient.get('/admin/gemini-accounts', { params }),
|
||||||
|
openai: (params) => apiClient.get('/admin/openai-accounts', { params }),
|
||||||
|
azure_openai: (params) => apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||||
|
'openai-responses': (params) => apiClient.get('/admin/openai-responses-accounts', { params }),
|
||||||
|
ccr: (params) => apiClient.get('/admin/ccr-accounts', { params }),
|
||||||
|
droid: (params) => apiClient.get('/admin/droid-accounts', { params }),
|
||||||
|
'gemini-api': (params) => apiClient.get('/admin/gemini-api-accounts', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPlatformKeys = Object.keys(platformRequestHandlers)
|
||||||
|
|
||||||
|
// 根据过滤器获取需要加载的平台列表
|
||||||
|
const getPlatformsForFilter = (filter) => {
|
||||||
|
if (filter === 'all') return allPlatformKeys
|
||||||
|
if (platformGroupMap[filter]) return platformGroupMap[filter]
|
||||||
|
if (allPlatformKeys.includes(filter)) return [filter]
|
||||||
|
return allPlatformKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台选项(两级结构)
|
||||||
|
const platformOptions = computed(() => {
|
||||||
|
const options = [{ value: 'all', label: '所有平台', icon: 'fa-globe', indent: 0 }]
|
||||||
|
|
||||||
|
platformHierarchy.forEach((group) => {
|
||||||
|
options.push({ ...group, indent: 0, isGroup: true })
|
||||||
|
group.children?.forEach((child) => {
|
||||||
|
options.push({ ...child, indent: 1, parent: group.value })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = ref([
|
||||||
|
{ value: 'normal', label: '正常', icon: 'fa-check-circle' },
|
||||||
|
{ value: 'unschedulable', label: '不可调度', icon: 'fa-ban' },
|
||||||
|
{ value: 'rateLimited', label: '限流', icon: 'fa-hourglass-half' },
|
||||||
|
{ value: 'other', label: '其他', icon: 'fa-exclamation-triangle' },
|
||||||
|
{ value: 'all', label: '全部状态', icon: 'fa-list' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const groupOptions = computed(() => {
|
const groupOptions = computed(() => {
|
||||||
@@ -2199,6 +2453,33 @@ const sortedAccounts = computed(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态过滤 (normal/unschedulable/rateLimited/other/all)
|
||||||
|
// 限流: isActive && rate-limited (最高优先级)
|
||||||
|
// 正常: isActive && !rate-limited && !blocked && schedulable
|
||||||
|
// 不可调度: isActive && !rate-limited && !blocked && schedulable === false
|
||||||
|
// 其他: 非限流的(未激活 || 被阻止)
|
||||||
|
if (statusFilter.value !== 'all') {
|
||||||
|
sourceAccounts = sourceAccounts.filter((account) => {
|
||||||
|
const isRateLimited = isAccountRateLimited(account)
|
||||||
|
const isBlocked = account.status === 'blocked' || account.status === 'unauthorized'
|
||||||
|
|
||||||
|
if (statusFilter.value === 'rateLimited') {
|
||||||
|
// 限流: 激活且限流中(优先判断)
|
||||||
|
return account.isActive && isRateLimited
|
||||||
|
} else if (statusFilter.value === 'normal') {
|
||||||
|
// 正常: 激活且非限流且非阻止且可调度
|
||||||
|
return account.isActive && !isRateLimited && !isBlocked && account.schedulable !== false
|
||||||
|
} else if (statusFilter.value === 'unschedulable') {
|
||||||
|
// 不可调度: 激活且非限流且非阻止但不可调度
|
||||||
|
return account.isActive && !isRateLimited && !isBlocked && account.schedulable === false
|
||||||
|
} else if (statusFilter.value === 'other') {
|
||||||
|
// 其他: 非限流的异常账户(未激活或被阻止)
|
||||||
|
return !isRateLimited && (!account.isActive || isBlocked)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!accountsSortBy.value) return sourceAccounts
|
if (!accountsSortBy.value) return sourceAccounts
|
||||||
|
|
||||||
const sorted = [...sourceAccounts].sort((a, b) => {
|
const sorted = [...sourceAccounts].sort((a, b) => {
|
||||||
@@ -2229,6 +2510,23 @@ const sortedAccounts = computed(() => {
|
|||||||
bVal = b.isActive ? 1 : 0
|
bVal = b.isActive ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理限流时间排序: 未限流优先,然后按剩余时间从小到大
|
||||||
|
if (accountsSortBy.value === 'rateLimitTime') {
|
||||||
|
const aIsRateLimited = isAccountRateLimited(a)
|
||||||
|
const bIsRateLimited = isAccountRateLimited(b)
|
||||||
|
const aMinutes = aIsRateLimited ? getRateLimitRemainingMinutes(a) : 0
|
||||||
|
const bMinutes = bIsRateLimited ? getRateLimitRemainingMinutes(b) : 0
|
||||||
|
|
||||||
|
// 未限流的排在前面
|
||||||
|
if (!aIsRateLimited && bIsRateLimited) return -1
|
||||||
|
if (aIsRateLimited && !bIsRateLimited) return 1
|
||||||
|
|
||||||
|
// 都未限流或都限流时,按剩余时间升序
|
||||||
|
if (aMinutes < bMinutes) return -1
|
||||||
|
if (aMinutes > bMinutes) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1
|
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1
|
||||||
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1
|
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1
|
||||||
return 0
|
return 0
|
||||||
@@ -2242,6 +2540,120 @@ const totalPages = computed(() => {
|
|||||||
return Math.ceil(total / pageSize.value) || 0
|
return Math.ceil(total / pageSize.value) || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 账户统计数据(按平台和状态分类)
|
||||||
|
const accountStats = computed(() => {
|
||||||
|
const platforms = [
|
||||||
|
{ value: 'claude', label: 'Claude' },
|
||||||
|
{ value: 'claude-console', label: 'Claude Console' },
|
||||||
|
{ value: 'gemini', label: 'Gemini' },
|
||||||
|
{ value: 'gemini-api', label: 'Gemini API' },
|
||||||
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
|
{ value: 'azure_openai', label: 'Azure OpenAI' },
|
||||||
|
{ value: 'bedrock', label: 'Bedrock' },
|
||||||
|
{ value: 'openai-responses', label: 'OpenAI-Responses' },
|
||||||
|
{ value: 'ccr', label: 'CCR' },
|
||||||
|
{ value: 'droid', label: 'Droid' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return platforms
|
||||||
|
.map((p) => {
|
||||||
|
const platformAccounts = accounts.value.filter((acc) => acc.platform === p.value)
|
||||||
|
|
||||||
|
// 先筛选限流账户(优先级最高)
|
||||||
|
const rateLimitedAccounts = platformAccounts.filter((acc) => isAccountRateLimited(acc))
|
||||||
|
|
||||||
|
// 正常: 非限流 && 激活 && 非阻止 && 可调度
|
||||||
|
const normal = platformAccounts.filter((acc) => {
|
||||||
|
const isRateLimited = isAccountRateLimited(acc)
|
||||||
|
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable !== false
|
||||||
|
}).length
|
||||||
|
|
||||||
|
// 不可调度: 非限流 && 激活 && 非阻止 && 不可调度
|
||||||
|
const unschedulable = platformAccounts.filter((acc) => {
|
||||||
|
const isRateLimited = isAccountRateLimited(acc)
|
||||||
|
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable === false
|
||||||
|
}).length
|
||||||
|
|
||||||
|
// 其他: 非限流的异常账户(未激活或被阻止)
|
||||||
|
const other = platformAccounts.filter((acc) => {
|
||||||
|
const isRateLimited = isAccountRateLimited(acc)
|
||||||
|
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
return !isRateLimited && (!acc.isActive || isBlocked)
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const rateLimit0_1h = rateLimitedAccounts.filter((acc) => {
|
||||||
|
const minutes = getRateLimitRemainingMinutes(acc)
|
||||||
|
return minutes > 0 && minutes <= 60
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const rateLimit1_5h = rateLimitedAccounts.filter((acc) => {
|
||||||
|
const minutes = getRateLimitRemainingMinutes(acc)
|
||||||
|
return minutes > 60 && minutes <= 300
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const rateLimit5_12h = rateLimitedAccounts.filter((acc) => {
|
||||||
|
const minutes = getRateLimitRemainingMinutes(acc)
|
||||||
|
return minutes > 300 && minutes <= 720
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const rateLimit12_24h = rateLimitedAccounts.filter((acc) => {
|
||||||
|
const minutes = getRateLimitRemainingMinutes(acc)
|
||||||
|
return minutes > 720 && minutes <= 1440
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const rateLimitOver24h = rateLimitedAccounts.filter((acc) => {
|
||||||
|
const minutes = getRateLimitRemainingMinutes(acc)
|
||||||
|
return minutes > 1440
|
||||||
|
}).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: p.value,
|
||||||
|
platformLabel: p.label,
|
||||||
|
normal,
|
||||||
|
unschedulable,
|
||||||
|
rateLimit0_1h,
|
||||||
|
rateLimit1_5h,
|
||||||
|
rateLimit5_12h,
|
||||||
|
rateLimit12_24h,
|
||||||
|
rateLimitOver24h,
|
||||||
|
other,
|
||||||
|
total: platformAccounts.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((stat) => stat.total > 0) // 只显示有账户的平台
|
||||||
|
})
|
||||||
|
|
||||||
|
// 账户统计合计
|
||||||
|
const accountStatsTotal = computed(() => {
|
||||||
|
return accountStats.value.reduce(
|
||||||
|
(total, stat) => {
|
||||||
|
total.normal += stat.normal
|
||||||
|
total.unschedulable += stat.unschedulable
|
||||||
|
total.rateLimit0_1h += stat.rateLimit0_1h
|
||||||
|
total.rateLimit1_5h += stat.rateLimit1_5h
|
||||||
|
total.rateLimit5_12h += stat.rateLimit5_12h
|
||||||
|
total.rateLimit12_24h += stat.rateLimit12_24h
|
||||||
|
total.rateLimitOver24h += stat.rateLimitOver24h
|
||||||
|
total.other += stat.other
|
||||||
|
total.total += stat.total
|
||||||
|
return total
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normal: 0,
|
||||||
|
unschedulable: 0,
|
||||||
|
rateLimit0_1h: 0,
|
||||||
|
rateLimit1_5h: 0,
|
||||||
|
rateLimit5_12h: 0,
|
||||||
|
rateLimit12_24h: 0,
|
||||||
|
rateLimitOver24h: 0,
|
||||||
|
other: 0,
|
||||||
|
total: 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const pageNumbers = computed(() => {
|
const pageNumbers = computed(() => {
|
||||||
const total = totalPages.value
|
const total = totalPages.value
|
||||||
const current = currentPage.value
|
const current = currentPage.value
|
||||||
@@ -2355,190 +2767,14 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
try {
|
try {
|
||||||
// 构建查询参数(用于其他筛选情况)
|
// 构建查询参数(用于其他筛选情况)
|
||||||
const params = {}
|
const params = {}
|
||||||
if (platformFilter.value !== 'all') {
|
if (platformFilter.value !== 'all' && !platformGroupMap[platformFilter.value]) {
|
||||||
params.platform = platformFilter.value
|
params.platform = platformFilter.value
|
||||||
}
|
}
|
||||||
if (groupFilter.value !== 'all') {
|
if (groupFilter.value !== 'all') {
|
||||||
params.groupId = groupFilter.value
|
params.groupId = groupFilter.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据平台筛选决定需要请求哪些接口
|
const platformsToFetch = getPlatformsForFilter(platformFilter.value)
|
||||||
const requests = []
|
|
||||||
|
|
||||||
if (platformFilter.value === 'all') {
|
|
||||||
// 请求所有平台
|
|
||||||
requests.push(
|
|
||||||
apiClient.get('/admin/claude-accounts', { params }),
|
|
||||||
apiClient.get('/admin/claude-console-accounts', { params }),
|
|
||||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
|
||||||
apiClient.get('/admin/gemini-accounts', { params }),
|
|
||||||
apiClient.get('/admin/openai-accounts', { params }),
|
|
||||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
|
||||||
apiClient.get('/admin/openai-responses-accounts', { params }),
|
|
||||||
apiClient.get('/admin/ccr-accounts', { params }),
|
|
||||||
apiClient.get('/admin/droid-accounts', { params }),
|
|
||||||
apiClient.get('/admin/gemini-api-accounts', { params })
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 只请求指定平台,其他平台设为null占位
|
|
||||||
switch (platformFilter.value) {
|
|
||||||
case 'claude':
|
|
||||||
requests.push(
|
|
||||||
apiClient.get('/admin/claude-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'claude-console':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
apiClient.get('/admin/claude-console-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'bedrock':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'gemini':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
apiClient.get('/admin/gemini-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'openai':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
apiClient.get('/admin/openai-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'azure_openai':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'openai-responses':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
|
||||||
apiClient.get('/admin/openai-responses-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'ccr':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
apiClient.get('/admin/ccr-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'droid':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
apiClient.get('/admin/droid-accounts', { params }),
|
|
||||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'gemini-api':
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
|
||||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
|
||||||
apiClient.get('/admin/gemini-api-accounts', { params })
|
|
||||||
)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
// 默认情况下返回空数组
|
|
||||||
requests.push(
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] }),
|
|
||||||
Promise.resolve({ success: true, data: [] })
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用缓存机制加载绑定计数和分组数据(不再加载完整的 API Keys 数据)
|
// 使用缓存机制加载绑定计数和分组数据(不再加载完整的 API Keys 数据)
|
||||||
await Promise.all([loadBindingCounts(forceReload), loadAccountGroups(forceReload)])
|
await Promise.all([loadBindingCounts(forceReload), loadAccountGroups(forceReload)])
|
||||||
@@ -2546,125 +2782,137 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
||||||
// await loadGroupMembers(forceReload)
|
// await loadGroupMembers(forceReload)
|
||||||
|
|
||||||
const [
|
const platformResults = await Promise.all(
|
||||||
claudeData,
|
platformsToFetch.map(async (platform) => {
|
||||||
claudeConsoleData,
|
const handler = platformRequestHandlers[platform]
|
||||||
bedrockData,
|
if (!handler) {
|
||||||
geminiData,
|
return { platform, success: true, data: [] }
|
||||||
openaiData,
|
}
|
||||||
azureOpenaiData,
|
|
||||||
openaiResponsesData,
|
try {
|
||||||
ccrData,
|
const res = await handler(params)
|
||||||
droidData,
|
return { platform, success: res?.success, data: res?.data }
|
||||||
geminiApiData
|
} catch (error) {
|
||||||
] = await Promise.all(requests)
|
console.debug(`Failed to load ${platform} accounts:`, error)
|
||||||
|
return { platform, success: false, data: [] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const allAccounts = []
|
const allAccounts = []
|
||||||
|
const counts = bindingCounts.value || {}
|
||||||
|
let openaiResponsesRaw = []
|
||||||
|
|
||||||
// 获取绑定计数数据
|
const appendAccounts = (platform, data) => {
|
||||||
const counts = bindingCounts.value
|
if (!data || data.length === 0) return
|
||||||
|
|
||||||
if (claudeData.success) {
|
switch (platform) {
|
||||||
const claudeAccounts = (claudeData.data || []).map((acc) => {
|
case 'claude': {
|
||||||
// 从绑定计数缓存获取数量
|
const items = data.map((acc) => {
|
||||||
const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0
|
const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'claude', boundApiKeysCount }
|
return { ...acc, platform: 'claude', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
allAccounts.push(...claudeAccounts)
|
allAccounts.push(...items)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
case 'claude-console': {
|
||||||
if (claudeConsoleData.success) {
|
const items = data.map((acc) => {
|
||||||
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
|
|
||||||
// 从绑定计数缓存获取数量
|
|
||||||
const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0
|
const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
allAccounts.push(...claudeConsoleAccounts)
|
allAccounts.push(...items)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
case 'bedrock': {
|
||||||
if (bedrockData.success) {
|
const items = data.map((acc) => ({ ...acc, platform: 'bedrock', boundApiKeysCount: 0 }))
|
||||||
const bedrockAccounts = (bedrockData.data || []).map((acc) => {
|
allAccounts.push(...items)
|
||||||
// Bedrock账户暂时不支持直接绑定
|
break
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 }
|
|
||||||
})
|
|
||||||
allAccounts.push(...bedrockAccounts)
|
|
||||||
}
|
}
|
||||||
|
case 'gemini': {
|
||||||
if (geminiData.success) {
|
const items = data.map((acc) => {
|
||||||
const geminiAccounts = (geminiData.data || []).map((acc) => {
|
|
||||||
// 从绑定计数缓存获取数量
|
|
||||||
const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0
|
const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
allAccounts.push(...geminiAccounts)
|
allAccounts.push(...items)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (openaiData.success) {
|
case 'openai': {
|
||||||
const openaiAccounts = (openaiData.data || []).map((acc) => {
|
const items = data.map((acc) => {
|
||||||
// 从绑定计数缓存获取数量
|
|
||||||
const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0
|
const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'openai', boundApiKeysCount }
|
return { ...acc, platform: 'openai', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
allAccounts.push(...openaiAccounts)
|
allAccounts.push(...items)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (azureOpenaiData && azureOpenaiData.success) {
|
case 'azure_openai': {
|
||||||
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
|
const items = data.map((acc) => {
|
||||||
// 从绑定计数缓存获取数量
|
|
||||||
const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0
|
const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
|
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
allAccounts.push(...azureOpenaiAccounts)
|
allAccounts.push(...items)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
case 'openai-responses': {
|
||||||
if (openaiResponsesData && openaiResponsesData.success) {
|
openaiResponsesRaw = data
|
||||||
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => {
|
break
|
||||||
// 从绑定计数缓存获取数量
|
|
||||||
// OpenAI-Responses账户使用 responses: 前缀
|
|
||||||
const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
|
|
||||||
})
|
|
||||||
allAccounts.push(...openaiResponsesAccounts)
|
|
||||||
}
|
}
|
||||||
|
case 'ccr': {
|
||||||
// CCR 账户
|
const items = data.map((acc) => ({ ...acc, platform: 'ccr', boundApiKeysCount: 0 }))
|
||||||
if (ccrData && ccrData.success) {
|
allAccounts.push(...items)
|
||||||
const ccrAccounts = (ccrData.data || []).map((acc) => {
|
break
|
||||||
// CCR 不支持 API Key 绑定,固定为 0
|
|
||||||
return { ...acc, platform: 'ccr', boundApiKeysCount: 0 }
|
|
||||||
})
|
|
||||||
allAccounts.push(...ccrAccounts)
|
|
||||||
}
|
}
|
||||||
|
case 'droid': {
|
||||||
// Droid 账户
|
const items = data.map((acc) => {
|
||||||
if (droidData && droidData.success) {
|
|
||||||
const droidAccounts = (droidData.data || []).map((acc) => {
|
|
||||||
// 从绑定计数缓存获取数量
|
|
||||||
const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0
|
const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0
|
||||||
return {
|
return { ...acc, platform: 'droid', boundApiKeysCount }
|
||||||
...acc,
|
|
||||||
platform: 'droid',
|
|
||||||
boundApiKeysCount
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
allAccounts.push(...droidAccounts)
|
allAccounts.push(...items)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
case 'gemini-api': {
|
||||||
// Gemini API 账户
|
const items = data.map((acc) => {
|
||||||
if (geminiApiData && geminiApiData.success) {
|
|
||||||
const geminiApiAccounts = (geminiApiData.data || []).map((acc) => {
|
|
||||||
// 从绑定计数缓存获取数量
|
|
||||||
// Gemini-API账户使用 api: 前缀
|
|
||||||
const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0
|
const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0
|
||||||
// 后端已经包含了groupInfos,直接使用
|
|
||||||
return { ...acc, platform: 'gemini-api', boundApiKeysCount }
|
return { ...acc, platform: 'gemini-api', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
allAccounts.push(...geminiApiAccounts)
|
allAccounts.push(...items)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
platformResults.forEach(({ platform, success, data }) => {
|
||||||
|
if (success) {
|
||||||
|
appendAccounts(platform, data || [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (openaiResponsesRaw.length > 0) {
|
||||||
|
let autoRecoveryConfigMap = {}
|
||||||
|
try {
|
||||||
|
const configsRes = await apiClient.get(
|
||||||
|
'/admin/openai-responses-accounts/auto-recovery-configs'
|
||||||
|
)
|
||||||
|
if (configsRes.success && Array.isArray(configsRes.data)) {
|
||||||
|
autoRecoveryConfigMap = configsRes.data.reduce((map, config) => {
|
||||||
|
if (config?.accountId) {
|
||||||
|
map[config.accountId] = config
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Failed to load auto-recovery configs:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsesAccounts = openaiResponsesRaw.map((acc) => {
|
||||||
|
const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0
|
||||||
|
const autoRecoveryConfig = autoRecoveryConfigMap[acc.id] || acc.autoRecoveryConfig || null
|
||||||
|
return { ...acc, platform: 'openai-responses', boundApiKeysCount, autoRecoveryConfig }
|
||||||
|
})
|
||||||
|
|
||||||
|
allAccounts.push(...responsesAccounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据分组筛选器过滤账户
|
// 根据分组筛选器过滤账户
|
||||||
@@ -3014,6 +3262,58 @@ const formatRateLimitTime = (minutes) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查账户是否被限流
|
||||||
|
const isAccountRateLimited = (account) => {
|
||||||
|
if (!account) return false
|
||||||
|
|
||||||
|
// 检查 rateLimitStatus
|
||||||
|
if (account.rateLimitStatus) {
|
||||||
|
if (typeof account.rateLimitStatus === 'string' && account.rateLimitStatus === 'limited') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof account.rateLimitStatus === 'object' &&
|
||||||
|
account.rateLimitStatus.isRateLimited === true
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取限流剩余时间(分钟)
|
||||||
|
const getRateLimitRemainingMinutes = (account) => {
|
||||||
|
if (!account || !account.rateLimitStatus) return 0
|
||||||
|
|
||||||
|
if (typeof account.rateLimitStatus === 'object') {
|
||||||
|
const status = account.rateLimitStatus
|
||||||
|
if (Number.isFinite(status.minutesRemaining)) {
|
||||||
|
return Math.max(0, Math.ceil(status.minutesRemaining))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(status.remainingMinutes)) {
|
||||||
|
return Math.max(0, Math.ceil(status.remainingMinutes))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(status.remainingSeconds)) {
|
||||||
|
return Math.max(0, Math.ceil(status.remainingSeconds / 60))
|
||||||
|
}
|
||||||
|
if (status.rateLimitResetAt) {
|
||||||
|
const diffMs = new Date(status.rateLimitResetAt).getTime() - Date.now()
|
||||||
|
return diffMs > 0 ? Math.ceil(diffMs / 60000) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 rateLimitUntil 字段,计算剩余时间
|
||||||
|
if (account.rateLimitUntil) {
|
||||||
|
const now = new Date().getTime()
|
||||||
|
const untilTime = new Date(account.rateLimitUntil).getTime()
|
||||||
|
const diff = untilTime - now
|
||||||
|
return diff > 0 ? Math.ceil(diff / 60000) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// 打开创建账户模态框
|
// 打开创建账户模态框
|
||||||
const openCreateAccountModal = () => {
|
const openCreateAccountModal = () => {
|
||||||
newAccountPlatform.value = null // 重置选择的平台
|
newAccountPlatform.value = null // 重置选择的平台
|
||||||
|
|||||||
@@ -116,6 +116,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 模型筛选器 -->
|
||||||
|
<div class="group relative min-w-[140px]">
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<div class="relative">
|
||||||
|
<CustomDropdown
|
||||||
|
v-model="selectedModels"
|
||||||
|
icon="fa-cube"
|
||||||
|
icon-color="text-orange-500"
|
||||||
|
:multiple="true"
|
||||||
|
:options="modelOptions"
|
||||||
|
placeholder="所有模型"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="selectedModels.length > 0"
|
||||||
|
class="absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-xs text-white shadow-sm"
|
||||||
|
>
|
||||||
|
{{ selectedModels.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 搜索模式与搜索框 -->
|
<!-- 搜索模式与搜索框 -->
|
||||||
<div class="flex min-w-[240px] flex-col gap-2 sm:flex-row sm:items-center">
|
<div class="flex min-w-[240px] flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<div class="sm:w-44">
|
<div class="sm:w-44">
|
||||||
@@ -2220,6 +2243,10 @@ const selectedApiKeyForDetail = ref(null)
|
|||||||
const selectedTagFilter = ref('')
|
const selectedTagFilter = ref('')
|
||||||
const availableTags = ref([])
|
const availableTags = ref([])
|
||||||
|
|
||||||
|
// 模型筛选相关
|
||||||
|
const selectedModels = ref([])
|
||||||
|
const availableModels = ref([])
|
||||||
|
|
||||||
// 搜索相关
|
// 搜索相关
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searchMode = ref('apiKey')
|
const searchMode = ref('apiKey')
|
||||||
@@ -2236,6 +2263,14 @@ const tagOptions = computed(() => {
|
|||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const modelOptions = computed(() => {
|
||||||
|
return availableModels.value.map((model) => ({
|
||||||
|
value: model,
|
||||||
|
label: model,
|
||||||
|
icon: 'fa-cube'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
const selectedTagCount = computed(() => {
|
const selectedTagCount = computed(() => {
|
||||||
if (!selectedTagFilter.value) return 0
|
if (!selectedTagFilter.value) return 0
|
||||||
return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value))
|
return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value))
|
||||||
@@ -2474,6 +2509,18 @@ const loadAccounts = async (forceRefresh = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载已使用的模型列表
|
||||||
|
const loadUsedModels = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get('/admin/api-keys/used-models')
|
||||||
|
if (data.success) {
|
||||||
|
availableModels.value = data.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load used models:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载API Keys(使用后端分页)
|
// 加载API Keys(使用后端分页)
|
||||||
const loadApiKeys = async (clearStatsCache = true) => {
|
const loadApiKeys = async (clearStatsCache = true) => {
|
||||||
apiKeysLoading.value = true
|
apiKeysLoading.value = true
|
||||||
@@ -2502,6 +2549,11 @@ const loadApiKeys = async (clearStatsCache = true) => {
|
|||||||
params.set('tag', selectedTagFilter.value)
|
params.set('tag', selectedTagFilter.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模型筛选参数
|
||||||
|
if (selectedModels.value.length > 0) {
|
||||||
|
params.set('models', selectedModels.value.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
// 排序参数(支持费用排序)
|
// 排序参数(支持费用排序)
|
||||||
const validSortFields = [
|
const validSortFields = [
|
||||||
'name',
|
'name',
|
||||||
@@ -4711,6 +4763,12 @@ watch(selectedTagFilter, () => {
|
|||||||
loadApiKeys(false)
|
loadApiKeys(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听模型筛选变化
|
||||||
|
watch(selectedModels, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadApiKeys(false)
|
||||||
|
})
|
||||||
|
|
||||||
// 监听排序变化,重新加载数据
|
// 监听排序变化,重新加载数据
|
||||||
watch([apiKeysSortBy, apiKeysSortOrder], () => {
|
watch([apiKeysSortBy, apiKeysSortOrder], () => {
|
||||||
loadApiKeys(false)
|
loadApiKeys(false)
|
||||||
@@ -4745,7 +4803,7 @@ onMounted(async () => {
|
|||||||
fetchCostSortStatus()
|
fetchCostSortStatus()
|
||||||
|
|
||||||
// 先加载 API Keys(优先显示列表)
|
// 先加载 API Keys(优先显示列表)
|
||||||
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()])
|
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys(), loadUsedModels()])
|
||||||
|
|
||||||
// 初始化全选状态
|
// 初始化全选状态
|
||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
<i class="fas fa-bell mr-2"></i>
|
<i class="fas fa-bell mr-2"></i>
|
||||||
通知设置
|
通知设置
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'border-b-2 pb-2 text-sm font-medium transition-colors',
|
||||||
|
activeSection === 'claude'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
@click="activeSection = 'claude'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot mr-2"></i>
|
||||||
|
Claude 转发
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -629,6 +641,182 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 转发配置部分 -->
|
||||||
|
<div v-show="activeSection === 'claude'">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="claudeConfigLoading" class="py-12 text-center">
|
||||||
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">正在加载配置...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Claude Code 客户端限制 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-orange-500 to-amber-600 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-terminal"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
仅允许 Claude Code 客户端
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
启用后,所有
|
||||||
|
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||||
|
>/api/v1/messages</code
|
||||||
|
>
|
||||||
|
和
|
||||||
|
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||||
|
>/claude/v1/messages</code
|
||||||
|
>
|
||||||
|
端点将强制验证 Claude Code CLI 客户端
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.claudeCodeOnlyEnabled"
|
||||||
|
class="peer sr-only"
|
||||||
|
type="checkbox"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-orange-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-orange-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-orange-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle mt-0.5 text-amber-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
此设置与 API Key 级别的客户端限制是 <strong>OR 逻辑</strong>:全局启用或 API
|
||||||
|
Key 设置中启用,都会执行 Claude Code 验证。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局会话绑定 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
强制会话绑定
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.globalSessionBindingEnabled"
|
||||||
|
class="peer sr-only"
|
||||||
|
type="checkbox"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-purple-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-purple-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 绑定配置详情(仅在启用时显示) -->
|
||||||
|
<div v-if="claudeConfig.globalSessionBindingEnabled" class="mt-6 space-y-4">
|
||||||
|
<!-- 绑定有效期 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-clock mr-2 text-gray-400"></i>
|
||||||
|
绑定有效期(天)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.sessionBindingTtlDays"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="365"
|
||||||
|
min="1"
|
||||||
|
placeholder="30"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
会话绑定到账户后的有效时间,过期后会自动解除绑定
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示消息 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2 text-gray-400"></i>
|
||||||
|
旧会话污染提示
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="claudeConfig.sessionBindingErrorMessage"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
placeholder="你的本地session已污染,请清理后使用。"
|
||||||
|
rows="2"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
当检测到为旧的sessionId且未在系统中有调度记录时提示,返回给客户端的错误消息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg bg-purple-50 p-4 dark:bg-purple-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mt-0.5 text-purple-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>工作原理:</strong>系统会提取请求中的原始 session ID (来自
|
||||||
|
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||||
|
>metadata.user_id</code
|
||||||
|
>), 并将其与首次调度的账户绑定。后续使用相同 session ID
|
||||||
|
的请求将自动路由到同一账户。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>新会话识别:</strong>如果绑定会话历史中没有该sessionId但请求中
|
||||||
|
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||||
|
>messages.length > 1</code
|
||||||
|
>, 系统会认为这是一个污染的会话并拒绝请求。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 配置更新信息 -->
|
||||||
|
<div
|
||||||
|
v-if="claudeConfig.updatedAt"
|
||||||
|
class="rounded-lg bg-gray-50 p-4 text-sm text-gray-500 dark:bg-gray-700/50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-history mr-2"></i>
|
||||||
|
最后更新:{{ formatDateTime(claudeConfig.updatedAt) }}
|
||||||
|
<span v-if="claudeConfig.updatedBy" class="ml-2">
|
||||||
|
由 <strong>{{ claudeConfig.updatedBy }}</strong> 修改
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1274,6 +1462,17 @@ const webhookConfig = ref({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Claude 转发配置
|
||||||
|
const claudeConfigLoading = ref(false)
|
||||||
|
const claudeConfig = ref({
|
||||||
|
claudeCodeOnlyEnabled: false,
|
||||||
|
globalSessionBindingEnabled: false,
|
||||||
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: 30,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
})
|
||||||
|
|
||||||
// 平台表单相关
|
// 平台表单相关
|
||||||
const showAddPlatformModal = ref(false)
|
const showAddPlatformModal = ref(false)
|
||||||
const editingPlatform = ref(null)
|
const editingPlatform = ref(null)
|
||||||
@@ -1311,6 +1510,8 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
|
|||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
if (newSection === 'webhook') {
|
if (newSection === 'webhook') {
|
||||||
await loadWebhookConfig()
|
await loadWebhookConfig()
|
||||||
|
} else if (newSection === 'claude') {
|
||||||
|
await loadClaudeConfig()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1522,6 +1723,67 @@ const saveWebhookConfig = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载 Claude 转发配置
|
||||||
|
const loadClaudeConfig = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
claudeConfigLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/claude-relay-config', {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
claudeConfig.value = {
|
||||||
|
claudeCodeOnlyEnabled: response.config?.claudeCodeOnlyEnabled ?? false,
|
||||||
|
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
|
||||||
|
sessionBindingErrorMessage:
|
||||||
|
response.config?.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30,
|
||||||
|
updatedAt: response.config?.updatedAt || null,
|
||||||
|
updatedBy: response.config?.updatedBy || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
showToast('获取 Claude 转发配置失败', 'error')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
if (isMounted.value) {
|
||||||
|
claudeConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 Claude 转发配置
|
||||||
|
const saveClaudeConfig = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
claudeCodeOnlyEnabled: claudeConfig.value.claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled: claudeConfig.value.globalSessionBindingEnabled,
|
||||||
|
sessionBindingErrorMessage: claudeConfig.value.sessionBindingErrorMessage,
|
||||||
|
sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.put('/admin/claude-relay-config', payload, {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
claudeConfig.value = {
|
||||||
|
...claudeConfig.value,
|
||||||
|
updatedAt: response.config?.updatedAt || new Date().toISOString(),
|
||||||
|
updatedBy: response.config?.updatedBy || null
|
||||||
|
}
|
||||||
|
showToast('Claude 转发配置已保存', 'success')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
showToast('保存 Claude 转发配置失败', 'error')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证 URL
|
// 验证 URL
|
||||||
const validateUrl = () => {
|
const validateUrl = () => {
|
||||||
// Bark和SMTP平台不需要验证URL
|
// Bark和SMTP平台不需要验证URL
|
||||||
|
|||||||
Reference in New Issue
Block a user