Compare commits

...

61 Commits

Author SHA1 Message Date
github-actions[bot]
0035f8cb4f chore: sync VERSION file with release v1.1.226 [skip ci] 2025-12-08 01:45:46 +00:00
shaw
d49cc0cec8 fix: 修复api-keys页面窗口费率显示问题 2025-12-08 09:45:19 +08:00
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
github-actions[bot]
d81a16b98d chore: sync VERSION file with release v1.1.223 [skip ci] 2025-12-06 03:01:17 +00:00
shaw
30727be92f chore: trigger release [force release] 2025-12-06 11:00:59 +08:00
shaw
b8a6cc627a Merge branch 'lusipad/main' 2025-12-06 10:56:57 +08:00
Wesley Liddick
01c63bf5df Merge pull request #760 from IanShaw027/upstream-pr-account-full [skip ci]
feat: 增强账户管理功能
2025-12-05 21:45:37 -05:00
Wesley Liddick
4317962955 Merge pull request #758 from IanShaw027/upstream-pr-temp-unavailable [skip ci]
feat: 添加上游不稳定错误检测与账户临时不可用机制
2025-12-05 21:44:39 -05:00
Wesley Liddick
b66fd7f655 Merge pull request #766 from atoz03/feature/account-detail-timeline [skip ci]
feat(account): enhance detail timeline & remove redundant entries
2025-12-05 21:43:14 -05:00
Wesley Liddick
ac280ef563 Merge pull request #767 from DaydreamCoding/patch-1 [skip ci]
Refactor model restriction checks to use blacklist
2025-12-05 21:39:56 -05:00
github-actions[bot]
c70070d912 chore: sync VERSION file with release v1.1.227 [skip ci] 2025-12-05 20:54:53 +00:00
lusipad
849d8e047b docs: translate isProAccount function comments to English
- Change function description from Chinese to English
- Translate inline comments (API priority, local config)
- Keep function logic unchanged

This completes the full English comment translation for all modified files.
2025-12-06 04:54:41 +08:00
github-actions[bot]
065aa6d35e chore: sync VERSION file with release v1.1.226 [skip ci] 2025-12-05 20:45:07 +00:00
lusipad
10a1d61427 docs: translate remaining Chinese comments in claudeAccountService.js
- Filter Opus models based on account type and model version
- Free account: does not support any Opus model
- Pro account: only supports Opus 4.5+
- Max account: supports all Opus versions
- Account without subscription info defaults to supported

All logic unchanged, only comment translation.
2025-12-06 04:44:52 +08:00
github-actions[bot]
cfdcc97cc7 chore: sync VERSION file with release v1.1.225 [skip ci] 2025-12-05 20:43:32 +00:00
lusipad
ea053c6a16 docs: convert Chinese comments to English
- Change VERSION判断逻辑 to VERSION LOGIC
- Change ACCOUNT TYPE判断逻辑 to ACCOUNT TYPE LOGIC
- Translate remaining Chinese phrases to English
- Keep all logic unchanged, only translation
2025-12-06 04:43:11 +08:00
github-actions[bot]
84a8fdeaba chore: sync VERSION file with release v1.1.224 [skip ci] 2025-12-05 20:26:31 +00:00
lusipad
c1c941aa4c fix(opus): fix PR#762 review issues and add maintenance comments
- Fix regex to support 2-digit minor versions (e.g., opus-4-10)
- Prevent matching 8-digit dates as minor version numbers
- Unify English comments for consistency across codebase
- Extract isProAccount() helper to eliminate code duplication
- Add detailed version logic comments for future maintenance

Changes:
- VERSION LOGIC: Opus 4.5+ returns true (Pro eligible), <4.5 returns false (Max only)
- ACCOUNT RESTRICTIONS: Free=no Opus, Pro=Opus 4.5+, Max=all Opus versions
- REGEX FIX: (\d{1,2}) limits minor version to 1-2 digits, avoiding date confusion

