Compare commits

..

20 Commits

Author SHA1 Message Date
github-actions[bot]
c4d6ab97f2 chore: sync VERSION file with release v1.1.225 [skip ci] 2025-12-08 00:26:14 +00:00
Wesley Liddick
7053d5f1ac Merge pull request #778 from miraserver/fix/droid-user-agent-and-provider
[fix] Droid: dynamic x-api-provider and custom User-Agent support
2025-12-07 19:25:56 -05:00
John Doe
24796fc889 fix: format droidAccountService.js with Prettier 2025-12-07 21:14:42 +03:00
John Doe
201d95c84e [fix] Droid: dynamic x-api-provider and custom User-Agent support
- Dynamic x-api-provider selection for OpenAI endpoint based on model
  - Models with '-max' suffix use 'openai' provider
  - Other models use 'azure_openai' provider
  - Fixes gpt-5.1-codex-max model compatibility issue

- Update default User-Agent to factory-cli/0.32.1

- Add custom User-Agent field for Droid accounts
  - Backend: userAgent field in createAccount and updateAccount
  - Frontend: User-Agent input in account creation/edit UI
  - Supports all Droid auth modes: OAuth, Manual, API Key

This resolves the issue where gpt-5.1-codex-max failed with 'Azure OpenAI only supports...' error due to incorrect provider header.
2025-12-07 21:08:48 +03:00
Wesley Liddick
b978d864e3 Merge pull request #776 from miraserver/fix/droid-openai-cache-tokens [skip ci]
[fix] Add cache token capture for Droid OpenAI endpoint
2025-12-06 22:46:54 -05:00
Wesley Liddick
175c041e5a Merge pull request #774 from mrlitong/main [skip ci]
chore(docker): optimize build cache and install flow
2025-12-06 22:45:12 -05:00
Wesley Liddick
b441506199 Merge branch 'main' into main 2025-12-06 22:44:44 -05:00
Wesley Liddick
eb2341fb16 Merge pull request #771 from DaydreamCoding/patch-2 [skip ci]
Update model filtering to use blacklist approach
2025-12-06 22:43:52 -05:00
Wesley Liddick
e89e2964e7 Merge pull request #773 from DaydreamCoding/feature/concurrency [skip ci]
feat(concurrencyManagement): implement concurrency status management …
2025-12-06 22:43:29 -05:00
John Doe
b3e27e9f15 [fix] Add cache token capture for Droid OpenAI endpoint
The _parseOpenAIUsageFromSSE method was not capturing cache-related
tokens (cache_read_input_tokens, cache_creation_input_tokens) from
OpenAI format responses, while the Anthropic endpoint correctly
captured them.

This fix adds extraction of:
- cached_tokens from input_tokens_details
- cache_creation_input_tokens from both input_tokens_details and
  top-level usage object

This ensures proper cache statistics tracking and cost calculation
for OpenAI models (like GPT-5/Codex) when using the Droid provider.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 23:00:54 +03:00
github-actions[bot]
d0b397b45a chore: sync VERSION file with release v1.1.221 [skip ci] 2025-12-06 11:46:10 +00:00
litongtongxue@gmail.com
195e42e0a5 chore(docker): optimize build cache and install flow 2025-12-06 03:45:43 -08:00
github-actions[bot]
ebecee4c6f chore: sync VERSION file with release v1.1.224 [skip ci] 2025-12-06 11:00:48 +00:00
Wesley Liddick
0607322cc7 Merge pull request #765 from SunSeekerX/feature_key_model_filter
feat(api-keys): 添加模型筛选功能
2025-12-06 06:00:32 -05:00
SunSeekerX
0828746281 fix: 修复 ESLint 错误 - if 语句花括号和箭头函数简写 2025-12-06 18:30:44 +08:00
SunSeekerX
e1df90684a fix: 合并冲突 - 保留多选支持并添加暗黑模式样式 2025-12-06 18:28:03 +08:00
DaydreamCoding
f74f77ef65 feat(concurrencyManagement): implement concurrency status management API and enhance concurrency handling in middleware 2025-12-06 17:23:42 +08:00
QTom
b63c3217bc Update model filtering to use blacklist approach
Change model filtering logic to blacklist restricted models.
2025-12-06 14:20:06 +08:00
SunSeekerX
93497cc13c fix: 修复 ESLint vue/attributes-order 属性顺序问题 2025-12-05 13:49:19 +08:00
SunSeekerX
2429bad2b7 feat(api-keys): 添加模型筛选功能 2025-12-05 13:44:09 +08:00
13 changed files with 762 additions and 37 deletions

View File

@@ -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
# 🔧 复制并设置启动脚本权限 # 🔧 复制并设置启动脚本权限

View File

@@ -1 +1 @@
1.1.223 1.1.225

View File

@@ -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) => {

View File

@@ -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 IDsOR 逻辑)
*/
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()

View File

@@ -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) {

View 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

View File

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

View File

@@ -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({

View File

@@ -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

View File

@@ -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,9 +1006,15 @@ class DroidRelayService {
} }
} }
// OpenAI 特定头 // OpenAI 特定头 - 根据模型动态选择 provider
if (endpointType === 'openai') { if (endpointType === 'openai') {
headers['x-api-provider'] = 'azure_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'
}
} }
// Comm 端点根据模型动态设置 provider // Comm 端点根据模型动态设置 provider

View File

@@ -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" />

View File

@@ -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) => {
emit('update:modelValue', option.value) if (props.multiple) {
emit('change', option.value) const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
closeDropdown() 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('change', option.value)
closeDropdown()
}
} }
const updateDropdownPosition = () => { const updateDropdownPosition = () => {

View File

@@ -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()