mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0035f8cb4f | ||
|
|
d49cc0cec8 | ||
|
|
c4d6ab97f2 | ||
|
|
7053d5f1ac | ||
|
|
24796fc889 | ||
|
|
201d95c84e | ||
|
|
b978d864e3 | ||
|
|
175c041e5a | ||
|
|
b441506199 | ||
|
|
eb2341fb16 | ||
|
|
e89e2964e7 | ||
|
|
b3e27e9f15 | ||
|
|
d0b397b45a | ||
|
|
195e42e0a5 | ||
|
|
ebecee4c6f | ||
|
|
0607322cc7 | ||
|
|
0828746281 | ||
|
|
e1df90684a | ||
|
|
f74f77ef65 | ||
|
|
b63c3217bc | ||
|
|
93497cc13c | ||
|
|
2429bad2b7 |
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
|
||||||
|
|
||||||
# 🔧 复制并设置启动脚本权限
|
# 🔧 复制并设置启动脚本权限
|
||||||
|
|||||||
@@ -226,8 +226,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 +259,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) {
|
||||||
@@ -991,7 +1047,10 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
|
|
||||||
// 获取 API Key 配置信息以判断是否需要窗口数据
|
// 获取 API Key 配置信息以判断是否需要窗口数据
|
||||||
const apiKey = await redis.getApiKey(keyId)
|
const apiKey = await redis.getApiKey(keyId)
|
||||||
if (apiKey && apiKey.rateLimitWindow > 0) {
|
// 显式转换为整数,与 apiStats.js 保持一致,避免字符串比较问题
|
||||||
|
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||||||
|
|
||||||
|
if (rateLimitWindow > 0) {
|
||||||
const costCountKey = `rate_limit:cost:${keyId}`
|
const costCountKey = `rate_limit:cost:${keyId}`
|
||||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||||
|
|
||||||
@@ -1002,7 +1061,7 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
if (windowStart) {
|
if (windowStart) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
windowStartTime = parseInt(windowStart)
|
windowStartTime = parseInt(windowStart)
|
||||||
const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒
|
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||||
windowEndTime = windowStartTime + windowDuration
|
windowEndTime = windowStartTime + windowDuration
|
||||||
|
|
||||||
// 如果窗口还有效
|
// 如果窗口还有效
|
||||||
@@ -1016,7 +1075,7 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
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,7 @@ 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')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -35,6 +36,7 @@ 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('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
@@ -824,7 +824,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({
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap 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
|
: option.isGroup
|
||||||
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
|
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<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 dark:text-blue-400"
|
class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400"
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,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: {
|
||||||
@@ -92,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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,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 : ''
|
||||||
})
|
})
|
||||||
@@ -120,9 +135,21 @@ 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 = () => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user