Test: All 21 tests pass
Format: Prettier validated
2025-12-06 04:26:11 +08:00
atoz03
f78e376dea fix:该文件的代码格式不符合 Prettier 规范 2025-12-05 22:29:45 +08:00
github-actions[bot]
530d38e4a4 chore: sync VERSION file with release v1.1.223 [skip ci] 2025-12-05 14:16:07 +00:00
lusipad
0bf7bfae04 Merge branch 'Wei-Shaw:main' into main 2025-12-05 22:15:54 +08:00
atoz03
fbb660138c fix:调整去重策略
- 调整去重策略(src/routes/admin/usageStats.js):账户筛选改为按 accountId 聚合记录所有出现的 accountType,构建 options 时依次按历史类型解析账号,失败再全量回退,无法解析也保留为筛选项并带 rawTypes,避免渠道改名/删除导致选项被“去
    重”丢失。
  - 解析兜底(src/routes/admin/usageStats.js):resolveAccountInfo 在传入未知类型或过滤后为空时回退尝试全部服务,减轻渠道改名解析不到的问题。
2025-12-05 19:19:52 +08:00
QTom
9c970fda3b Refactor model restriction checks to use blacklist 2025-12-05 17:06:21 +08:00
atoz03
bfa3f528a2 fix:优化了dropdown的弹窗
- ActionDropdown.vue:位置计算调整,优先向右展开并增加 8px 间距,减少遮挡左侧内容;下拉全局互斥仍保留。
  - 账户页面:列表下拉/卡片已无“请求时间线”入口,只保留详情弹窗顶部按钮。
2025-12-05 15:11:12 +08:00
atoz03
9b0d0bee96 fix: 账户时间线入口与路由修复
- 移除账户列表下拉/卡片的时间线入口,仅保留详情弹窗顶部按钮
  - ActionDropdown 全局互斥,避免多菜单堆叠
  - 账户筛选去重,避免“未知渠道”重复泄露
2025-12-05 14:57:34 +08:00
atoz03
ff30bfab82 feat: 账户时间线详情页与接口完善
- 后端新增 /admin/accounts/:accountId/usage-records 接口,支持按账户聚合多 Key 记录并分页筛选、汇总统计
  - 修复 API Key 时间线账户筛选跳过已删除账号,补充账户/Key 辅助解析
  - 前端新增 AccountUsageRecordsView、路由及账户列表“时间线”入口,支持模型/API Key 筛选与 CSV 导出
  - 补装 prettier-plugin-tailwindcss 并完成相关文件格式化
2025-12-05 14:23:25 +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
IanShaw027
a03753030c fix: CustomDropdown组件支持层级结构显示
- 添加动态padding支持indent属性(每级缩进16px)
- 添加isGroup属性支持,分组项显示为粗体带背景
- 修复暗黑模式下选中图标颜色
- 支持二级平台分类的视觉层级展示
2025-12-05 12:47:20 +08:00
lusipad
6ab91c0c75 chore: revert version 2025-12-05 08:25:42 +08:00
github-actions[bot]
675e7b9111 chore: sync VERSION file with release v1.1.221 [skip ci] 2025-12-05 00:15:42 +00:00
lusipad
f82db11e7d Merge pull request #1 from lusipad/feature/opus-45-pro-support
feat(account): 支持 Pro 账号使用 Opus 4.5+ 模型
2025-12-05 08:15:32 +08:00
lusipad
06b18b7186 refactor: extract isProAccount helper for Pro account detection
Extract duplicate Pro account detection logic into a reusable helper
function that handles both API-returned (hasClaudePro) and locally
configured (accountType) data sources.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 08:12:51 +08:00
lusipad
12cb841a64 refactor: address Copilot review feedback
- Import isOpus45OrNewer from modelHelper instead of duplicating code
- Remove invalid 'claude_free' check (only 'free' is used in practice)
2025-12-05 07:56:53 +08:00
lusipad
dc868522cf fix: apply ESLint curly rule and remove useless escape chars 2025-12-05 07:49:55 +08:00
lusipad
b1dc27b5d7 style: format test-official-models.js with Prettier 2025-12-05 07:43:15 +08:00
lusipad
b94bd2b822 feat(account): 支持 Pro 账号使用 Opus 4.5+ 模型
Opus 4.5 已对 Claude Pro 用户开放,调整账户模型限制逻辑:

- Pro 账号:支持 Opus 4.5+,不支持历史版本 (3.x/4.0/4.1)
- Free 账号:不支持任何 Opus 模型
- Max 账号:支持所有 Opus 版本

修改内容:
- 新增 isOpus45OrNewer() 函数用于精确识别模型版本
- 更新 claudeAccountService.js 中的账户选择逻辑
- 更新 unifiedClaudeScheduler.js 中的模型支持检查
- 新增测试脚本验证官方模型名称识别

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 07:38:55 +08:00
IanShaw027
827c0f6207 feat: 添加两级平台筛选功能(支持平台分组)
- 添加 platformHierarchy 定义平台层级结构(Claude全部、OpenAI全部、Gemini全部、Droid)
- 添加 platformGroupMap 映射平台组到具体平台
- 添加 platformRequestHandlers 动态处理平台请求
- 将 platformOptions 从 ref 改为 computed 支持缩进显示
- 优化 loadAccounts 使用动态平台加载替代大型 switch 语句
- 新增 getPlatformsForFilter 辅助函数

功能说明:
- 支持选择"Claude(全部)"同时筛选 claude + claude-console + bedrock + ccr
- 支持选择"OpenAI(全部)"同时筛选 openai + openai-responses + azure_openai
- 支持选择"Gemini(全部)"同时筛选 gemini + gemini-api
- 保持向后兼容,仍支持单独选择具体平台
2025-12-05 03:31:13 +08:00
IanShaw027
0b3cf5112b refactor: 移除仪表盘使用记录功能以避免与PR #753重叠
移除了仪表盘中的使用记录展示功能,避免与PR #753的API Key详细使用记录功能重叠:
- 移除DashboardView.vue中的使用记录表格UI及相关函数
- 移除dashboard.js中的/dashboard/usage-records接口
- 保留核心账户管理功能(账户过滤、限流状态、统计模态框等)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
3db268fff7 feat: 完善账户管理和仪表盘功能
- 修改使用记录API路由路径为 /dashboard/usage-records
- 增加对更多账户类型的支持(Bedrock、Azure、Droid、CCR等)
- 修复Codex模型识别逻辑,避免 gpt-5-codex 系列被错误归一化
- 在账户管理页面添加状态过滤器(正常/异常)
- 在账户管理页面添加限流时间过滤器(≤1h/5h/12h/1d)
- 增加账户统计汇总弹窗,按平台分类展示
- 完善仪表盘使用记录展示功能,支持分页加载
- 将 logs1/ 目录添加到 .gitignore

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
81971436e6 feat: 在仪表盘添加使用记录展示功能
- 新增后端API端点 /admin/dashboard/usage-records
  - 支持分页查询所有API Key的使用记录
  - 自动关联API Key名称和账户名称
  - 按时间倒序排列(最新的在前)

- 新增仪表盘使用记录表格
  - 显示时间、API Key、账户、模型、输入/输出/缓存创建/缓存读取tokens、成本
  - 智能时间格式化(今天显示时分秒,昨天显示时间)
  - 支持加载更多记录,分页展示
  - 响应式设计,支持暗黑模式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
69a1006f4c feat: 增强账户管理页面的过滤和统计功能
- 新增状态过滤器:支持按正常/异常/全部筛选账户
- 新增限流时间过滤器:支持按1h/5h/12h/1d筛选限流账户
- 新增账户统计弹窗:按平台类型和状态汇总账户数量
- 优化账户列表过滤逻辑,支持组合过滤条件
- 默认状态过滤为'正常',提升用户体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:54:14 +08:00
IanShaw027
4cf1762467 fix: 修复 ESLint curly 规则问题
- 在 if 语句后添加必需的大括号
- 修复 unifiedClaudeScheduler.js (1处)
- 修复 unstableUpstreamHelper.js (2处)
2025-12-05 02:28:30 +08:00
IanShaw027
0d64d40654 feat: 添加上游不稳定错误检测与账户临时不可用机制
## 背景
当上游 API(如 Anthropic、AWS Bedrock 等)出现临时故障时,服务会持续向故障
账户发送请求,导致用户体验下降。需要自动检测上游不稳定状态并临时排除故障账户。

## 改动内容

### 新增 unstableUpstreamHelper.js
- 检测多种上游不稳定错误模式
- 支持环境变量扩展检测规则

### 修改 unifiedClaudeScheduler.js
- 新增 markAccountTemporarilyUnavailable() 方法:标记账户临时不可用
- 新增 isAccountTemporarilyUnavailable() 方法:检查账户是否临时不可用
- 专属账户检查:claude-official、claude-console、bedrock 临时不可用时自动回退到池
- 池账户选择:跳过临时不可用的账户

### 修改 claudeRelayService.js
- _handleServerError() 方法增加临时不可用标记逻辑
- 5xx 错误时自动标记账户临时不可用(5分钟 TTL)

## 检测的状态码

| 分类 | 状态码 | 说明 |
|------|--------|------|
| 服务器错误 | 500-599 | 内部错误、服务不可用等 |
| 超时类 | 408 | 请求超时 |
| 连接类 | 499 | 客户端关闭请求 (Nginx) |
| 网关类 | 502, 503, 504 | 网关错误、服务不可用、网关超时 |
| CDN类 | 522 | Cloudflare 连接超时 |
| 语义类 | error.type = "server_error" | API 级别服务器错误 |

## 环境变量配置

- UNSTABLE_ERROR_TYPES: 额外的错误类型(逗号分隔)
- UNSTABLE_ERROR_KEYWORDS: 错误消息关键词(逗号分隔)

## Redis 键

- temp_unavailable:{accountType}:{accountId} - TTL 300秒
2025-12-05 02:28:30 +08:00
30 changed files with 3054 additions and 457 deletions

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ redis_data/
# Logs directory # Logs directory
logs/ logs/
logs1/
*.log *.log
startup.log startup.log
app.log app.log

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.222 1.1.226

80
package-lock.json generated
View File

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

View File

@@ -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": {

View 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)
}

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

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

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

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

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

View File

@@ -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' // 同时更新请求体中的模型

View File

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

View File

@@ -1948,7 +1948,13 @@ class ClaudeRelayService {
} }
// 🛠️ 统一的错误处理方法 // 🛠️ 统一的错误处理方法
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 +1968,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告警不再自动停止调度避免上游抖动导致误停

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,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') {

View File

@@ -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)
} }
} }
@@ -177,6 +218,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 +254,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 +273,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 +290,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 +308,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 +325,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 +570,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 +670,18 @@ class UnifiedClaudeScheduler {
// 继续处理该账号 // 继续处理该账号
} }
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
currentAccount.id,
'claude-console'
)
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable`
)
continue
}
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
currentAccount.id currentAccount.id
@@ -682,7 +780,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 +837,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 +1212,42 @@ class UnifiedClaudeScheduler {
} }
} }
// ⏱️ 标记账户为临时不可用状态用于5xx等临时故障默认5分钟后自动恢复
async markAccountTemporarilyUnavailable(
accountId,
accountType,
sessionHash = null,
ttlSeconds = 300
) {
try {
const client = redis.getClientSafe()
const key = `temp_unavailable:${accountType}:${accountId}`
await client.setex(key, ttlSeconds, '1')
if (sessionHash) {
await this._deleteSessionMapping(sessionHash)
}
logger.warn(
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
return { success: false }
}
}
// 🔍 检查账户是否临时不可用
async isAccountTemporarilyUnavailable(accountId, accountType) {
try {
const client = redis.getClientSafe()
const key = `temp_unavailable:${accountType}:${accountId}`
return (await client.exists(key)) === 1
} catch (error) {
logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error)
return false
}
}
// 🚫 标记账户为限流状态 // 🚫 标记账户为限流状态
async markAccountRateLimited( async markAccountRateLimited(
accountId, accountId,

View File

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

View 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
}

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

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

View File

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

View File

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

View File

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

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

View File

@@ -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 // 重置选择的平台

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