Compare commits

..

172 Commits

Author SHA1 Message Date
github-actions[bot]
b892ac30a0 chore: sync VERSION file with release v1.1.242 [skip ci] 2025-12-26 05:59:55 +00:00
Wesley Liddick
b8f34b4630 Merge pull request #844 from dadongwo/antigravity
feat: 实现 Antigravity OAuth 账户支持与路径分流
2025-12-26 00:59:42 -05:00
Wesley Liddick
c9621e9efb Merge pull request #846 from bgColorGray/feat/passthrough-system-prompt [skip ci]
feat: allow passing system prompt to Claude
2025-12-26 00:59:29 -05:00
Wesley Liddick
3f98267738 Merge branch 'main' into antigravity 2025-12-26 00:56:27 -05:00
Wesley Liddick
e187b8946a Merge pull request #825 from atoz03/feat/account-quota [skip ci]
Feat:account quota
2025-12-26 00:53:33 -05:00
Wesley Liddick
8917019a78 Merge pull request #814 from Guccbai/feature/multi-select-permissions [skip ci]
feat(permissions): 服务权限从单选改为多选
2025-12-26 00:52:42 -05:00
pengyujie
e57a7bd614 feat: allow passing system prompt to Claude
Add CRS_PASSTHROUGH_SYSTEM_PROMPT to optionally forward OpenAI-format system messages to Claude, improving compatibility with clients that rely on strict system instructions (e.g. MineContext).
2025-12-25 20:02:26 +08:00
52227
9960f237b8 feat: 实现 Antigravity OAuth 账户支持与路径分流 2025-12-25 14:33:24 +08:00
shaw
b6da77cabe docs: update readme 2025-12-25 14:27:23 +08:00
github-actions[bot]
e561387e81 chore: sync VERSION file with release v1.1.241 [skip ci] 2025-12-25 06:23:55 +00:00
shaw
982cca1020 fix: 修复鉴权检测的重大安全漏洞 2025-12-25 14:23:35 +08:00
github-actions[bot]
792ba51290 chore: sync VERSION file with release v1.1.240 [skip ci] 2025-12-25 02:46:09 +00:00
Wesley Liddick
74d138a2fb Merge pull request #842 from IanShaw027/feat/account-export-api
feat(admin): 添加账户导出同步 API
2025-12-24 21:45:55 -05:00
IanShaw027
b88698191e style(admin): fix ESLint curly rule violations in sync.js
为单行 if 语句添加花括号以符合 ESLint curly 规则要求
2025-12-24 17:57:30 -08:00
IanShaw027
11c38b23d1 style(admin): format sync.js with prettier
修复 CI 格式化检查失败问题
2025-12-24 17:52:51 -08:00
IanShaw027
b2dfc2eb25 feat(admin): 添加账户导出同步 API
- 新增 /api/accounts 端点,支持导出所有账户数据
- 新增 /api/proxies 端点,支持导出所有代理配置
- 支持 Sub2API 从 CRS 批量同步账户
- 包含完整的 credentials 和 extra 字段
- 提供账户类型标识 (oauth/setup_token/api_key)

相关 PR: Sub2API 端实现账户同步功能
2025-12-24 17:35:11 -08:00
github-actions[bot]
59ce0f091c chore: sync VERSION file with release v1.1.239 [skip ci] 2025-12-24 11:56:05 +00:00
shaw
67c20fa30e feat: 为 claude-official 账户添加 403 错误重试机制
针对 OAuth 和 Setup Token 类型的 Claude 账户,遇到 403 错误时:
- 休息 2 秒后进行重试
- 最多重试 2 次(总共最多 3 次请求)
- 重试后仍是 403 才标记账户为 blocked

同时支持流式和非流式请求,并修复了流式请求中的竞态条件问题。
2025-12-24 19:54:25 +08:00
shaw
671451253f fix: 修复并发清理任务 WRONGTYPE 错误
问题:
- 并发清理定时任务在遇到非 zset 类型的遗留键时报 WRONGTYPE 错误
- 错误键如 concurrency:wait:*, concurrency:user:*, concurrency:account:* 等

修复:
- app.js: 使用原子 Lua 脚本先检查键类型再执行清理,消除竞态条件
- redis.js: 为 6 个并发管理函数添加类型检查
  - getAllConcurrencyStatus(): 跳过 queue 键 + 类型检查
  - getConcurrencyStatus(): 类型检查,非 zset 返回 invalidType
  - forceClearConcurrency(): 类型检查,任意类型都删除
  - forceClearAllConcurrency(): 跳过 queue 键 + 类型检查
  - cleanupExpiredConcurrency(): 跳过 queue 键 + 类型检查

- 遗留键会被自动识别并删除,同时记录日志
2025-12-24 17:51:19 +08:00
Guccbai
534fbf6ac2 fix(eslint): 修复 ESLint 检查错误
- 修复 apiKeyService.js 中 if 语句缺少大括号的 curly 错误
- 移除 openaiGeminiRoutes.js 中重复声明 apiKeyService 导致的 no-shadow 错误

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:26:18 +08:00
github-actions[bot]
0173ab224b chore: sync VERSION file with release v1.1.238 [skip ci] 2025-12-21 14:41:29 +00:00
shaw
11fb77c8bd chore: trigger release [force release] 2025-12-21 22:41:03 +08:00
shaw
3d67f0b124 chore: update readme 2025-12-21 22:37:13 +08:00
shaw
84f19b348b fix: 适配cc遥测端点 2025-12-21 22:29:36 +08:00
shaw
8ec8a59b07 feat: claude账号新增支持拦截预热请求 2025-12-21 22:28:22 +08:00
shaw
00d8ac4bec Merge branch 'main' into dev 2025-12-21 21:35:16 +08:00
atoz03
b6f3459522 修复 eslint 2025-12-20 01:40:41 +08:00
atoz03
e56d797d87 修复 tests/accountBalanceService.test.js 的 Prettier 格式问题 2025-12-20 01:35:30 +08:00
atoz03
4c6879a9c2 Prettier 格式化 2025-12-20 01:24:08 +08:00
atoz03
1c8084a3b1 fix(admin): 打开余额脚本弹窗时重置表单,避免跨账户残留配置
- 打开弹窗先重置表单字段(baseUrl/apiKey/extra 等),仅保留示例脚本\n- 若后端存在已保存配置,则加载后覆盖\n- 同步清理测试结果与 loading 状态,避免残留误导
2025-12-20 01:18:49 +08:00
atoz03
f6f4b5cfec feat(admin): 余额脚本驱动的余额/配额刷新与管理端体验修复
- 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗
2025-12-20 01:18:49 +08:00
atoz03
26ca696b91 fix:修复了重复声明 redis 导致的启动报错,并保留余额脚本功能接入账户 2025-12-20 01:18:49 +08:00
atoz03
ce496ed9e6 feat:单账户配置余额脚本 + 刷新按钮即用脚本”,并去掉独立页面/标签。
具体改动

  - 后端
      - src/models/redis.js:新增脚本配置存取 account_balance_script:{platform}:{accountId}。
      - src/services/accountBalanceService.js:支持脚本查询。若账户有脚本配置且 queryApi=true,调用 balanceScriptService.execute 获取余额/配额,缓存后返回。
      - src/routes/admin/accountBalance.js:新增接口
          - GET /admin/accounts/:id/balance/script?platform=...
          - PUT /admin/accounts/:id/balance/script?platform=...
          - POST /admin/accounts/:id/balance/script/test?platform=...
  - 前端
      - 新增弹窗 AccountBalanceScriptModal,在账户管理页每个账户“余额/配额”下方有“配置余额脚本”按钮,支持填写 baseUrl/apiKey/token/extra/超时/自动间隔、编写脚本、测试、保存。
      - 将余额脚本独立路由/标签移除。
  - 格式/ lint 已通过(新组件及 AccountsView)。
2025-12-20 01:18:49 +08:00
atoz03
f6ed420401 feat(admin): 新增账户余额/配额查询与展示
- 新增 accountBalanceService 与多 Provider 适配(Claude/Claude Console/OpenAI Responses/通用)
  - Redis 增加余额查询结果与本地统计缓存读写
  - 管理端新增 /admin/accounts/balance 相关接口与汇总接口,并在应用启动时注册 Provider
  - 后台前端新增余额组件与 Dashboard 余额/配额汇总、低余额/高使用提示
  - 补充 accountBalanceService 单元测试
2025-12-20 01:15:33 +08:00
github-actions[bot]
5863816882 chore: sync VERSION file with release v1.1.237 [skip ci] 2025-12-19 14:30:21 +00:00
shaw
638d2ff189 feat: 支持claude单账户开启串行队列 2025-12-19 22:29:57 +08:00
github-actions[bot]
fa2fc2fb16 chore: sync VERSION file with release v1.1.236 [skip ci] 2025-12-19 07:50:25 +00:00
Wesley Liddick
6d56601550 Merge pull request #821 from guoyongchang/feat/cron-test-support
feat: Claude账户定时测试功能
2025-12-19 02:50:08 -05:00
guoyongchang
dd8a0c95c3 fix: use template literals instead of string concatenation
- Convert string concatenation to template literals per ESLint prefer-template rule
- Fixes ESLint errors in sessionKeyPrefix logging (lines 281, 330)

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-19 15:46:38 +08:00
guoyongchang
126eee3712 feat/cron-test-support format fix. 2025-12-19 14:59:47 +08:00
guoyongchang
26bfdd6892 [feat/cron-test-support]optimize. 2025-12-19 14:03:31 +08:00
guoyongchang
cd3f51e9e2 refactor: optimize cron test support feature
**优化内容:**

1. **验证和安全性加强**
   - 移除cron验证重复,统一使用accountTestSchedulerService.validateCronExpression()方法
   - 添加model参数类型和长度验证(max 256 chars)
   - 限制cronExpression长度至100字符防止DoS攻击
   - 双层验证:service层和route层都进行长度检查

2. **性能优化**
   - 优化_refreshAllTasks()使用Promise.all()并行加载所有平台配置(之前是顺序加载)
   - 改进错误处理,平台加载失败时继续处理其他平台

3. **数据管理改进**
   - 为test config添加1年TTL过期机制(之前没有过期设置)
   - 保证test history已有30天TTL和5条记录限制

4. **错误响应标准化**
   - 统一所有API响应格式,确保error状态都包含message字段
   - 改进错误消息的可读性和上下文信息

5. **用户体验改进**
   - Vue组件使用showToast()替代原生alert()
   - 移除console.error()改用toast通知用户
   - 成功保存时显示成功提示

6. **代码整理**
   - 移除未使用的maxConcurrentTests变量及其getStatus()中的引用
   - 保持代码整洁性

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-19 13:39:39 +08:00
guoyongchang
9977245d59 feat/cron-test-support package lock fix. 2025-12-19 13:32:16 +08:00
guoyongchang
09cf951cdc [feat/cron-test-support]done. 2025-12-19 10:25:43 +08:00
Guccbai
33ea26f2ac feat(permissions): 服务权限从单选改为多选
- 将 API Key 的服务权限从单选改为多选,支持同时选择多个服务
- 移除"全部服务"选项,空数组表示允许访问全部服务
- 后端自动兼容旧格式('all' -> [], 'claude' -> ['claude'])
- 前端 radio 改为 checkbox,更新账户选择器联动逻辑

修改文件:
- apiKeyService.js: 添加 normalizePermissions/hasPermission 函数
- api.js, droidRoutes.js, openaiRoutes.js, unified.js, openaiGeminiRoutes.js, geminiHandlers.js: 使用新权限验证函数
- admin/apiKeys.js: 支持数组格式权限验证
- CreateApiKeyModal.vue, EditApiKeyModal.vue: UI 改为 checkbox 多选

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:35:11 +08:00
Wesley Liddick
ba93ae55a9 Merge pull request #811 from sususu98/feat/event-logging-endpoint
feat: 添加 Claude Code 遥测端点并优化日志级别
2025-12-16 19:34:44 -05:00
Wesley Liddick
53cda0fd18 Merge pull request #806 from XiaoXice/main [skip ci]
fix: 全时间api-token统计因为日token记录过期导致不准的问题
2025-12-16 19:34:35 -05:00
Wesley Liddick
151cb7536c Merge pull request #808 from SilentFlower/fix/openai-scheduler-priority [skip ci]
fix(scheduler): 恢复OpenAI 账号选择支持 priority + lastUsedAt
2025-12-16 19:33:18 -05:00
sususu
0994eb346f format 2025-12-16 18:32:11 +08:00
sususu
4863a37328 feat: 添加 Claude Code 遥测端点并优化日志级别
- 添加 /api/event_logging/batch 端点处理客户端遥测请求
- 将遥测相关请求日志改为 debug 级别,减少日志噪音
2025-12-16 18:31:07 +08:00
huajiwuyan
052e236a93 fix(scheduler): 恢复OpenAI 账号选择支持 priority + lastUsedAt 2025-12-15 23:17:44 +08:00
XiaoXice
c79ea19aa1 fix: 全时间api-token统计因为日token记录过期导致不准的问题 2025-12-15 15:14:09 +08:00
github-actions[bot]
79f2cebdb8 chore: sync VERSION file with release v1.1.235 [skip ci] 2025-12-15 01:48:14 +00:00
Wesley Liddick
bd7b8884ab Merge pull request #801 from miraserver/fix/cost-calculation-and-ui-display
fix: correct API key cost calculation and UI display issues
2025-12-14 20:48:00 -05:00
github-actions[bot]
38e0adb499 chore: sync VERSION file with release v1.1.234 [skip ci] 2025-12-15 01:44:56 +00:00
shaw
7698f5ce11 chore: 增加opus4.5快捷映射按钮 2025-12-15 09:44:36 +08:00
shaw
ce13e5ddb1 fix: console账号转发使用白名单透传header 2025-12-15 09:38:51 +08:00
John Doe
baafebbf7b fix: correct API key cost calculation and UI display issues
- Fix admin panel cost display for "all time" period using permanent Redis key
- Fix user statistics total cost limit to show complete history
- Fix restricted models list overflow with scrollable container

Backend changes:
- src/routes/admin/apiKeys.js: Use allTimeCost for timeRange='all' instead of scanning TTL keys
- src/routes/apiStats.js: Prioritize permanent usage:cost:total key over monthly keys

Frontend changes:
- web/admin-spa/src/components/apistats/LimitConfig.vue: Add overflow-visible and scrolling to model list

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:11:02 +03:00
github-actions[bot]
87426133a2 chore: sync VERSION file with release v1.1.233 [skip ci] 2025-12-12 06:58:37 +00:00
Wesley Liddick
60f5cbe780 Merge pull request #800 from DaydreamCoding/feature/concurrency-queue
feat: enhance concurrency queue with health check and admin endpoints
2025-12-12 01:58:24 -05:00
Wesley Liddick
86d8ed52d7 Merge pull request #799 from kikii16/main [skip ci]
尝试自定义请求体maxSize大小
2025-12-12 01:58:12 -05:00
DaydreamCoding
07633ddbf8 feat: enhance concurrency queue with health check and admin endpoints
- Add queue health check for fast-fail when overloaded (P90 > threshold)
  - Implement socket identity verification with UUID token
  - Add wait time statistics (P50/P90/P99) and queue stats tracking
  - Add admin endpoints for queue stats and cleanup
  - Add CLEAR_CONCURRENCY_QUEUES_ON_STARTUP config option
  - Update documentation with troubleshooting and proxy config guide
2025-12-12 14:32:09 +08:00
kikii16
dd90c426e4 Update docker-compose.yml 2025-12-12 11:38:31 +08:00
kikii16
059357f834 Update .env.example 2025-12-12 11:36:01 +08:00
kikii16
ceee3a9295 Update auth.js 2025-12-12 11:34:46 +08:00
Wesley Liddick
403f609f69 Merge pull request #797 from thejoven/patch-1 [skip ci]
修改提醒内容:新版 gemini-cli 的 Access Token 位置和文件名已变更
2025-12-11 21:10:07 -05:00
thejoven
304c8dda4e Update AccountForm.vue
新版 gemini-cli 的 Access Token 位置和文件名已变更
2025-12-12 00:43:11 +08:00
github-actions[bot]
c4d923c46f chore: sync VERSION file with release v1.1.232 [skip ci] 2025-12-11 02:46:31 +00:00
Wesley Liddick
fa9f9146a2 Merge pull request #793 from qq790716890/main
fix:修复codex统计token问题
2025-12-10 21:46:15 -05:00
Wesley Liddick
dc9409a5a6 Merge pull request #788 from atoz03/main [skip ci]
fix: 账户列表默认显示限额/限流账号并加固加载健壮性
2025-12-10 21:44:55 -05:00
LZY
51aa8dc381 fix:修复codex统计token问题 2025-12-10 22:56:25 +08:00
github-actions[bot]
5061f4d9fd chore: sync VERSION file with release v1.1.231 [skip ci] 2025-12-10 12:11:39 +00:00
Wesley Liddick
4337af06d4 Merge pull request #791 from DaydreamCoding/feature/log-opt
fix: improve logging for client disconnections in relay services
2025-12-10 07:11:24 -05:00
Wesley Liddick
d226d57325 Merge pull request #790 from DaydreamCoding/patch-4 [skip ci]
fix(security): add authenticateAdmin middleware to concurrency routes
2025-12-10 07:11:07 -05:00
Wesley Liddick
9f92c58640 Merge pull request #789 from DaydreamCoding/feature/user-message-queue-optimize [skip ci]
feat(queue): 优化用户消息队列锁释放时机
2025-12-10 07:10:38 -05:00
QTom
8901994644 fix: improve logging for client disconnections in relay services
当客户端主动断开连接时,改为使用 INFO 级别记录而不是 ERROR 级别,
因为这是正常情况而非错误。

- ccrRelayService: 区分客户端断开与实际错误
- claudeConsoleRelayService: 区分客户端断开与实际错误
- claudeRelayService: 区分客户端断开与实际错误
- droidRelayService: 区分客户端断开与实际错误
2025-12-10 14:18:44 +08:00
QTom
e3ca555df7 fix(security): add authenticateAdmin middleware to concurrency routes
fix(security): add authenticateAdmin middleware to concurrency routes

All concurrency management endpoints were missing authentication,
allowing unauthenticated access to view and clear concurrency data.
2025-12-10 13:59:25 +08:00
QTom
3b9c96dff8 feat(queue): 优化用户消息队列锁释放时机
将队列锁释放时机从"请求完成后"提前到"请求发送后",因为 Claude API
限流(RPM)基于请求发送时刻计算,无需等待响应完成。

主要变更:
- 移除锁续租机制(startLockRenewal、refreshUserMessageLock)
- 所有 relay 服务在请求发送成功后立即释放锁
- 流式请求通过 onResponseStart 回调在收到响应头时释放
- 调整默认配置:timeoutMs 60s→5s,lockTtlMs 120s→5s
- 新增 USER_MESSAGE_QUEUE_LOCK_TTL_MS 环境变量支持
2025-12-10 01:26:00 +08:00
github-actions[bot]
cb94a4260e chore: sync VERSION file with release v1.1.230 [skip ci] 2025-12-09 10:59:05 +00:00
Wesley Liddick
ac9499aa6d Merge pull request #787 from DaydreamCoding/feature/user-message-queue-fix
feat: 修复 userMessageQueue 配置缺失导致的 500 错误
2025-12-09 05:58:35 -05:00
atoz03
fc25840f95 fix: 账户列表默认显示限额/限流账号并加固加载健壮性
- 将账户页状态筛选默认值从 normal 改为 all,额度满/限流/异常账号默认可见
  - appendAccounts 使用 Array.isArray 兜底接口响应,避免空/异常数据导致“加载账户失败”
  - 便于在额度耗尽场景查看并处理账号
2025-12-09 18:49:57 +08:00
QTom
b409adf9d8 feat: 修复 userMessageQueue 配置缺失导致的 500 错误
- 在 config.example.js 添加缺失的 userMessageQueue 配置段
  - 在 userMessageQueueService.js 添加防御性代码,当配置未定义时使用默认值

  修复 #783 合并后新用户安装报错:
  Cannot read properties of undefined (reading 'enabled')
2025-12-09 18:41:13 +08:00
github-actions[bot]
b76776d7b0 chore: sync VERSION file with release v1.1.229 [skip ci] 2025-12-09 09:49:01 +00:00
Wesley Liddick
8499992abd Merge pull request #783 from DaydreamCoding/feature/user-message-queue
feat: 添加用户手动输入的消息串行队列功能,防止同账户并发请求触发限流&封号
2025-12-09 04:48:49 -05:00
QTom
dc96447d72 style: 格式化文件以符合 Prettier 规范 2025-12-09 17:18:43 +08:00
QTom
f5d1c25295 feat: 添加用户消息串行队列功能,防止同账户并发请求触发限流
- 新增 userMessageQueueService.js 实现基于 Redis 的队列锁机制
- 在 claudeRelayService、claudeConsoleRelayService、bedrockRelayService、ccrRelayService 中集成队列锁
- 添加 Redis 原子性 Lua 脚本:acquireUserMessageLock、releaseUserMessageLock、refreshUserMessageLock
- 支持锁续租机制,防止长时间请求锁过期
- 添加可配置参数:USER_MESSAGE_QUEUE_ENABLED、USER_MESSAGE_QUEUE_DELAY_MS、USER_MESSAGE_QUEUE_TIMEOUT_MS
- 添加 Web 管理界面配置入口
- 添加 logger.performance 方法用于结构化性能日志
- 添加完整单元测试 (tests/userMessageQueue.test.js)
2025-12-09 17:04:01 +08:00
github-actions[bot]
95870883a1 chore: sync VERSION file with release v1.1.228 [skip ci] 2025-12-08 13:05:52 +00:00
shaw
aa71c58400 fix: 修复强制会话绑定首次会话的bug 2025-12-08 21:05:21 +08:00
github-actions[bot]
698f3d7daa chore: sync VERSION file with release v1.1.227 [skip ci] 2025-12-08 08:10:44 +00:00
shaw
5af5e55d80 chore: trigger release [force release]
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 16:10:09 +08:00
shaw
5a18f54abd Merge branch 'dev' 2025-12-08 16:08:10 +08:00
Wesley Liddick
6d3b51510a Merge pull request #779 from sususu98/fix/explore-agent-prompt-template [skip ci]
fix: 添加 Explore agent 系统提示词模板并优化日志级别
2025-12-08 03:06:55 -05:00
shaw
c79fdc4d71 feat: 增加Claude会话强制绑定 2025-12-08 16:06:23 +08:00
shaw
659072075d fix: 统一格式化claude参数传递 2025-12-08 14:23:13 +08:00
sususu
cf93128a96 fix: format 2025-12-08 11:01:10 +08:00
sususu
909b5ad37f fix: 添加 Explore agent 系统提示词模板并优化日志级别
- 添加 exploreAgentSystemPrompt 模板用于匹配 Claude Code Explore 子代理
- 将详细的 prompt 内容从 error 日志移至 warn 级别,减少日志噪音

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:45:46 +08:00
shaw
bab7073822 fix: 修复api-keys页面窗口费率显示问题 2025-12-08 09:58:54 +08:00
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
github-actions[bot]
94aca4dc22 chore: sync VERSION file with release v1.1.222 [skip ci] 2025-12-05 01:06:39 +00:00
shaw
6bfef2525a Merge PR #753: feat: 新增 API Key
请求时间线接口与管理端详情页面
2025-12-05 09:03:53 +08:00
github-actions[bot]
5a636a36f6 chore: sync VERSION file with release v1.1.221 [skip ci] 2025-12-05 00:38:43 +00:00
shaw
b61e1062bf fix: 修复create_proxyagent调用方式 2025-12-05 08:37:43 +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
github-actions[bot]
1b18a1226d chore: sync VERSION file with release v1.1.220 [skip ci] 2025-12-04 13:01:54 +00:00
Wesley Liddick
0b2372abab Merge pull request #756 from SunSeekerX/feature_api_disable_switch
feat(account): 新增账户自动防护禁用开关
2025-12-04 08:01:35 -05:00
SunSeekerX
8aca1f9dd1 feat(account): 新增账户自动防护禁用开关
支持 disableAutoProtection 配置项,启用后上游 401/400/429/529 错误不再自动禁用账户
2025-12-04 20:47:12 +08:00
atoz03
95ef04c1a3 fix: 保持仪表盘趋势图非负并纠正小时区间
- 小时粒度请求使用用户选择的起止时间,避免近24小时被截成整天
  - 修正日期展示格式化逻辑,减少时区偏移导致的窗口错位
  - 趋势图 Y 轴(Token/请求数/费用等)强制最小值为 0,防止出现负刻度
2025-12-04 17:05:36 +08:00
atoz03
4919e392a5 feat: 仪表盘日期筛选默认今日并记忆用户偏好 2025-12-04 16:48:11 +08:00
atoz03
354d8da13f feat:已修复详情弹窗位置问题:RecordDetailModal 现在 append-to-body、destroy-on-close,并设定 top="10vh",点击列表底部的“详情”不会被滚动容器截断或浮在页面顶部看不到。 2025-12-04 15:17:48 +08:00
atoz03
3df0c7c650 feat:已修复 ESLint no-shadow 问题:geminiApiAccountService 不再重复声明,改用顶部引入的实例。后端/前端 lint 均通过(npm run lint:check、cd web/admin-spa && npm run lint) 2025-12-04 15:05:09 +08:00
atoz03
6a3dce523b chore: format usage stats route 2025-12-04 15:02:07 +08:00
atoz03
9fe2918a54 feat: keep API key stats modal and add timeline entry point 2025-12-04 14:56:27 +08:00
atoz03
92b30e1924 feat: add API key usage timeline API and admin UI 2025-12-04 14:41:38 +08:00
114 changed files with 28366 additions and 1528 deletions

View File

@@ -33,6 +33,41 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
CLAUDE_API_VERSION=2023-06-01
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
# 🤖 Gemini OAuth / Antigravity 配置(可选)
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret可在此覆盖
# GEMINI_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_SECRET=
# Gemini CLI OAuth redirect_uri可选默认 https://codeassist.google.com/authcode
# GEMINI_OAUTH_REDIRECT_URI=
# ANTIGRAVITY_OAUTH_CLIENT_ID=
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
# Antigravity OAuth redirect_uri可选默认 http://localhost:45462用于避免 redirect_uri_mismatch
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
# Antigravity 上游地址(可选,默认 sandbox
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
# Antigravity User-Agent可选
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
# Claude CodeAnthropic Messages API路由分流无需额外环境变量
# - /api -> Claude 账号池(默认)
# - /antigravity/api -> Antigravity OAuth
# - /gemini-cli/api -> Gemini CLI OAuth
# 可选Claude Code 调试 Dump会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题
# - anthropic-requests-dump.jsonl
# - anthropic-responses-dump.jsonl
# - anthropic-tools-dump.jsonl
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
#
# 可选Antigravity 上游请求 Dump会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload含 tools/schema 清洗后的结果)
# - antigravity-upstream-requests-dump.jsonl
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
# 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
@@ -61,6 +96,9 @@ PROXY_USE_IPV4=true
# ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟
# 🔧 请求体大小配置
REQUEST_MAX_SIZE_MB=60
# 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000
@@ -75,6 +113,8 @@ TOKEN_USAGE_RETENTION=2592000000
HEALTH_CHECK_INTERVAL=60000
TIMEZONE_OFFSET=8 # UTC偏移小时数默认+8中国时区
METRICS_WINDOW=5 # 实时指标统计窗口分钟可选1-60默认5分钟
# 启动时清理残留的并发排队计数器默认true多实例部署时建议设为false
CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=true
# 🎨 Web 界面配置
WEB_TITLE=Claude Relay Service
@@ -126,3 +166,7 @@ DEFAULT_USER_ROLE=user
USER_SESSION_TIMEOUT=86400000
MAX_API_KEYS_PER_USER=1
ALLOW_USER_DELETE_API_KEYS=false
# Pass through incoming OpenAI-format system prompts to Claude.
# Enable this when using generic OpenAI-compatible clients (e.g. MineContext) that rely on system prompts.
# CRS_PASSTHROUGH_SYSTEM_PROMPT=true

1
.gitignore vendored
View File

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

View File

@@ -22,6 +22,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
- **权限控制**: API Key支持权限配置all/claude/gemini/openai等控制可访问的服务类型
- **客户端限制**: 基于User-Agent的客户端识别和限制支持ClaudeCode、Gemini-CLI等预定义客户端
- **模型黑名单**: 支持API Key级别的模型访问限制
- **并发请求排队**: 当API Key并发数超限时请求进入队列等待而非立即返回429支持配置最大排队数、超时时间适用于Claude Code Agent并行工具调用场景
### 主要服务组件
@@ -60,6 +61,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
- **apiKeyService.js**: API Key管理验证、限流、使用统计、成本计算
- **userService.js**: 用户管理系统支持用户注册、登录、API Key管理
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
- **pricingService.js**: 定价服务,模型价格管理和成本计算
- **costInitService.js**: 成本数据初始化服务
- **webhookService.js**: Webhook通知服务
@@ -185,12 +187,17 @@ npm run service:stop # 停止服务
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间分钟0表示禁用
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL小时默认1
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值分钟默认0
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列默认false
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔毫秒默认200
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时毫秒默认5000锁持有时间短无需长等待
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL毫秒默认5000请求发送后立即释放无需长TTL
- `METRICS_WINDOW`: 实时指标统计窗口分钟1-60默认5
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量默认1
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys默认false
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志默认false仅开发环境
- `PROXY_USE_IPV4`: 代理使用IPv4默认true
- `REQUEST_TIMEOUT`: 请求超时时间毫秒默认600000即10分钟
- `CLEAR_CONCURRENCY_QUEUES_ON_STARTUP`: 启动时清理残留的并发排队计数器默认true多实例部署时建议设为false
#### AWS Bedrock配置可选
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock设置为1启用
@@ -337,6 +344,35 @@ npm run setup # 自动生成密钥并创建管理员账户
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据检查pricingService是否正确加载模型价格
13. **缓存命中率低**: 查看缓存监控统计调整LRU缓存大小配置
14. **用户消息队列超时**: 优化后锁持有时间已从分钟级降到毫秒级(请求发送后立即释放),默认 `USER_MESSAGE_QUEUE_TIMEOUT_MS=5000` 已足够。如仍有超时,检查网络延迟或禁用此功能(`USER_MESSAGE_QUEUE_ENABLED=false`
15. **并发请求排队问题**:
- 排队超时:检查 `concurrentRequestQueueTimeoutMs` 配置是否合理默认10秒
- 排队数过多:调整 `concurrentRequestQueueMaxSize` 和 `concurrentRequestQueueMaxSizeMultiplier`
- 查看排队统计:访问 `/admin/concurrency-queue/stats` 接口查看 entered/success/timeout/cancelled/socket_changed/rejected_overload 统计
- 排队计数泄漏:系统重启时自动清理,或访问 `/admin/concurrency-queue` DELETE 接口手动清理
- Socket 身份验证失败:查看 `socket_changed` 统计,如果频繁发生,检查代理配置或客户端连接稳定性
- 健康检查拒绝:查看 `rejected_overload` 统计,表示队列过载时的快速失败次数
### 代理配置要求(并发请求排队)
使用并发请求排队功能时,需要正确配置代理(如 Nginx的超时参数
- **推荐配置**: `proxy_read_timeout >= max(2 × concurrentRequestQueueTimeoutMs, 60s)`
- 当前默认排队超时 10 秒Nginx 默认 `proxy_read_timeout = 60s` 已满足要求
- 如果调整排队超时到 60 秒,推荐代理超时 ≥ 120 秒
- **Nginx 配置示例**:
```nginx
location /api/ {
proxy_read_timeout 120s; # 排队超时 60s 时推荐 120s
proxy_connect_timeout 10s;
# ...其他配置
}
```
- **企业防火墙环境**:
- 某些企业防火墙可能静默关闭长时间无数据的连接20-40 秒)
- 如遇此问题,联系网络管理员调整空闲连接超时策略
- 或降低 `concurrentRequestQueueTimeoutMs` 配置
- **后续升级说明**: 如有需要,后续版本可能提供可选的轻量级心跳机制
### 调试工具
@@ -449,6 +485,15 @@ npm run setup # 自动生成密钥并创建管理员账户
- **缓存优化**: 多层LRU缓存解密缓存、账户缓存全局缓存监控和统计
- **成本追踪**: 实时token使用统计input/output/cache_create/cache_read和成本计算基于pricingService
- **并发控制**: Redis Sorted Set实现的并发计数支持自动过期清理
- **并发请求排队**: 当API Key并发超限时请求进入队列等待而非直接返回429
- **工作原理**: 采用「先占后检查」模式,每次轮询尝试占位,超限则释放继续等待
- **指数退避**: 初始200ms指数增长至最大2秒带±20%抖动防惊群效应
- **智能清理**: 排队计数有TTL保护超时+30秒进程崩溃也能自动清理
- **Socket身份验证**: 使用UUID token + socket对象引用双重验证避免HTTP Keep-Alive连接复用导致的身份混淆
- **健康检查**: P90等待时间超过阈值时快速失败返回429避免新请求在过载时继续排队
- **配置参数**: `concurrentRequestQueueEnabled`默认false、`concurrentRequestQueueMaxSize`默认3、`concurrentRequestQueueMaxSizeMultiplier`默认0、`concurrentRequestQueueTimeoutMs`默认10秒、`concurrentRequestQueueMaxRedisFailCount`默认5、`concurrentRequestQueueHealthCheckEnabled`默认true、`concurrentRequestQueueHealthThreshold`默认0.8
- **最大排队数**: max(固定值, 并发限制×倍数),例如并发限制=10、倍数=2时最大排队数=20
- **适用场景**: Claude Code Agent并行工具调用、批量请求处理
- **客户端识别**: 基于User-Agent的客户端限制支持预定义客户端ClaudeCode、Gemini-CLI等
- **错误处理**: 529错误自动标记账户过载状态配置时长内自动排除该账户
@@ -508,8 +553,16 @@ npm run setup # 自动生成密钥并创建管理员账户
- `overload:{accountId}` - 账户过载状态529错误
- **并发控制**:
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
- **并发请求排队**:
- `concurrency:queue:{apiKeyId}` - API Key级别的排队计数器TTL由 `concurrentRequestQueueTimeoutMs` + 30秒缓冲决定
- `concurrency:queue:stats:{apiKeyId}` - 排队统计entered/success/timeout/cancelled
- `concurrency:queue:wait_times:{apiKeyId}` - 按API Key的等待时间记录用于P50/P90/P99计算
- `concurrency:queue:wait_times:global` - 全局等待时间记录
- **Webhook配置**:
- `webhook_config:{id}` - Webhook配置
- **用户消息队列**:
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁当前持有者requestId
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
- **系统信息**:
- `system_info` - 系统状态缓存
- `model_pricing` - 模型价格数据pricingService

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
# 📁 设置工作目录
@@ -7,8 +20,9 @@ WORKDIR /app/web/admin-spa
# 📦 复制前端依赖文件
COPY web/admin-spa/package*.json ./
# 🔽 安装前端依赖
RUN npm ci
# 🔽 安装前端依赖 - 使用 BuildKit 缓存加速
RUN --mount=type=cache,target=/root/.npm \
npm ci
# 📋 复制前端源代码
COPY web/admin-spa/ ./
@@ -34,17 +48,16 @@ RUN apk add --no-cache \
# 📁 设置工作目录
WORKDIR /app
# 📦 复制 package 文件
# 📦 复制 package 文件 (用于版本信息等)
COPY package*.json ./
# 🔽 安装依赖 (生产环境)
RUN npm ci --only=production && \
npm cache clean --force
# 📦 从后端依赖阶段复制 node_modules (已预装好)
COPY --from=backend-deps /app/node_modules ./node_modules
# 📋 复制应用代码
COPY . .
# 📦 从构建阶段复制前端产物
# 📦 从前端构建阶段复制前端产物
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
# 🔧 复制并设置启动脚本权限

View File

@@ -1,5 +1,10 @@
# Claude Relay Service
> [!CAUTION]
> **安全更新通知**v1.1.240 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
>
> **请立即更新到 v1.1.241+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -389,13 +394,31 @@ docker-compose.yml 已包含:
**Claude Code 设置环境变量:**
默认使用标准 Claude 账号池:
默认使用标准 Claude 账号池Claude/Console/Bedrock/CCR
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
```
如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
Antigravity OAuth支持 `claude-opus-4-5` 等 Antigravity 模型):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth使用 Gemini 模型):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude 插件配置:**
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
@@ -408,6 +431,8 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
> 💡 **IntelliJ IDEA 用户推荐**[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
**Gemini CLI 设置环境变量:**
**方式一(推荐):通过 Gemini Assist API 方式访问**

View File

@@ -1,5 +1,10 @@
# Claude Relay Service
> [!CAUTION]
> **Security Update**: v1.1.240 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
>
> **Please update to v1.1.241+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
<div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -238,13 +243,31 @@ Now you can replace the official API with your own service:
**Claude Code Set Environment Variables:**
Default uses standard Claude account pool:
Default uses standard Claude account pool (Claude/Console/Bedrock/CCR):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
```
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth (Gemini models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude Plugin Configuration:**
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
@@ -604,4 +627,4 @@ This project uses the [MIT License](LICENSE).
**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
</div>
</div>

View File

@@ -1 +1 @@
1.1.219
1.1.242

View File

@@ -203,6 +203,23 @@ const config = {
development: {
debug: process.env.DEBUG === 'true',
hotReload: process.env.HOT_RELOAD === 'true'
},
// 💰 账户余额相关配置
accountBalance: {
// 是否允许执行自定义余额脚本(安全开关)
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
// 默认保持开启如需禁用请显式设置BALANCE_SCRIPT_ENABLED=false
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
},
// 📬 用户消息队列配置
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
userMessageQueue: {
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL毫秒5秒足以覆盖请求发送
}
}

View File

@@ -21,6 +21,9 @@ services:
- PORT=3000
- HOST=0.0.0.0
# 🔧 请求体大小配置
- REQUEST_MAX_SIZE_MB=60
# 🔐 安全配置(必填)
- JWT_SECRET=${JWT_SECRET} # 必填至少32字符的随机字符串
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填32字符的加密密钥

90
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
@@ -44,6 +45,7 @@
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"supertest": "^6.3.3"
},
"engines": {
@@ -7027,6 +7029,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7598,6 +7609,85 @@
"node": ">=6.0.0"
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.19"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-hermes": "*",
"@prettier/plugin-oxc": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-marko": "*",
"prettier-plugin-multiline-arrays": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-sort-imports": "*",
"prettier-plugin-svelte": "*"
},
"peerDependenciesMeta": {
"@ianvs/prettier-plugin-sort-imports": {
"optional": true
},
"@prettier/plugin-hermes": {
"optional": true
},
"@prettier/plugin-oxc": {
"optional": true
},
"@prettier/plugin-pug": {
"optional": true
},
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
"prettier-plugin-css-order": {
"optional": true
},
"prettier-plugin-jsdoc": {
"optional": true
},
"prettier-plugin-marko": {
"optional": true
},
"prettier-plugin-multiline-arrays": {
"optional": true
},
"prettier-plugin-organize-attributes": {
"optional": true
},
"prettier-plugin-organize-imports": {
"optional": true
},
"prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-svelte": {
"optional": true
}
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",

View File

@@ -65,6 +65,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
@@ -83,6 +84,7 @@
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"supertest": "^6.3.3"
},
"engines": {

6428
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

@@ -52,6 +52,16 @@ class Application {
await redis.connect()
logger.success('✅ Redis connected successfully')
// 💳 初始化账户余额查询服务Provider 注册)
try {
const accountBalanceService = require('./services/accountBalanceService')
const { registerAllProviders } = require('./services/balanceProviders')
registerAllProviders(accountBalanceService)
logger.info('✅ 账户余额查询服务已初始化')
} catch (error) {
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
}
// 💰 初始化价格服务
logger.info('🔄 Initializing pricing service...')
await pricingService.initialize()
@@ -68,6 +78,10 @@ class Application {
logger.info('🔄 Initializing admin credentials...')
await this.initializeAdmin()
// 🔒 安全启动:清理无效/伪造的管理员会话
logger.info('🔒 Cleaning up invalid admin sessions...')
await this.cleanupInvalidSessions()
// 💰 初始化费用数据
logger.info('💰 Checking cost data initialization...')
const costInitService = require('./services/costInitService')
@@ -264,6 +278,25 @@ class Application {
this.app.use('/api', apiRoutes)
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
// Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户
// - /antigravity/api/v1/messages -> Antigravity OAuth
// - /gemini-cli/api/v1/messages -> Gemini CLI OAuth
this.app.use(
'/antigravity/api',
(req, res, next) => {
req._anthropicVendor = 'antigravity'
next()
},
apiRoutes
)
this.app.use(
'/gemini-cli/api',
(req, res, next) => {
req._anthropicVendor = 'gemini-cli'
next()
},
apiRoutes
)
this.app.use('/admin', adminRoutes)
this.app.use('/users', userRoutes)
// 使用 web 路由(包含 auth 和页面重定向)
@@ -426,6 +459,54 @@ class Application {
}
}
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
async cleanupInvalidSessions() {
try {
const client = redis.getClient()
// 获取所有 session:* 键
const sessionKeys = await client.keys('session:*')
let validCount = 0
let invalidCount = 0
for (const key of sessionKeys) {
// 跳过 admin_credentials系统凭据
if (key === 'session:admin_credentials') {
continue
}
const sessionData = await client.hgetall(key)
// 检查会话完整性:必须有 username 和 loginTime
const hasUsername = !!sessionData.username
const hasLoginTime = !!sessionData.loginTime
if (!hasUsername || !hasLoginTime) {
// 无效会话 - 可能是漏洞利用创建的伪造会话
invalidCount++
logger.security(
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
)
await client.del(key)
} else {
validCount++
}
}
if (invalidCount > 0) {
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
}
logger.success(
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
)
} catch (error) {
// 清理失败不应阻止服务启动
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
}
}
// 🔍 Redis健康检查
async checkRedisHealth() {
try {
@@ -581,15 +662,40 @@ class Application {
const now = Date.now()
let totalCleaned = 0
let legacyCleaned = 0
// 使用 Lua 脚本批量清理所有过期项
for (const key of keys) {
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
if (
key.startsWith('concurrency:queue:stats:') ||
key.startsWith('concurrency:queue:wait_times:') ||
(key.startsWith('concurrency:queue:') &&
!key.includes(':stats:') &&
!key.includes(':wait_times:'))
) {
continue
}
try {
const cleaned = await redis.client.eval(
// 使用原子 Lua 脚本:先检查类型,再执行清理
// 返回值0 = 正常清理无删除1 = 清理后删除空键,-1 = 遗留键已删除
const result = await redis.client.eval(
`
local key = KEYS[1]
local now = tonumber(ARGV[1])
-- 先检查键类型,只对 Sorted Set 执行清理
local keyType = redis.call('TYPE', key)
if keyType.ok ~= 'zset' then
-- 非 ZSET 类型的遗留键,直接删除
redis.call('DEL', key)
return -1
end
-- 清理过期项
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
@@ -608,8 +714,10 @@ class Application {
key,
now
)
if (cleaned === 1) {
if (result === 1) {
totalCleaned++
} else if (result === -1) {
legacyCleaned++
}
} catch (error) {
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
@@ -619,12 +727,50 @@ class Application {
if (totalCleaned > 0) {
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
}
if (legacyCleaned > 0) {
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
}
} catch (error) {
logger.error('❌ Concurrency cleanup task failed:', error)
}
}, 60000) // 每分钟执行一次
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
// 📬 启动用户消息队列服务
const userMessageQueueService = require('./services/userMessageQueueService')
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
userMessageQueueService.cleanupStaleLocks().then(() => {
// 然后启动定时清理任务
userMessageQueueService.startCleanupTask()
})
// 🚦 清理服务重启后残留的并发排队计数器
// 多实例部署时建议关闭此开关,避免新实例启动时清空其他实例的队列计数
// 可通过 DELETE /admin/concurrency/queue 接口手动清理
const clearQueuesOnStartup = process.env.CLEAR_CONCURRENCY_QUEUES_ON_STARTUP !== 'false'
if (clearQueuesOnStartup) {
redis.clearAllConcurrencyQueues().catch((error) => {
logger.error('❌ Error clearing concurrency queues on startup:', error)
})
} else {
logger.info(
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
)
}
// 🧪 启动账户定时测试调度器
// 根据配置定期测试账户连通性并保存测试历史
const accountTestSchedulerEnabled =
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
config.accountTestScheduler?.enabled !== false
if (accountTestSchedulerEnabled) {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.start()
logger.info('🧪 Account test scheduler service started')
} else {
logger.info('🧪 Account test scheduler service disabled')
}
}
setupGracefulShutdown() {
@@ -661,6 +807,15 @@ class Application {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
// 停止用户消息队列清理服务
try {
const userMessageQueueService = require('./services/userMessageQueueService')
userMessageQueueService.stopCleanupTask()
logger.info('📬 User message queue service stopped')
} catch (error) {
logger.error('❌ Error stopping user message queue service:', error)
}
// 停止费用排序索引服务
try {
const costRankService = require('./services/costRankService')
@@ -670,6 +825,15 @@ class Application {
logger.error('❌ Error stopping cost rank service:', error)
}
// 停止账户定时测试调度器
try {
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
accountTestSchedulerService.stop()
logger.info('🧪 Account test scheduler service stopped')
} catch (error) {
logger.error('❌ Error stopping account test scheduler service:', error)
}
// 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try {
logger.info('🔢 Cleaning up all concurrency counters...')

View File

@@ -9,6 +9,7 @@ const logger = require('../utils/logger')
const geminiAccountService = require('../services/geminiAccountService')
const geminiApiAccountService = require('../services/geminiApiAccountService')
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
const crypto = require('crypto')
const sessionHelper = require('../utils/sessionHelper')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
@@ -86,8 +87,7 @@ function generateSessionHash(req) {
* 检查 API Key 权限
*/
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
/**
@@ -449,9 +449,8 @@ async function handleMessages(req, res) {
// 添加代理配置
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -509,20 +508,37 @@ async function handleMessages(req, res) {
// OAuth 账户:使用现有的 sendGeminiRequest
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
const effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
geminiResponse = await sendGeminiRequest({
messages,
model,
temperature,
maxTokens: max_tokens,
stream,
accessToken: account.accessToken,
proxy: account.proxy,
apiKeyId: apiKeyData.id,
signal: abortController.signal,
projectId: effectiveProjectId,
accountId: account.id
})
if (oauthProvider === 'antigravity') {
geminiResponse = await sendAntigravityRequest({
messages,
model,
temperature,
maxTokens: max_tokens,
stream,
accessToken: account.accessToken,
proxy: account.proxy,
apiKeyId: apiKeyData.id,
signal: abortController.signal,
projectId: effectiveProjectId,
accountId: account.id
})
} else {
geminiResponse = await sendGeminiRequest({
messages,
model,
temperature,
maxTokens: max_tokens,
stream,
accessToken: account.accessToken,
proxy: account.proxy,
apiKeyId: apiKeyData.id,
signal: abortController.signal,
projectId: effectiveProjectId,
accountId: account.id
})
}
}
if (stream) {
@@ -732,9 +748,8 @@ async function handleModels(req, res) {
headers: { 'Content-Type': 'application/json' }
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
const response = await axios(axiosConfig)
models = (response.data.models || []).map((m) => ({
@@ -756,8 +771,16 @@ async function handleModels(req, res) {
]
}
} else {
// OAuth 账户:使用 OAuth token 获取模型列表
models = await getAvailableModels(account.accessToken, account.proxy)
// OAuth 账户:根据 OAuth provider 选择上游
const oauthProvider = account.oauthProvider || 'gemini-cli'
models =
oauthProvider === 'antigravity'
? await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
account.proxy,
account.refreshToken
)
: await getAvailableModels(account.accessToken, account.proxy)
}
res.json({
@@ -929,7 +952,8 @@ function handleSimpleEndpoint(apiMethod) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
// 直接转发请求体,不做特殊处理
@@ -1008,7 +1032,12 @@ async function handleLoadCodeAssist(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID
const effectiveProjectId = projectId || cloudaicompanionProject || null
@@ -1106,7 +1135,12 @@ async function handleOnboardUser(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID
const effectiveProjectId = projectId || cloudaicompanionProject || null
@@ -1234,9 +1268,8 @@ async function handleCountTokens(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -1259,7 +1292,8 @@ async function handleCountTokens(req, res) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
}
@@ -1369,13 +1403,20 @@ async function handleGenerateContent(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
// 如果没有任何项目ID尝试调用 loadCodeAssist 获取
if (!effectiveProjectId) {
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -1391,6 +1432,12 @@ async function handleGenerateContent(req, res) {
}
}
if (!effectiveProjectId && oauthProvider === 'antigravity') {
// Antigravity 账号允许没有 projectId生成一个稳定的临时 projectId 并缓存
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
}
// 如果还是没有项目ID返回错误
if (!effectiveProjectId) {
return res.status(403).json({
@@ -1413,14 +1460,24 @@ async function handleGenerateContent(req, res) {
: '从loadCodeAssist获取'
})
const response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
const response =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentAntigravity(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
: await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
// 记录使用统计
if (response?.response?.usageMetadata) {
@@ -1581,13 +1638,20 @@ async function handleStreamGenerateContent(req, res) {
// 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
// 如果没有任何项目ID尝试调用 loadCodeAssist 获取
if (!effectiveProjectId) {
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -1603,6 +1667,11 @@ async function handleStreamGenerateContent(req, res) {
}
}
if (!effectiveProjectId && oauthProvider === 'antigravity') {
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
}
// 如果还是没有项目ID返回错误
if (!effectiveProjectId) {
return res.status(403).json({
@@ -1625,15 +1694,26 @@ async function handleStreamGenerateContent(req, res) {
: '从loadCodeAssist获取'
})
const streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
const streamResponse =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentStreamAntigravity(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
: await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
@@ -1963,9 +2043,8 @@ async function handleStandardGenerateContent(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -1982,15 +2061,23 @@ async function handleStandardGenerateContent(req, res) {
} else {
// OAuth 账户
const { accessToken, refreshToken } = account
const oauthProvider = account.oauthProvider || 'gemini-cli'
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
oauthProvider
)
let effectiveProjectId = account.projectId || account.tempProjectId || null
if (!effectiveProjectId) {
if (oauthProvider === 'antigravity') {
if (!effectiveProjectId) {
// Antigravity 账号允许没有 projectId生成一个稳定的临时 projectId 并缓存
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
}
} else if (!effectiveProjectId) {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -2028,14 +2115,25 @@ async function handleStandardGenerateContent(req, res) {
const userPromptId = `${crypto.randomUUID()}########0`
response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
if (oauthProvider === 'antigravity') {
response = await geminiAccountService.generateContentAntigravity(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
} else {
response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
}
}
// 记录使用统计
@@ -2246,9 +2344,8 @@ async function handleStandardStreamGenerateContent(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -2268,12 +2365,20 @@ async function handleStandardStreamGenerateContent(req, res) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
let effectiveProjectId = account.projectId || account.tempProjectId || null
if (!effectiveProjectId) {
const oauthProvider = account.oauthProvider || 'gemini-cli'
if (oauthProvider === 'antigravity') {
if (!effectiveProjectId) {
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
}
} else if (!effectiveProjectId) {
try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -2311,15 +2416,27 @@ async function handleStandardStreamGenerateContent(req, res) {
const userPromptId = `${crypto.randomUUID()}########0`
streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
if (oauthProvider === 'antigravity') {
streamResponse = await geminiAccountService.generateContentStreamAntigravity(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
} else {
streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
}
}
// 设置 SSE 响应头

View File

@@ -6,6 +6,104 @@ const logger = require('../utils/logger')
const redis = require('../models/redis')
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { calculateWaitTimeStats } = require('../utils/statsHelper')
// 工具函数
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 检查排队是否过载,决定是否应该快速失败
* 详见 design.md Decision 7: 排队健康检查与快速失败
*
* @param {string} apiKeyId - API Key ID
* @param {number} timeoutMs - 排队超时时间(毫秒)
* @param {Object} queueConfig - 队列配置
* @param {number} maxQueueSize - 最大排队数
* @returns {Promise<Object>} { reject: boolean, reason?: string, estimatedWaitMs?: number, timeoutMs?: number }
*/
async function shouldRejectDueToOverload(apiKeyId, timeoutMs, queueConfig, maxQueueSize) {
try {
// 如果健康检查被禁用,直接返回不拒绝
if (!queueConfig.concurrentRequestQueueHealthCheckEnabled) {
return { reject: false, reason: 'health_check_disabled' }
}
// 🔑 先检查当前队列长度
const currentQueueCount = await redis.getConcurrencyQueueCount(apiKeyId).catch(() => 0)
// 队列为空,说明系统已恢复,跳过健康检查
if (currentQueueCount === 0) {
return { reject: false, reason: 'queue_empty', currentQueueCount: 0 }
}
// 🔑 关键改进:只有当队列接近满载时才进行健康检查
// 队列长度 <= maxQueueSize * 0.5 时,认为系统有足够余量,跳过健康检查
// 这避免了在队列较短时过于保守地拒绝请求
// 使用 ceil 确保小队列(如 maxQueueSize=3时阈值为 2即队列 <=1 时跳过
const queueLoadThreshold = Math.ceil(maxQueueSize * 0.5)
if (currentQueueCount <= queueLoadThreshold) {
return {
reject: false,
reason: 'queue_not_loaded',
currentQueueCount,
queueLoadThreshold,
maxQueueSize
}
}
// 获取该 API Key 的等待时间样本
const waitTimes = await redis.getQueueWaitTimes(apiKeyId)
const stats = calculateWaitTimeStats(waitTimes)
// 样本不足(< 10跳过健康检查避免冷启动误判
if (!stats || stats.sampleCount < 10) {
return { reject: false, reason: 'insufficient_samples', sampleCount: stats?.sampleCount || 0 }
}
// P90 不可靠时也跳过(虽然 sampleCount >= 10 时 p90Unreliable 应该是 false
if (stats.p90Unreliable) {
return { reject: false, reason: 'p90_unreliable', sampleCount: stats.sampleCount }
}
// 计算健康阈值P90 >= 超时时间 × 阈值 时拒绝
const threshold = queueConfig.concurrentRequestQueueHealthThreshold || 0.8
const maxAllowedP90 = timeoutMs * threshold
if (stats.p90 >= maxAllowedP90) {
return {
reject: true,
reason: 'queue_overloaded',
estimatedWaitMs: stats.p90,
timeoutMs,
threshold,
sampleCount: stats.sampleCount,
currentQueueCount,
maxQueueSize
}
}
return { reject: false, p90: stats.p90, sampleCount: stats.sampleCount, currentQueueCount }
} catch (error) {
// 健康检查出错时不阻塞请求,记录警告并继续
logger.warn(`Health check failed for ${apiKeyId}:`, error.message)
return { reject: false, reason: 'health_check_error', error: error.message }
}
}
// 排队轮询配置常量(可通过配置文件覆盖)
// 性能权衡:初始间隔越短响应越快,但 Redis QPS 越高
// 当前配置100 个等待者时约 250-300 QPS指数退避后
const QUEUE_POLLING_CONFIG = {
pollIntervalMs: 200, // 初始轮询间隔(毫秒)- 平衡响应速度和 Redis 压力
maxPollIntervalMs: 2000, // 最大轮询间隔(毫秒)- 长时间等待时降低 Redis 压力
backoffFactor: 1.5, // 指数退避系数
jitterRatio: 0.2, // 抖动比例±20%- 防止惊群效应
maxRedisFailCount: 5 // 连续 Redis 失败阈值(从 3 提高到 5提高网络抖动容忍度
}
const FALLBACK_CONCURRENCY_CONFIG = {
leaseSeconds: 300,
@@ -126,9 +224,223 @@ function isTokenCountRequest(req) {
return false
}
/**
* 等待并发槽位(排队机制核心)
*
* 采用「先占后检查」模式避免竞态条件:
* - 每次轮询时尝试 incrConcurrency 占位
* - 如果超限则 decrConcurrency 释放并继续等待
* - 成功获取槽位后返回,调用方无需再次 incrConcurrency
*
* ⚠️ 重要清理责任说明:
* - 排队计数:此函数的 finally 块负责调用 decrConcurrencyQueue 清理
* - 并发槽位:当返回 acquired=true 时,槽位已被占用(通过 incrConcurrency
* 调用方必须在请求结束时调用 decrConcurrency 释放槽位
* (已在 authenticateApiKey 的 finally 块中处理)
*
* @param {Object} req - Express 请求对象
* @param {Object} res - Express 响应对象
* @param {string} apiKeyId - API Key ID
* @param {Object} queueOptions - 配置参数
* @returns {Promise<Object>} { acquired: boolean, reason?: string, waitTimeMs: number }
*/
async function waitForConcurrencySlot(req, res, apiKeyId, queueOptions) {
const {
concurrencyLimit,
requestId,
leaseSeconds,
timeoutMs,
pollIntervalMs,
maxPollIntervalMs,
backoffFactor,
jitterRatio,
maxRedisFailCount: configMaxRedisFailCount
} = queueOptions
let clientDisconnected = false
// 追踪轮询过程中是否临时占用了槽位(用于异常时清理)
// 工作流程:
// 1. incrConcurrency 成功且 count <= limit 时,设置 internalSlotAcquired = true
// 2. 统计记录完成后,设置 internalSlotAcquired = false 并返回(所有权转移给调用方)
// 3. 如果在步骤 1-2 之间发生异常finally 块会检测到 internalSlotAcquired = true 并释放槽位
let internalSlotAcquired = false
// 监听客户端断开事件
// ⚠️ 重要:必须监听 socket 的事件,而不是 req 的事件!
// 原因:对于 POST 请求,当 body-parser 读取完请求体后reqIncomingMessage 可读流)
// 的 'close' 事件会立即触发,但这不代表客户端断开连接!客户端仍在等待响应。
// socket 的 'close' 事件才是真正的连接关闭信号。
const { socket } = req
const onSocketClose = () => {
clientDisconnected = true
logger.debug(
`🔌 [Queue] Socket closed during queue wait for API key ${apiKeyId}, requestId: ${requestId}`
)
}
if (socket) {
socket.once('close', onSocketClose)
}
// 检查 socket 是否在监听器注册前已被销毁(边界情况)
if (socket?.destroyed) {
clientDisconnected = true
}
const startTime = Date.now()
let pollInterval = pollIntervalMs
let redisFailCount = 0
// 优先使用配置中的值,否则使用默认值
const maxRedisFailCount = configMaxRedisFailCount || QUEUE_POLLING_CONFIG.maxRedisFailCount
try {
while (Date.now() - startTime < timeoutMs) {
// 检测客户端是否断开(双重检查:事件标记 + socket 状态)
// socket.destroyed 是同步检查,确保即使事件处理有延迟也能及时检测
if (clientDisconnected || socket?.destroyed) {
redis
.incrConcurrencyQueueStats(apiKeyId, 'cancelled')
.catch((e) => logger.warn('Failed to record cancelled stat:', e))
return {
acquired: false,
reason: 'client_disconnected',
waitTimeMs: Date.now() - startTime
}
}
// 尝试获取槽位(先占后检查)
try {
const count = await redis.incrConcurrency(apiKeyId, requestId, leaseSeconds)
redisFailCount = 0 // 重置失败计数
if (count <= concurrencyLimit) {
// 成功获取槽位!
const waitTimeMs = Date.now() - startTime
// 槽位所有权转移说明:
// 1. 此时槽位已通过 incrConcurrency 获取
// 2. 先标记 internalSlotAcquired = true确保异常时 finally 块能清理
// 3. 统计操作完成后,清除标记并返回,所有权转移给调用方
// 4. 调用方authenticateApiKey负责在请求结束时释放槽位
// 标记槽位已获取(用于异常时 finally 块清理)
internalSlotAcquired = true
// 记录统计非阻塞fire-and-forget 模式)
// ⚠️ 设计说明:
// - 故意不 await 这些 Promise因为统计记录不应阻塞请求处理
// - 每个 Promise 都有独立的 .catch(),确保单个失败不影响其他
// - 外层 .catch() 是防御性措施,处理 Promise.all 本身的异常
// - 即使统计记录在函数返回后才完成/失败,也是安全的(仅日志记录)
// - 统计数据丢失可接受,不影响核心业务逻辑
Promise.all([
redis
.recordQueueWaitTime(apiKeyId, waitTimeMs)
.catch((e) => logger.warn('Failed to record queue wait time:', e)),
redis
.recordGlobalQueueWaitTime(waitTimeMs)
.catch((e) => logger.warn('Failed to record global wait time:', e)),
redis
.incrConcurrencyQueueStats(apiKeyId, 'success')
.catch((e) => logger.warn('Failed to increment success stats:', e))
]).catch((e) => logger.warn('Failed to record queue stats batch:', e))
// 成功返回前清除标记(所有权转移给调用方,由其负责释放)
internalSlotAcquired = false
return { acquired: true, waitTimeMs }
}
// 超限,释放槽位继续等待
try {
await redis.decrConcurrency(apiKeyId, requestId)
} catch (decrError) {
// 释放失败时记录警告但继续轮询
// 下次 incrConcurrency 会自然覆盖同一 requestId 的条目
logger.warn(
`Failed to release slot during polling for ${apiKeyId}, will retry:`,
decrError
)
}
} catch (redisError) {
redisFailCount++
logger.error(
`Redis error in queue polling (${redisFailCount}/${maxRedisFailCount}):`,
redisError
)
if (redisFailCount >= maxRedisFailCount) {
// 连续 Redis 失败,放弃排队
return {
acquired: false,
reason: 'redis_error',
waitTimeMs: Date.now() - startTime
}
}
}
// 指数退避等待
await sleep(pollInterval)
// 计算下一次轮询间隔(指数退避 + 抖动)
// 1. 先应用指数退避
let nextInterval = pollInterval * backoffFactor
// 2. 添加抖动防止惊群效应±jitterRatio 范围内的随机偏移)
// 抖动范围:[-jitterRatio, +jitterRatio],例如 jitterRatio=0.2 时为 ±20%
// 这是预期行为:负抖动可使间隔略微缩短,正抖动可使间隔略微延长
// 目的是分散多个等待者的轮询时间点,避免同时请求 Redis
const jitter = nextInterval * jitterRatio * (Math.random() * 2 - 1)
nextInterval = nextInterval + jitter
// 3. 确保在合理范围内:最小 1ms最大 maxPollIntervalMs
// Math.max(1, ...) 保证即使负抖动也不会产生 ≤0 的间隔
pollInterval = Math.max(1, Math.min(nextInterval, maxPollIntervalMs))
}
// 超时
redis
.incrConcurrencyQueueStats(apiKeyId, 'timeout')
.catch((e) => logger.warn('Failed to record timeout stat:', e))
return { acquired: false, reason: 'timeout', waitTimeMs: Date.now() - startTime }
} finally {
// 确保清理:
// 1. 减少排队计数(排队计数在调用方已增加,这里负责减少)
try {
await redis.decrConcurrencyQueue(apiKeyId)
} catch (cleanupError) {
// 清理失败记录错误(可能导致计数泄漏,但有 TTL 保护)
logger.error(
`Failed to decrement queue count in finally block for ${apiKeyId}:`,
cleanupError
)
}
// 2. 如果内部获取了槽位但未正常返回(异常路径),释放槽位
if (internalSlotAcquired) {
try {
await redis.decrConcurrency(apiKeyId, requestId)
logger.warn(
`⚠️ Released orphaned concurrency slot in finally block for ${apiKeyId}, requestId: ${requestId}`
)
} catch (slotCleanupError) {
logger.error(
`Failed to release orphaned concurrency slot for ${apiKeyId}:`,
slotCleanupError
)
}
}
// 清理 socket 事件监听器
if (socket) {
socket.removeListener('close', onSocketClose)
}
}
}
// 🔑 API Key验证中间件优化版
const authenticateApiKey = async (req, res, next) => {
const startTime = Date.now()
let authErrored = false
let concurrencyCleanup = null
let hasConcurrencySlot = false
try {
// 安全提取API Key支持多种格式包括Gemini CLI支持
@@ -201,6 +513,53 @@ const authenticateApiKey = async (req, res, next) => {
)
}
// 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑)
// 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages)
if (!skipKeyRestrictions) {
const normalizedPath = (req.originalUrl || req.path || '').toLowerCase()
const isClaudeMessagesEndpoint =
normalizedPath.includes('/v1/messages') &&
(normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude'))
if (isClaudeMessagesEndpoint) {
try {
const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled()
// API Key 级别的 Claude Code 限制
const keyClaudeCodeOnly =
validation.keyData.enableClientRestriction &&
Array.isArray(validation.keyData.allowedClients) &&
validation.keyData.allowedClients.length === 1 &&
validation.keyData.allowedClients.includes('claude_code')
// OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code
if (globalClaudeCodeOnly || keyClaudeCodeOnly) {
const isClaudeCode = ClaudeCodeValidator.validate(req)
if (!isClaudeCode) {
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
logger.api(
`❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}`
)
return res.status(403).json({
error: {
type: 'client_validation_error',
message: 'This endpoint only accepts requests from Claude Code CLI'
}
})
}
logger.api(
`✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})`
)
}
} catch (error) {
logger.error('❌ Error checking Claude Code restriction:', error)
// 配置服务出错时不阻断请求
}
}
}
// 检查并发限制
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
if (!skipKeyRestrictions && concurrencyLimit > 0) {
@@ -216,29 +575,346 @@ const authenticateApiKey = async (req, res, next) => {
}
const requestId = uuidv4()
// ⚠️ 优化后的 Connection: close 设置策略
// 问题背景HTTP Keep-Alive 使多个请求共用同一个 TCP 连接
// 当第一个请求正在处理,第二个请求进入排队时,它们共用同一个 socket
// 如果客户端超时关闭连接,两个请求都会受影响
// 优化方案:只有在请求实际进入排队时才设置 Connection: close
// 未排队的请求保持 Keep-Alive避免不必要的 TCP 握手开销
// 详见 design.md Decision 2: Connection: close 设置时机
// 注意Connection: close 将在下方代码实际进入排队时设置(第 637 行左右)
// ============================================================
// 🔒 并发槽位状态管理说明
// ============================================================
// 此函数中有两个关键状态变量:
// - hasConcurrencySlot: 当前是否持有并发槽位
// - concurrencyCleanup: 错误时调用的清理函数
//
// 状态转换流程:
// 1. incrConcurrency 成功 → hasConcurrencySlot=true, 设置临时清理函数
// 2. 若超限 → 释放槽位hasConcurrencySlot=false, concurrencyCleanup=null
// 3. 若排队成功 → hasConcurrencySlot=true, 升级为完整清理函数(含 interval 清理)
// 4. 请求结束res.close/req.close→ 调用 decrementConcurrency 释放
// 5. 认证错误 → finally 块调用 concurrencyCleanup 释放
//
// 为什么需要两种清理函数?
// - 临时清理:在排队/认证过程中出错时使用,只释放槽位
// - 完整清理:请求正常开始后使用,还需清理 leaseRenewInterval
// ============================================================
const setTemporaryConcurrencyCleanup = () => {
concurrencyCleanup = async () => {
if (!hasConcurrencySlot) {
return
}
hasConcurrencySlot = false
try {
await redis.decrConcurrency(validation.keyData.id, requestId)
} catch (cleanupError) {
logger.error(
`Failed to decrement concurrency after auth error for key ${validation.keyData.id}:`,
cleanupError
)
}
}
}
const currentConcurrency = await redis.incrConcurrency(
validation.keyData.id,
requestId,
leaseSeconds
)
hasConcurrencySlot = true
setTemporaryConcurrencyCleanup()
logger.api(
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
)
if (currentConcurrency > concurrencyLimit) {
// 如果超过限制,立即减少计数
await redis.decrConcurrency(validation.keyData.id, requestId)
logger.security(
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
// 1. 先释放刚占用的槽位
try {
await redis.decrConcurrency(validation.keyData.id, requestId)
} catch (error) {
logger.error(
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
error
)
}
hasConcurrencySlot = false
concurrencyCleanup = null
// 2. 获取排队配置
const queueConfig = await claudeRelayConfigService.getConfig()
// 3. 排队功能未启用,直接返回 429保持现有行为
if (!queueConfig.concurrentRequestQueueEnabled) {
logger.security(
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`
)
// 建议客户端在短暂延迟后重试(并发场景下通常很快会有槽位释放)
res.set('Retry-After', '1')
return res.status(429).json({
error: 'Concurrency limit exceeded',
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
currentConcurrency: currentConcurrency - 1,
concurrencyLimit
})
}
// 4. 计算最大排队数
const maxQueueSize = Math.max(
concurrencyLimit * queueConfig.concurrentRequestQueueMaxSizeMultiplier,
queueConfig.concurrentRequestQueueMaxSize
)
return res.status(429).json({
error: 'Concurrency limit exceeded',
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`,
currentConcurrency: currentConcurrency - 1,
concurrencyLimit
})
// 4.5 排队健康检查:过载时快速失败
// 详见 design.md Decision 7: 排队健康检查与快速失败
const overloadCheck = await shouldRejectDueToOverload(
validation.keyData.id,
queueConfig.concurrentRequestQueueTimeoutMs,
queueConfig,
maxQueueSize
)
if (overloadCheck.reject) {
// 使用健康检查返回的当前排队数,避免重复调用 Redis
const currentQueueCount = overloadCheck.currentQueueCount || 0
logger.api(
`🚨 Queue overloaded for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`P90=${overloadCheck.estimatedWaitMs}ms, timeout=${overloadCheck.timeoutMs}ms, ` +
`threshold=${overloadCheck.threshold}, samples=${overloadCheck.sampleCount}, ` +
`concurrency=${concurrencyLimit}, queue=${currentQueueCount}/${maxQueueSize}`
)
// 记录被拒绝的过载统计
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'rejected_overload')
.catch((e) => logger.warn('Failed to record rejected_overload stat:', e))
// 返回 429 + Retry-After让客户端稍后重试
const retryAfterSeconds = 30
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Queue overloaded',
message: `Queue is overloaded. Estimated wait time (${overloadCheck.estimatedWaitMs}ms) exceeds threshold. Limit: ${concurrencyLimit} concurrent requests, queue: ${currentQueueCount}/${maxQueueSize}. Please retry later.`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
queueCount: currentQueueCount,
maxQueueSize,
estimatedWaitMs: overloadCheck.estimatedWaitMs,
timeoutMs: overloadCheck.timeoutMs,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
retryAfterSeconds
})
}
// 5. 尝试进入排队(原子操作:先增加再检查,避免竞态条件)
let queueIncremented = false
try {
const newQueueCount = await redis.incrConcurrencyQueue(
validation.keyData.id,
queueConfig.concurrentRequestQueueTimeoutMs
)
queueIncremented = true
if (newQueueCount > maxQueueSize) {
// 超过最大排队数,立即释放并返回 429
await redis.decrConcurrencyQueue(validation.keyData.id)
queueIncremented = false
logger.api(
`🚦 Concurrency queue full for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`queue: ${newQueueCount - 1}, maxQueue: ${maxQueueSize}`
)
// 队列已满,建议客户端在排队超时时间后重试
const retryAfterSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Concurrency queue full',
message: `Too many requests waiting in queue. Limit: ${concurrencyLimit} concurrent requests, queue: ${newQueueCount - 1}/${maxQueueSize}, timeout: ${retryAfterSeconds}s`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
queueCount: newQueueCount - 1,
maxQueueSize,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
retryAfterSeconds
})
}
// 6. 已成功进入排队,记录统计并开始等待槽位
logger.api(
`⏳ Request entering queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`queue position: ${newQueueCount}`
)
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'entered')
.catch((e) => logger.warn('Failed to record entered stat:', e))
// ⚠️ 仅在请求实际进入排队时设置 Connection: close
// 详见 design.md Decision 2: Connection: close 设置时机
// 未排队的请求保持 Keep-Alive避免不必要的 TCP 握手开销
if (!res.headersSent) {
res.setHeader('Connection', 'close')
logger.api(
`🔌 [Queue] Set Connection: close for queued request, key: ${validation.keyData.id}`
)
}
// ⚠️ 记录排队开始时的 socket 标识,用于排队完成后验证
// 问题背景HTTP Keep-Alive 连接复用时,长时间排队可能导致 socket 被其他请求使用
// 验证方法:使用 UUID token + socket 对象引用双重验证
// 详见 design.md Decision 1: Socket 身份验证机制
req._crService = req._crService || {}
req._crService.queueToken = uuidv4()
req._crService.originalSocket = req.socket
req._crService.startTime = Date.now()
const savedToken = req._crService.queueToken
const savedSocket = req._crService.originalSocket
// ⚠️ 重要:在调用前将 queueIncremented 设为 false
// 因为 waitForConcurrencySlot 的 finally 块会负责清理排队计数
// 如果在调用后设置,当 waitForConcurrencySlot 抛出异常时
// 外层 catch 块会重复减少计数finally 已经减过一次)
queueIncremented = false
const slot = await waitForConcurrencySlot(req, res, validation.keyData.id, {
concurrencyLimit,
requestId,
leaseSeconds,
timeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
pollIntervalMs: QUEUE_POLLING_CONFIG.pollIntervalMs,
maxPollIntervalMs: QUEUE_POLLING_CONFIG.maxPollIntervalMs,
backoffFactor: QUEUE_POLLING_CONFIG.backoffFactor,
jitterRatio: QUEUE_POLLING_CONFIG.jitterRatio,
maxRedisFailCount: queueConfig.concurrentRequestQueueMaxRedisFailCount
})
// 7. 处理排队结果
if (!slot.acquired) {
if (slot.reason === 'client_disconnected') {
// 客户端已断开,不返回响应(连接已关闭)
logger.api(
`🔌 Client disconnected while queuing for key: ${validation.keyData.id} (${validation.keyData.name})`
)
return
}
if (slot.reason === 'redis_error') {
// Redis 连续失败,返回 503
logger.error(
`❌ Redis error during queue wait for key: ${validation.keyData.id} (${validation.keyData.name})`
)
return res.status(503).json({
error: 'Service temporarily unavailable',
message: 'Failed to acquire concurrency slot due to internal error'
})
}
// 排队超时(使用 api 级别,与其他排队日志保持一致)
logger.api(
`⏰ Queue timeout for key: ${validation.keyData.id} (${validation.keyData.name}), waited: ${slot.waitTimeMs}ms`
)
// 已等待超时,建议客户端稍后重试
// ⚠️ Retry-After 策略优化:
// - 请求已经等了完整的 timeout 时间,说明系统负载较高
// - 过早重试(如固定 5 秒)会加剧拥塞,导致更多超时
// - 合理策略:使用 timeout 时间的一半作为重试间隔
// - 最小值 5 秒,最大值 30 秒,避免极端情况
const timeoutSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000)
const retryAfterSeconds = Math.max(5, Math.min(30, Math.ceil(timeoutSeconds / 2)))
res.set('Retry-After', String(retryAfterSeconds))
return res.status(429).json({
error: 'Queue timeout',
message: `Request timed out waiting for concurrency slot. Limit: ${concurrencyLimit} concurrent requests, maxQueue: ${maxQueueSize}, Queue timeout: ${timeoutSeconds}s, waited: ${slot.waitTimeMs}ms`,
currentConcurrency: concurrencyLimit,
concurrencyLimit,
maxQueueSize,
queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs,
waitTimeMs: slot.waitTimeMs,
retryAfterSeconds
})
}
// 8. 排队成功slot.acquired 表示已在 waitForConcurrencySlot 中获取到槽位
logger.api(
`✅ Queue wait completed for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms`
)
hasConcurrencySlot = true
setTemporaryConcurrencyCleanup()
// 9. ⚠️ 关键检查:排队等待结束后,验证客户端是否还在等待响应
// 长时间排队后,客户端可能在应用层已放弃(如 Claude Code 的超时机制),
// 但 TCP 连接仍然存活。此时继续处理请求是浪费资源。
// 注意如果发送了心跳headersSent 会是 true但这是正常的
const postQueueSocket = req.socket
// 只检查连接是否真正断开destroyed/writableEnded/socketDestroyed
// headersSent 在心跳场景下是正常的,不应该作为放弃的依据
if (res.destroyed || res.writableEnded || postQueueSocket?.destroyed) {
logger.warn(
`⚠️ Client no longer waiting after queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms | destroyed: ${res.destroyed}, ` +
`writableEnded: ${res.writableEnded}, socketDestroyed: ${postQueueSocket?.destroyed}`
)
// 释放刚获取的槽位
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) => logger.error('Failed to release slot after client abandoned:', e))
// 不返回响应(客户端已不在等待)
return
}
// 10. ⚠️ 关键检查:验证 socket 身份是否改变
// HTTP Keep-Alive 连接复用可能导致排队期间 socket 被其他请求使用
// 验证方法UUID token + socket 对象引用双重验证
// 详见 design.md Decision 1: Socket 身份验证机制
const queueData = req._crService
const socketIdentityChanged =
!queueData ||
queueData.queueToken !== savedToken ||
queueData.originalSocket !== savedSocket
if (socketIdentityChanged) {
logger.error(
`❌ [Queue] Socket identity changed during queue wait! ` +
`key: ${validation.keyData.id} (${validation.keyData.name}), ` +
`waited: ${slot.waitTimeMs}ms | ` +
`tokenMatch: ${queueData?.queueToken === savedToken}, ` +
`socketMatch: ${queueData?.originalSocket === savedSocket}`
)
// 释放刚获取的槽位
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) => logger.error('Failed to release slot after socket identity change:', e))
// 记录 socket_changed 统计
redis
.incrConcurrencyQueueStats(validation.keyData.id, 'socket_changed')
.catch((e) => logger.warn('Failed to record socket_changed stat:', e))
// 不返回响应socket 已被其他请求使用)
return
}
} catch (queueError) {
// 异常时清理资源,防止泄漏
// 1. 清理排队计数(如果还没被 waitForConcurrencySlot 的 finally 清理)
if (queueIncremented) {
await redis
.decrConcurrencyQueue(validation.keyData.id)
.catch((e) => logger.error('Failed to cleanup queue count after error:', e))
}
// 2. 防御性清理:如果 waitForConcurrencySlot 内部获取了槽位但在返回前异常
// 虽然这种情况极少发生(统计记录的异常会被内部捕获),但为了安全起见
// 尝试释放可能已获取的槽位。decrConcurrency 使用 ZREM即使成员不存在也安全
if (hasConcurrencySlot) {
hasConcurrencySlot = false
await redis
.decrConcurrency(validation.keyData.id, requestId)
.catch((e) =>
logger.error('Failed to cleanup concurrency slot after queue error:', e)
)
}
throw queueError
}
}
const renewIntervalMs =
@@ -249,7 +925,38 @@ const authenticateApiKey = async (req, res, next) => {
let leaseRenewInterval = null
if (renewIntervalMs > 0) {
// 🔴 关键修复:添加最大刷新次数限制,防止租约永不过期
// 默认最大生存时间为 10 分钟,可通过环境变量配置
const maxLifetimeMinutes = parseInt(process.env.CONCURRENCY_MAX_LIFETIME_MINUTES) || 10
const maxRefreshCount = Math.ceil((maxLifetimeMinutes * 60 * 1000) / renewIntervalMs)
let refreshCount = 0
leaseRenewInterval = setInterval(() => {
refreshCount++
// 超过最大刷新次数,强制停止并清理
if (refreshCount > maxRefreshCount) {
logger.warn(
`⚠️ Lease refresh exceeded max count (${maxRefreshCount}) for key ${validation.keyData.id} (${validation.keyData.name}), forcing cleanup after ${maxLifetimeMinutes} minutes`
)
// 清理定时器
if (leaseRenewInterval) {
clearInterval(leaseRenewInterval)
leaseRenewInterval = null
}
// 强制减少并发计数(如果还没减少)
if (!concurrencyDecremented) {
concurrencyDecremented = true
redis.decrConcurrency(validation.keyData.id, requestId).catch((error) => {
logger.error(
`Failed to decrement concurrency after max refresh for key ${validation.keyData.id}:`,
error
)
})
}
return
}
redis
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
.catch((error) => {
@@ -268,6 +975,7 @@ const authenticateApiKey = async (req, res, next) => {
const decrementConcurrency = async () => {
if (!concurrencyDecremented) {
concurrencyDecremented = true
hasConcurrencySlot = false
if (leaseRenewInterval) {
clearInterval(leaseRenewInterval)
leaseRenewInterval = null
@@ -282,6 +990,11 @@ const authenticateApiKey = async (req, res, next) => {
}
}
}
// 升级为完整清理函数(包含 leaseRenewInterval 清理逻辑)
// 此时请求已通过认证,后续由 res.close/req.close 事件触发清理
if (hasConcurrencySlot) {
concurrencyCleanup = decrementConcurrency
}
// 监听最可靠的事件(避免重复监听)
// res.on('close') 是最可靠的,会在连接关闭时触发
@@ -607,6 +1320,7 @@ const authenticateApiKey = async (req, res, next) => {
return next()
} catch (error) {
authErrored = true
const authDuration = Date.now() - startTime
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, {
error: error.message,
@@ -620,6 +1334,14 @@ const authenticateApiKey = async (req, res, next) => {
error: 'Authentication error',
message: 'Internal server error during authentication'
})
} finally {
if (authErrored && typeof concurrencyCleanup === 'function') {
try {
await concurrencyCleanup()
} catch (cleanupError) {
logger.error('Failed to cleanup concurrency after auth error:', cleanupError)
}
}
}
}
@@ -667,6 +1389,18 @@ const authenticateAdmin = async (req, res, next) => {
})
}
// 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
if (!adminSession.username || !adminSession.loginTime) {
logger.security(
`🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
)
await redis.deleteSession(token) // 清理无效/伪造的会话
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 检查会话活跃性(可选:检查最后活动时间)
const now = new Date()
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
@@ -1022,9 +1756,13 @@ const requestLogger = (req, res, next) => {
const referer = req.get('Referer') || 'none'
// 记录请求开始
const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') {
// 避免健康检查日志过多
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
if (isDebugRoute) {
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} else {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
}
}
res.on('finish', () => {
@@ -1056,7 +1794,14 @@ const requestLogger = (req, res, next) => {
logMetadata
)
} else if (req.originalUrl !== '/health') {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
if (isDebugRoute) {
logger.debug(
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
logMetadata
)
} else {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
}
}
// API Key相关日志
@@ -1298,7 +2043,8 @@ const globalRateLimit = async (req, res, next) =>
// 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => {
const maxSize = 60 * 1024 * 1024 // 60MB
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
const maxSize = MAX_SIZE_MB * 1024 * 1024
const contentLength = parseInt(req.headers['content-length'] || '0')
if (contentLength > maxSize) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
const express = require('express')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const accountBalanceService = require('../../services/accountBalanceService')
const balanceScriptService = require('../../services/balanceScriptService')
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
const router = express.Router()
const ensureValidPlatform = (rawPlatform) => {
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
if (!normalized) {
return { ok: false, status: 400, error: '缺少 platform 参数' }
}
const supported = accountBalanceService.getSupportedPlatforms()
if (!supported.includes(normalized)) {
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
}
return { ok: true, platform: normalized }
}
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform, queryApi } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
queryApi
})
if (!balance) {
return res.status(404).json({ success: false, error: 'Account not found' })
}
return res.json(balance)
} catch (error) {
logger.error('获取账户余额失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 2) 强制刷新账户余额强制触发查询优先脚本Provider 仅为降级)
// POST /admin/accounts/:accountId/balance/refresh
// Body: { platform: 'xxx' }
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.body || {}
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
if (!balance) {
return res.status(404).json({ success: false, error: 'Account not found' })
}
return res.json(balance)
} catch (error) {
logger.error('刷新账户余额失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 3) 批量获取平台所有账户余额
// GET /admin/accounts/balance/platform/:platform?queryApi=false
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
try {
const { platform } = req.params
const { queryApi } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
return res.json({ success: true, data: balances })
} catch (error) {
logger.error('批量获取余额失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 4) 获取余额汇总Dashboard 用)
// GET /admin/accounts/balance/summary
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
try {
const summary = await accountBalanceService.getBalanceSummary()
return res.json({ success: true, data: summary })
} catch (error) {
logger.error('获取余额汇总失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 5) 清除缓存
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
await accountBalanceService.clearCache(accountId, valid.platform)
return res.json({ success: true, message: '缓存已清除' })
} catch (error) {
logger.error('清除缓存失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
// 6) 获取/保存/测试余额脚本配置(单账户)
router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const config = await accountBalanceService.redis.getBalanceScriptConfig(
valid.platform,
accountId
)
return res.json({ success: true, data: config || null })
} catch (error) {
logger.error('获取余额脚本配置失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
const payload = req.body || {}
await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload)
return res.json({ success: true, data: payload })
} catch (error) {
logger.error('保存余额脚本配置失败', error)
return res.status(500).json({ success: false, error: error.message })
}
})
router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform } = req.query
const valid = ensureValidPlatform(platform)
if (!valid.ok) {
return res.status(valid.status).json({ success: false, error: valid.error })
}
if (!isBalanceScriptEnabled()) {
return res.status(403).json({
success: false,
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
})
}
const payload = req.body || {}
const { scriptBody } = payload
if (!scriptBody) {
return res.status(400).json({ success: false, error: '脚本内容不能为空' })
}
const result = await balanceScriptService.execute({
scriptBody,
timeoutSeconds: payload.timeoutSeconds || 10,
variables: {
baseUrl: payload.baseUrl || '',
apiKey: payload.apiKey || '',
token: payload.token || '',
accountId,
platform: valid.platform,
extra: payload.extra || ''
}
})
return res.json({ success: true, data: result })
} catch (error) {
logger.error('测试余额脚本失败', error)
return res.status(400).json({ success: false, error: error.message })
}
})
module.exports = router

View File

@@ -8,6 +8,43 @@ const config = require('../../../config/config')
const router = express.Router()
// 有效的权限值列表
const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid']
/**
* 验证权限数组格式
* @param {any} permissions - 权限值(可以是数组或其他)
* @returns {string|null} - 返回错误消息null 表示验证通过
*/
function validatePermissions(permissions) {
// 空值或未定义表示全部服务
if (permissions === undefined || permissions === null || permissions === '') {
return null
}
// 兼容旧格式字符串
if (typeof permissions === 'string') {
if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) {
return null
}
return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}`
}
// 新格式数组
if (Array.isArray(permissions)) {
// 空数组表示全部服务
if (permissions.length === 0) {
return null
}
// 验证数组中的每个值
for (const perm of permissions) {
if (!VALID_PERMISSIONS.includes(perm)) {
return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}`
}
}
return null
}
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
}
// 👥 用户管理 (用于API Key分配)
// 获取所有用户列表用于API Key分配
@@ -103,6 +140,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
router.get('/api-keys', authenticateAdmin, async (req, res) => {
try {
@@ -116,6 +164,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
// 筛选参数
tag = '',
isActive = '',
models = '', // 模型筛选(逗号分隔)
// 排序参数
sortBy = 'createdAt',
sortOrder = 'desc',
@@ -127,6 +176,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
timeRange = 'all'
} = req.query
// 解析模型筛选参数
const modelFilter = models ? models.split(',').filter((m) => m.trim()) : []
// 验证分页参数
const pageNum = Math.max(1, parseInt(page) || 1)
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
@@ -217,7 +269,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
search,
searchMode,
tag,
isActive
isActive,
modelFilter
})
costSortStatus = {
@@ -250,7 +303,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
search,
searchMode,
tag,
isActive
isActive,
modelFilter
})
costSortStatus.isRealTimeCalculation = false
@@ -265,7 +319,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
tag,
isActive,
sortBy: validSortBy,
sortOrder: validSortOrder
sortOrder: validSortOrder,
modelFilter
})
}
@@ -322,7 +377,17 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
* 使用预计算索引进行费用排序的分页查询
*/
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')
// 1. 获取排序后的全量 keyId 列表
@@ -369,6 +434,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. 收集所有可用标签
const allTags = new Set()
for (const key of allKeys) {
@@ -411,8 +485,18 @@ async function getApiKeysSortedByCostPrecomputed(options) {
* 使用实时计算进行 custom 时间范围的费用排序
*/
async function getApiKeysSortedByCostCustom(options) {
const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } =
options
const {
page,
pageSize,
sortOrder,
startDate,
endDate,
search,
searchMode,
tag,
isActive,
modelFilter = []
} = options
const costRankService = require('../../services/costRankService')
// 1. 实时计算所有 Keys 的费用
@@ -427,9 +511,9 @@ async function getApiKeysSortedByCostCustom(options) {
}
// 2. 转换为数组并排序
const sortedEntries = [...costs.entries()].sort((a, b) => {
return sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
})
const sortedEntries = [...costs.entries()].sort((a, b) =>
sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
)
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
// 3. 批量获取 API Key 基础数据
@@ -465,6 +549,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. 收集所有可用标签
const allTags = new Set()
for (const key of allKeys) {
@@ -863,6 +956,86 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
// 去重(避免日数据和月数据重复计算)
const uniqueKeys = [...new Set(allKeys)]
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
let dailyCost = 0
let currentWindowCost = 0
let windowRemainingSeconds = null
let windowStartTime = null
let windowEndTime = null
let allTimeCost = 0
try {
// 先获取 API Key 配置,判断是否需要查询限制相关数据
const apiKey = await redis.getApiKey(keyId)
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
// 只在启用了每日费用限制时查询
if (dailyCostLimit > 0) {
dailyCost = await redis.getDailyCost(keyId)
}
// 只在启用了总费用限制时查询
if (totalCostLimit > 0) {
const totalCostKey = `usage:cost:total:${keyId}`
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
}
// 🔧 FIX: 对于 "全部时间" 时间范围,直接使用 allTimeCost
// 因为 usage:*:model:daily:* 键有 30 天 TTL旧数据已经过期
if (timeRange === 'all' && allTimeCost > 0) {
logger.debug(`📊 使用 allTimeCost 计算 timeRange='all': ${allTimeCost}`)
return {
requests: 0, // 旧数据详情不可用
tokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: allTimeCost,
formattedCost: CostCalculator.formatCost(allTimeCost),
// 实时限制数据(始终返回,不受时间范围影响)
dailyCost,
currentWindowCost,
windowRemainingSeconds,
windowStartTime,
windowEndTime,
allTimeCost
}
}
// 只在启用了窗口限制时查询窗口数据
if (rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`
const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
if (windowStart) {
const now = Date.now()
windowStartTime = parseInt(windowStart)
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
windowEndTime = windowStartTime + windowDuration
// 如果窗口还有效
if (now < windowEndTime) {
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
} else {
// 窗口已过期
windowRemainingSeconds = 0
currentWindowCost = 0
}
}
}
} catch (error) {
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
}
// 如果没有使用数据,返回零值但包含窗口数据
if (uniqueKeys.length === 0) {
return {
requests: 0,
@@ -872,7 +1045,14 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: 0,
formattedCost: '$0.00'
formattedCost: '$0.00',
// 实时限制数据(始终返回,不受时间范围影响)
dailyCost,
currentWindowCost,
windowRemainingSeconds,
windowStartTime,
windowEndTime,
allTimeCost
}
}
@@ -887,12 +1067,10 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
const modelStatsMap = new Map()
let totalRequests = 0
// 用于去重:统计数据,避免与数据重复
// 用于去重:统计数据,避免与数据重复
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
// 检查是否有日数据
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
for (let i = 0; i < results.length; i++) {
const [err, data] = results[i]
@@ -919,8 +1097,12 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
continue
}
// 如果有日数据,则跳过月数据以避免重复
if (hasDailyData && isMonthly) {
// 跳过当前月的月数据
if (isMonthly && key.includes(`:${currentMonth}`)) {
continue
}
// 跳过非当前月的日数据
if (!isMonthly && !key.includes(`:${currentMonth}-`)) {
continue
}
@@ -973,52 +1155,6 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 获取实时限制数据
let dailyCost = 0
let currentWindowCost = 0
let windowRemainingSeconds = null
let windowStartTime = null
let windowEndTime = null
let allTimeCost = 0
try {
// 获取当日费用
dailyCost = await redis.getDailyCost(keyId)
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
const totalCostKey = `usage:cost:total:${keyId}`
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
// 获取 API Key 配置信息以判断是否需要窗口数据
const apiKey = await redis.getApiKey(keyId)
if (apiKey && apiKey.rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`
const windowStartKey = `rate_limit:window_start:${keyId}`
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
// 获取窗口开始时间和计算剩余时间
const windowStart = await client.get(windowStartKey)
if (windowStart) {
const now = Date.now()
windowStartTime = parseInt(windowStart)
const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒
windowEndTime = windowStartTime + windowDuration
// 如果窗口还有效
if (now < windowEndTime) {
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
} else {
// 窗口已过期
windowRemainingSeconds = 0
currentWindowCost = 0
}
}
}
} catch (error) {
logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message)
}
return {
requests: totalRequests,
tokens,
@@ -1283,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
}
}
// 验证服务权限字段
if (
permissions !== undefined &&
permissions !== null &&
permissions !== '' &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
const permissionsError = validatePermissions(permissions)
if (permissionsError) {
return res.status(400).json({ error: permissionsError })
}
const newKey = await apiKeyService.generateApiKey({
@@ -1382,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
}
if (
permissions !== undefined &&
permissions !== null &&
permissions !== '' &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
const batchPermissionsError = validatePermissions(permissions)
if (batchPermissionsError) {
return res.status(400).json({ error: batchPermissionsError })
}
// 生成批量API Keys
@@ -1493,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
})
}
if (
updates.permissions !== undefined &&
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
if (updates.permissions !== undefined) {
const updatePermissionsError = validatePermissions(updates.permissions)
if (updatePermissionsError) {
return res.status(400).json({ error: updatePermissionsError })
}
}
logger.info(
@@ -1774,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
}
if (permissions !== undefined) {
// 验证权限值
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
return res.status(400).json({
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
})
// 验证服务权限字段(支持数组格式)
const singlePermissionsError = validatePermissions(permissions)
if (singlePermissionsError) {
return res.status(400).json({ error: singlePermissionsError })
}
updates.permissions = permissions
}

View File

@@ -0,0 +1,41 @@
const express = require('express')
const { authenticateAdmin } = require('../../middleware/auth')
const balanceScriptService = require('../../services/balanceScriptService')
const router = express.Router()
// 获取全部脚本配置列表
router.get('/balance-scripts', authenticateAdmin, (req, res) => {
const items = balanceScriptService.listConfigs()
return res.json({ success: true, data: items })
})
// 获取单个脚本配置
router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => {
const { name } = req.params
const config = balanceScriptService.getConfig(name || 'default')
return res.json({ success: true, data: config })
})
// 保存脚本配置
router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => {
try {
const { name } = req.params
const saved = balanceScriptService.saveConfig(name || 'default', req.body || {})
return res.json({ success: true, data: saved })
} catch (error) {
return res.status(400).json({ success: false, error: error.message })
}
})
// 测试脚本(不落库)
router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => {
try {
const { name } = req.params
const result = await balanceScriptService.testScript(name || 'default', req.body || {})
return res.json({ success: true, data: result })
} catch (error) {
return res.status(400).json({ success: false, error: error.message })
}
})
module.exports = router

View File

@@ -9,6 +9,7 @@ const router = express.Router()
const claudeAccountService = require('../../services/claudeAccountService')
const claudeRelayService = require('../../services/claudeRelayService')
const accountGroupService = require('../../services/accountGroupService')
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
@@ -277,7 +278,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req,
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})
@@ -326,7 +327,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
hasProxy: !!proxy
})
@@ -583,7 +584,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedClientId,
unifiedClientId,
expiresAt,
extInfo
extInfo,
maxConcurrency,
interceptWarmup
} = req.body
if (!name) {
@@ -628,7 +631,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
expiresAt: expiresAt || null, // 账户订阅到期时间
extInfo: extInfo || null
extInfo: extInfo || null,
maxConcurrency: maxConcurrency || 0, // 账户级串行队列0=使用全局配置,>0=强制启用
interceptWarmup: interceptWarmup === true // 拦截预热请求默认为false
})
// 如果是分组类型,将账户添加到分组
@@ -903,4 +908,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
}
})
// ============================================================================
// 账户定时测试相关端点
// ============================================================================
// 获取账户测试历史
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
const history = await redis.getAccountTestHistory(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
history
}
})
} catch (error) {
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to get test history',
message: error.message
})
}
})
// 获取账户定时测试配置
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
config: testConfig || {
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
}
}
})
} catch (error) {
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to get test config',
message: error.message
})
}
})
// 设置账户定时测试配置
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { enabled, cronExpression, model } = req.body
try {
// 验证 enabled 参数
if (typeof enabled !== 'boolean') {
return res.status(400).json({
error: 'Invalid parameter',
message: 'enabled must be a boolean'
})
}
// 验证 cronExpression 参数
if (!cronExpression || typeof cronExpression !== 'string') {
return res.status(400).json({
error: 'Invalid parameter',
message: 'cronExpression is required and must be a string'
})
}
// 限制 cronExpression 长度防止 DoS
const MAX_CRON_LENGTH = 100
if (cronExpression.length > MAX_CRON_LENGTH) {
return res.status(400).json({
error: 'Invalid parameter',
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
})
}
// 使用 service 的方法验证 cron 表达式
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
return res.status(400).json({
error: 'Invalid parameter',
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
})
}
// 验证模型参数
const testModel = model || 'claude-sonnet-4-5-20250929'
if (typeof testModel !== 'string' || testModel.length > 256) {
return res.status(400).json({
error: 'Invalid parameter',
message: 'model must be a valid string (max 256 characters)'
})
}
// 检查账户是否存在
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: 'Account not found',
message: `Claude account ${accountId} not found`
})
}
// 保存配置
await redis.saveAccountTestConfig(accountId, 'claude', {
enabled,
cronExpression,
model: testModel
})
logger.success(
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
)
return res.json({
success: true,
message: 'Test config updated successfully',
data: {
accountId,
platform: 'claude',
config: { enabled, cronExpression, model: testModel }
}
})
} catch (error) {
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to update test config',
message: error.message
})
}
})
// 手动触发账户测试非流式返回JSON结果
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
try {
// 检查账户是否存在
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: 'Account not found',
message: `Claude account ${accountId} not found`
})
}
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
// 执行测试
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
// 保存测试结果到历史
await redis.saveAccountTestResult(accountId, 'claude', testResult)
await redis.setAccountLastTestTime(accountId, 'claude')
return res.json({
success: true,
data: {
accountId,
platform: 'claude',
result: testResult
}
})
} catch (error) {
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
return res.status(500).json({
error: 'Failed to run test',
message: error.message
})
}
})
// 批量获取多个账户的测试历史
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
const { accountIds } = req.body
try {
if (!Array.isArray(accountIds) || accountIds.length === 0) {
return res.status(400).json({
error: 'Invalid parameter',
message: 'accountIds must be a non-empty array'
})
}
// 限制批量查询数量
const limitedIds = accountIds.slice(0, 100)
const accounts = limitedIds.map((accountId) => ({
accountId,
platform: 'claude'
}))
const historyMap = await redis.getAccountsTestHistory(accounts)
return res.json({
success: true,
data: historyMap
})
} catch (error) {
logger.error('❌ Failed to get batch test history:', error)
return res.status(500).json({
error: 'Failed to get batch test history',
message: error.message
})
}
})
module.exports = router

View File

@@ -131,7 +131,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
groupId,
dailyQuota,
quotaResetTime,
maxConcurrentTasks
maxConcurrentTasks,
disableAutoProtection,
interceptWarmup
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -151,6 +153,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
}
}
// 校验上游错误自动防护开关
const normalizedDisableAutoProtection =
disableAutoProtection === true || disableAutoProtection === 'true'
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res
@@ -180,7 +186,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks:
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks)
: 0
: 0,
disableAutoProtection: normalizedDisableAutoProtection,
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
})
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)
@@ -250,6 +258,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
return res.status(404).json({ error: 'Account not found' })
}
// 规范化上游错误自动防护开关
if (mappedUpdates.disableAutoProtection !== undefined) {
mappedUpdates.disableAutoProtection =
mappedUpdates.disableAutoProtection === true ||
mappedUpdates.disableAutoProtection === 'true'
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除

View File

@@ -0,0 +1,239 @@
/**
* Claude 转发配置 API 路由
* 管理全局 Claude Code 限制和会话绑定配置
*/
const express = require('express')
const { authenticateAdmin } = require('../../middleware/auth')
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
const logger = require('../../utils/logger')
const router = express.Router()
/**
* GET /admin/claude-relay-config
* 获取 Claude 转发配置
*/
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
try {
const config = await claudeRelayConfigService.getConfig()
return res.json({
success: true,
config
})
} catch (error) {
logger.error('❌ Failed to get Claude relay config:', error)
return res.status(500).json({
error: 'Failed to get configuration',
message: error.message
})
}
})
/**
* PUT /admin/claude-relay-config
* 更新 Claude 转发配置
*/
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
try {
const {
claudeCodeOnlyEnabled,
globalSessionBindingEnabled,
sessionBindingErrorMessage,
sessionBindingTtlDays,
userMessageQueueEnabled,
userMessageQueueDelayMs,
userMessageQueueTimeoutMs,
concurrentRequestQueueEnabled,
concurrentRequestQueueMaxSize,
concurrentRequestQueueMaxSizeMultiplier,
concurrentRequestQueueTimeoutMs
} = req.body
// 验证输入
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
}
if (
globalSessionBindingEnabled !== undefined &&
typeof globalSessionBindingEnabled !== 'boolean'
) {
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
}
if (sessionBindingErrorMessage !== undefined) {
if (typeof sessionBindingErrorMessage !== 'string') {
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
}
if (sessionBindingErrorMessage.length > 500) {
return res
.status(400)
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
}
}
if (sessionBindingTtlDays !== undefined) {
if (
typeof sessionBindingTtlDays !== 'number' ||
sessionBindingTtlDays < 1 ||
sessionBindingTtlDays > 365
) {
return res
.status(400)
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
}
}
// 验证用户消息队列配置
if (userMessageQueueEnabled !== undefined && typeof userMessageQueueEnabled !== 'boolean') {
return res.status(400).json({ error: 'userMessageQueueEnabled must be a boolean' })
}
if (userMessageQueueDelayMs !== undefined) {
if (
typeof userMessageQueueDelayMs !== 'number' ||
userMessageQueueDelayMs < 0 ||
userMessageQueueDelayMs > 10000
) {
return res
.status(400)
.json({ error: 'userMessageQueueDelayMs must be a number between 0 and 10000' })
}
}
if (userMessageQueueTimeoutMs !== undefined) {
if (
typeof userMessageQueueTimeoutMs !== 'number' ||
userMessageQueueTimeoutMs < 1000 ||
userMessageQueueTimeoutMs > 300000
) {
return res
.status(400)
.json({ error: 'userMessageQueueTimeoutMs must be a number between 1000 and 300000' })
}
}
// 验证并发请求排队配置
if (
concurrentRequestQueueEnabled !== undefined &&
typeof concurrentRequestQueueEnabled !== 'boolean'
) {
return res.status(400).json({ error: 'concurrentRequestQueueEnabled must be a boolean' })
}
if (concurrentRequestQueueMaxSize !== undefined) {
if (
typeof concurrentRequestQueueMaxSize !== 'number' ||
!Number.isInteger(concurrentRequestQueueMaxSize) ||
concurrentRequestQueueMaxSize < 1 ||
concurrentRequestQueueMaxSize > 100
) {
return res
.status(400)
.json({ error: 'concurrentRequestQueueMaxSize must be an integer between 1 and 100' })
}
}
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
// 使用 Number.isFinite() 同时排除 NaN、Infinity、-Infinity 和非数字类型
if (
!Number.isFinite(concurrentRequestQueueMaxSizeMultiplier) ||
concurrentRequestQueueMaxSizeMultiplier < 0 ||
concurrentRequestQueueMaxSizeMultiplier > 10
) {
return res.status(400).json({
error: 'concurrentRequestQueueMaxSizeMultiplier must be a finite number between 0 and 10'
})
}
}
if (concurrentRequestQueueTimeoutMs !== undefined) {
if (
typeof concurrentRequestQueueTimeoutMs !== 'number' ||
!Number.isInteger(concurrentRequestQueueTimeoutMs) ||
concurrentRequestQueueTimeoutMs < 5000 ||
concurrentRequestQueueTimeoutMs > 300000
) {
return res.status(400).json({
error:
'concurrentRequestQueueTimeoutMs must be an integer between 5000 and 300000 (5 seconds to 5 minutes)'
})
}
}
const updateData = {}
if (claudeCodeOnlyEnabled !== undefined) {
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
}
if (globalSessionBindingEnabled !== undefined) {
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
}
if (sessionBindingErrorMessage !== undefined) {
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
}
if (sessionBindingTtlDays !== undefined) {
updateData.sessionBindingTtlDays = sessionBindingTtlDays
}
if (userMessageQueueEnabled !== undefined) {
updateData.userMessageQueueEnabled = userMessageQueueEnabled
}
if (userMessageQueueDelayMs !== undefined) {
updateData.userMessageQueueDelayMs = userMessageQueueDelayMs
}
if (userMessageQueueTimeoutMs !== undefined) {
updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs
}
if (concurrentRequestQueueEnabled !== undefined) {
updateData.concurrentRequestQueueEnabled = concurrentRequestQueueEnabled
}
if (concurrentRequestQueueMaxSize !== undefined) {
updateData.concurrentRequestQueueMaxSize = concurrentRequestQueueMaxSize
}
if (concurrentRequestQueueMaxSizeMultiplier !== undefined) {
updateData.concurrentRequestQueueMaxSizeMultiplier = concurrentRequestQueueMaxSizeMultiplier
}
if (concurrentRequestQueueTimeoutMs !== undefined) {
updateData.concurrentRequestQueueTimeoutMs = concurrentRequestQueueTimeoutMs
}
const updatedConfig = await claudeRelayConfigService.updateConfig(
updateData,
req.admin?.username || 'unknown'
)
return res.json({
success: true,
message: 'Configuration updated successfully',
config: updatedConfig
})
} catch (error) {
logger.error('❌ Failed to update Claude relay config:', error)
return res.status(500).json({
error: 'Failed to update configuration',
message: error.message
})
}
})
/**
* GET /admin/claude-relay-config/session-bindings
* 获取会话绑定统计
*/
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
try {
const stats = await claudeRelayConfigService.getSessionBindingStats()
return res.json({
success: true,
data: stats
})
} catch (error) {
logger.error('❌ Failed to get session binding stats:', error)
return res.status(500).json({
error: 'Failed to get session binding statistics',
message: error.message
})
}
})
module.exports = router

View File

@@ -0,0 +1,313 @@
/**
* 并发管理 API 路由
* 提供并发状态查看和手动清理功能
*/
const express = require('express')
const router = express.Router()
const redis = require('../../models/redis')
const logger = require('../../utils/logger')
const { authenticateAdmin } = require('../../middleware/auth')
const { calculateWaitTimeStats } = require('../../utils/statsHelper')
/**
* GET /admin/concurrency
* 获取所有并发状态
*/
router.get('/concurrency', authenticateAdmin, async (req, res) => {
try {
const status = await redis.getAllConcurrencyStatus()
// 为每个 API Key 获取排队计数
const statusWithQueue = await Promise.all(
status.map(async (s) => {
const queueCount = await redis.getConcurrencyQueueCount(s.apiKeyId)
return {
...s,
queueCount
}
})
)
// 计算汇总统计
const summary = {
totalKeys: statusWithQueue.length,
totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0),
totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0),
totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0)
}
res.json({
success: true,
summary,
concurrencyStatus: statusWithQueue
})
} 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-queue/stats
* 获取排队统计信息
*/
router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => {
try {
// 获取所有有统计数据的 API Key
const statsKeys = await redis.scanConcurrencyQueueStatsKeys()
const queueKeys = await redis.scanConcurrencyQueueKeys()
// 合并所有相关的 API Key
const allApiKeyIds = [...new Set([...statsKeys, ...queueKeys])]
// 获取各 API Key 的详细统计
const perKeyStats = await Promise.all(
allApiKeyIds.map(async (apiKeyId) => {
const [queueCount, stats, waitTimes] = await Promise.all([
redis.getConcurrencyQueueCount(apiKeyId),
redis.getConcurrencyQueueStats(apiKeyId),
redis.getQueueWaitTimes(apiKeyId)
])
return {
apiKeyId,
currentQueueCount: queueCount,
stats,
waitTimeStats: calculateWaitTimeStats(waitTimes)
}
})
)
// 获取全局等待时间统计
const globalWaitTimes = await redis.getGlobalQueueWaitTimes()
const globalWaitTimeStats = calculateWaitTimeStats(globalWaitTimes)
// 计算全局汇总
const globalStats = {
totalEntered: perKeyStats.reduce((sum, s) => sum + s.stats.entered, 0),
totalSuccess: perKeyStats.reduce((sum, s) => sum + s.stats.success, 0),
totalTimeout: perKeyStats.reduce((sum, s) => sum + s.stats.timeout, 0),
totalCancelled: perKeyStats.reduce((sum, s) => sum + s.stats.cancelled, 0),
totalSocketChanged: perKeyStats.reduce((sum, s) => sum + (s.stats.socket_changed || 0), 0),
totalRejectedOverload: perKeyStats.reduce(
(sum, s) => sum + (s.stats.rejected_overload || 0),
0
),
currentTotalQueued: perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0),
// 队列资源利用率指标
peakQueueSize:
perKeyStats.length > 0 ? Math.max(...perKeyStats.map((s) => s.currentQueueCount)) : 0,
avgQueueSize:
perKeyStats.length > 0
? Math.round(
perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0) / perKeyStats.length
)
: 0,
activeApiKeys: perKeyStats.filter((s) => s.currentQueueCount > 0).length
}
// 计算成功率
if (globalStats.totalEntered > 0) {
globalStats.successRate = Math.round(
(globalStats.totalSuccess / globalStats.totalEntered) * 100
)
globalStats.timeoutRate = Math.round(
(globalStats.totalTimeout / globalStats.totalEntered) * 100
)
globalStats.cancelledRate = Math.round(
(globalStats.totalCancelled / globalStats.totalEntered) * 100
)
}
// 从全局等待时间统计中提取关键指标
if (globalWaitTimeStats) {
globalStats.avgWaitTimeMs = globalWaitTimeStats.avg
globalStats.p50WaitTimeMs = globalWaitTimeStats.p50
globalStats.p90WaitTimeMs = globalWaitTimeStats.p90
globalStats.p99WaitTimeMs = globalWaitTimeStats.p99
// 多实例采样策略标记(详见 design.md Decision 9
// 全局 P90 仅用于可视化和监控,不用于系统决策
// 健康检查使用 API Key 级别的 P90每 Key 独立采样)
globalWaitTimeStats.globalP90ForVisualizationOnly = true
}
res.json({
success: true,
globalStats,
globalWaitTimeStats,
perKeyStats
})
} catch (error) {
logger.error('❌ Failed to get queue stats:', error)
res.status(500).json({
success: false,
error: 'Failed to get queue stats',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency-queue/:apiKeyId
* 清理特定 API Key 的排队计数
*/
router.delete('/concurrency-queue/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
await redis.clearConcurrencyQueue(apiKeyId)
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared queue for key ${apiKeyId}`)
res.json({
success: true,
message: `Successfully cleared queue for API key ${apiKeyId}`
})
} catch (error) {
logger.error(`❌ Failed to clear queue for ${req.params.apiKeyId}:`, error)
res.status(500).json({
success: false,
error: 'Failed to clear queue',
message: error.message
})
}
})
/**
* DELETE /admin/concurrency-queue
* 清理所有排队计数
*/
router.delete('/concurrency-queue', authenticateAdmin, async (req, res) => {
try {
const cleared = await redis.clearAllConcurrencyQueues()
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared ALL queues`)
res.json({
success: true,
message: 'Successfully cleared all queues',
cleared
})
} catch (error) {
logger.error('❌ Failed to clear all queues:', error)
res.status(500).json({
success: false,
error: 'Failed to clear all queues',
message: error.message
})
}
})
/**
* GET /admin/concurrency/:apiKeyId
* 获取特定 API Key 的并发状态详情
*/
router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
try {
const { apiKeyId } = req.params
const status = await redis.getConcurrencyStatus(apiKeyId)
const queueCount = await redis.getConcurrencyQueueCount(apiKeyId)
res.json({
success: true,
concurrencyStatus: {
...status,
queueCount
}
})
} 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', authenticateAdmin, 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', authenticateAdmin, 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', authenticateAdmin, 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

@@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
const ccrAccountService = require('../../services/ccrAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const droidAccountService = require('../../services/droidAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const CostCalculator = require('../../utils/costCalculator')
const pricingService = require('../../services/pricingService')
const config = require('../../../config/config')
const router = express.Router()

View File

@@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils')
const router = express.Router()
// 🤖 Gemini OAuth 账户管理
function getDefaultRedirectUri(oauthProvider) {
if (oauthProvider === 'antigravity') {
return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462'
}
return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode'
}
// 生成 Gemini OAuth 授权 URL
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { state, proxy } = req.body // 接收代理配置
const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider
// 使用新的 codeassist.google.com 回调地址
const redirectUri = 'https://codeassist.google.com/authcode'
const redirectUri = getDefaultRedirectUri(oauthProvider)
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
@@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
authUrl,
state: authState,
codeVerifier,
redirectUri: finalRedirectUri
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
redirectUri: finalRedirectUri,
oauthProvider: resolvedOauthProvider
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
const sessionId = authState
@@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
redirectUri: finalRedirectUri,
codeVerifier, // 保存 PKCE code verifier
proxy: proxy || null, // 保存代理配置
oauthProvider: resolvedOauthProvider,
createdAt: new Date().toISOString()
})
@@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
success: true,
data: {
authUrl,
sessionId
sessionId,
oauthProvider: resolvedOauthProvider
}
})
} catch (error) {
@@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
// 交换 Gemini 授权码
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { code, sessionId, proxy: requestProxy } = req.body
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
let resolvedOauthProvider = oauthProvider
if (!code) {
return res.status(400).json({ error: 'Authorization code is required' })
}
let redirectUri = 'https://codeassist.google.com/authcode'
let redirectUri = getDefaultRedirectUri(resolvedOauthProvider)
let codeVerifier = null
let proxyConfig = null
@@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
const {
redirectUri: sessionRedirectUri,
codeVerifier: sessionCodeVerifier,
proxy
proxy,
oauthProvider: sessionOauthProvider
} = sessionData
redirectUri = sessionRedirectUri || redirectUri
codeVerifier = sessionCodeVerifier
proxyConfig = proxy // 获取代理配置
if (!resolvedOauthProvider && sessionOauthProvider) {
// 会话里保存的 provider 仅作为兜底
resolvedOauthProvider = sessionOauthProvider
}
logger.info(
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
)
@@ -120,7 +134,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
code,
redirectUri,
codeVerifier,
proxyConfig // 传递代理配置
proxyConfig, // 传递代理配置
resolvedOauthProvider
)
// 清理 OAuth 会话
@@ -129,7 +144,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
}
logger.success('✅ Successfully exchanged Gemini authorization code')
return res.json({ success: true, data: { tokens } })
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
} catch (error) {
logger.error('❌ Failed to exchange Gemini authorization code:', error)
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })

View File

@@ -21,7 +21,11 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
const droidAccountsRoutes = require('./droidAccounts')
const dashboardRoutes = require('./dashboard')
const usageStatsRoutes = require('./usageStats')
const accountBalanceRoutes = require('./accountBalance')
const systemRoutes = require('./system')
const concurrencyRoutes = require('./concurrency')
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
const syncRoutes = require('./sync')
// 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径)
@@ -34,7 +38,11 @@ router.use('/', openaiResponsesAccountsRoutes)
router.use('/', droidAccountsRoutes)
router.use('/', dashboardRoutes)
router.use('/', usageStatsRoutes)
router.use('/', accountBalanceRoutes)
router.use('/', systemRoutes)
router.use('/', concurrencyRoutes)
router.use('/', claudeRelayConfigRoutes)
router.use('/', syncRoutes)
// 使用相对路径的模块(需要指定基础路径前缀)
router.use('/account-groups', accountGroupsRoutes)

460
src/routes/admin/sync.js Normal file
View File

@@ -0,0 +1,460 @@
/**
* Admin Routes - Sync / Export (for migration)
* Exports account data (including secrets) for safe server-to-server syncing.
*/
const express = require('express')
const router = express.Router()
const { authenticateAdmin } = require('../../middleware/auth')
const redis = require('../../models/redis')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const logger = require('../../utils/logger')
function toBool(value, defaultValue = false) {
if (value === undefined || value === null || value === '') {
return defaultValue
}
if (value === true || value === 'true') {
return true
}
if (value === false || value === 'false') {
return false
}
return defaultValue
}
function normalizeProxy(proxy) {
if (!proxy || typeof proxy !== 'object') {
return null
}
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
const host = proxy.host || ''
const port = Number(proxy.port || 0)
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
return null
}
return {
protocol: String(protocol),
host: String(host),
port,
username: proxy.username ? String(proxy.username) : '',
password: proxy.password ? String(proxy.password) : ''
}
}
function buildModelMappingFromSupportedModels(supportedModels) {
if (!supportedModels) {
return null
}
if (Array.isArray(supportedModels)) {
const mapping = {}
for (const model of supportedModels) {
if (typeof model === 'string' && model.trim()) {
mapping[model.trim()] = model.trim()
}
}
return Object.keys(mapping).length ? mapping : null
}
if (typeof supportedModels === 'object') {
const mapping = {}
for (const [from, to] of Object.entries(supportedModels)) {
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
mapping[from.trim()] = to.trim()
}
}
return Object.keys(mapping).length ? mapping : null
}
return null
}
function safeParseJson(raw, fallback = null) {
if (!raw || typeof raw !== 'string') {
return fallback
}
try {
return JSON.parse(raw)
} catch (_) {
return fallback
}
}
// Export accounts for migration (includes secrets).
// GET /admin/sync/export-accounts?include_secrets=true
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
try {
const includeSecrets = toBool(req.query.include_secrets, false)
if (!includeSecrets) {
return res.status(400).json({
success: false,
error: 'include_secrets_required',
message: 'Set include_secrets=true to export secrets'
})
}
// ===== Claude official OAuth / Setup Token accounts =====
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
const claudeAccounts = rawClaudeAccounts.map((account) => {
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
let decryptedClaudeAiOauth = null
if (account.claudeAiOauth) {
try {
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
} catch (_) {
decryptedClaudeAiOauth = null
}
}
const rawScopes =
account.scopes && account.scopes.trim()
? account.scopes
: decryptedClaudeAiOauth?.scopes
? decryptedClaudeAiOauth.scopes.join(' ')
: ''
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
const authType = isOAuth ? 'oauth' : 'setup-token'
const accessToken =
account.accessToken && String(account.accessToken).trim()
? claudeAccountService._decryptSensitiveData(account.accessToken)
: decryptedClaudeAiOauth?.accessToken || ''
const refreshToken =
account.refreshToken && String(account.refreshToken).trim()
? claudeAccountService._decryptSensitiveData(account.refreshToken)
: decryptedClaudeAiOauth?.refreshToken || ''
let expiresAt = null
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
expiresAt = new Date(expiresAtMs).toISOString()
} else if (decryptedClaudeAiOauth?.expiresAt) {
try {
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
} catch (_) {
expiresAt = null
}
}
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
let orgUuid = null
let accountUuid = null
if (account.subscriptionInfo) {
try {
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
orgUuid = subscriptionInfo.organizationUuid || null
accountUuid = subscriptionInfo.accountUuid || null
} catch (_) {
// Ignore parse errors
}
}
// 🔧 Calculate expires_in from expires_at
let expiresIn = null
if (expiresAt) {
try {
const expiresAtTime = new Date(expiresAt).getTime()
const nowTime = Date.now()
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
if (diffSeconds > 0) {
expiresIn = diffSeconds
}
} catch (_) {
// Ignore calculation errors
}
}
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
if (!expiresIn && isOAuth) {
expiresIn = 28800 // 8 hours
}
const credentials = {
access_token: accessToken,
refresh_token: refreshToken || undefined,
expires_at: expiresAt || undefined,
expires_in: expiresIn || undefined,
scope: scopes.join(' ') || undefined,
token_type: 'Bearer'
}
// 🔧 Add auth info as top-level credentials fields
if (orgUuid) {
credentials.org_uuid = orgUuid
}
if (accountUuid) {
credentials.account_uuid = accountUuid
}
// 🔧 Store complete original CRS data in extra
const extra = {
crs_account_id: account.id,
crs_kind: 'claude-account',
crs_id: account.id,
crs_name: account.name,
crs_description: account.description || '',
crs_platform: account.platform || 'claude',
crs_auth_type: authType,
crs_is_active: account.isActive === 'true',
crs_schedulable: account.schedulable !== 'false',
crs_priority: Number.parseInt(account.priority, 10) || 50,
crs_status: account.status || 'active',
crs_scopes: scopes,
crs_subscription_info: account.subscriptionInfo || undefined
}
return {
kind: 'claude-account',
id: account.id,
name: account.name,
description: account.description || '',
platform: account.platform || 'claude',
authType,
isActive: account.isActive === 'true',
schedulable: account.schedulable !== 'false',
priority: Number.parseInt(account.priority, 10) || 50,
status: account.status || 'active',
proxy,
credentials,
extra
}
})
// ===== Claude Console API Key accounts =====
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
const claudeConsoleAccounts = []
for (const summary of claudeConsoleSummaries) {
const full = await claudeConsoleAccountService.getAccount(summary.id)
if (!full) {
continue
}
const proxy = normalizeProxy(full.proxy)
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
const credentials = {
api_key: full.apiKey,
base_url: full.apiUrl
}
if (modelMapping) {
credentials.model_mapping = modelMapping
}
if (full.userAgent) {
credentials.user_agent = full.userAgent
}
claudeConsoleAccounts.push({
kind: 'claude-console-account',
id: full.id,
name: full.name,
description: full.description || '',
platform: full.platform || 'claude-console',
isActive: full.isActive === true,
schedulable: full.schedulable !== false,
priority: Number.parseInt(full.priority, 10) || 50,
status: full.status || 'active',
proxy,
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
credentials,
extra: {
crs_account_id: full.id,
crs_kind: 'claude-console-account',
crs_id: full.id,
crs_name: full.name,
crs_description: full.description || '',
crs_platform: full.platform || 'claude-console',
crs_is_active: full.isActive === true,
crs_schedulable: full.schedulable !== false,
crs_priority: Number.parseInt(full.priority, 10) || 50,
crs_status: full.status || 'active'
}
})
}
// ===== OpenAI OAuth accounts =====
const openaiOAuthAccounts = []
{
const client = redis.getClientSafe()
const openaiKeys = await client.keys('openai:account:*')
for (const key of openaiKeys) {
const id = key.split(':').slice(2).join(':')
const account = await openaiAccountService.getAccount(id)
if (!account) {
continue
}
const accessToken = account.accessToken
? openaiAccountService.decrypt(account.accessToken)
: ''
if (!accessToken) {
// Skip broken/legacy records without decryptable token
continue
}
const scopes =
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
? account.scopes.trim().split(' ')
: []
const proxy = normalizeProxy(account.proxy)
// 🔧 Calculate expires_in from expires_at
let expiresIn = null
if (account.expiresAt) {
try {
const expiresAtTime = new Date(account.expiresAt).getTime()
const nowTime = Date.now()
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
if (diffSeconds > 0) {
expiresIn = diffSeconds
}
} catch (_) {
// Ignore calculation errors
}
}
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
if (!expiresIn) {
expiresIn = 864000 // 10 days
}
const credentials = {
access_token: accessToken,
refresh_token: account.refreshToken || undefined,
id_token: account.idToken || undefined,
expires_at: account.expiresAt || undefined,
expires_in: expiresIn || undefined,
scope: scopes.join(' ') || undefined,
token_type: 'Bearer'
}
// 🔧 Add auth info as top-level credentials fields
if (account.accountId) {
credentials.chatgpt_account_id = account.accountId
}
if (account.chatgptUserId) {
credentials.chatgpt_user_id = account.chatgptUserId
}
if (account.organizationId) {
credentials.organization_id = account.organizationId
}
// 🔧 Store complete original CRS data in extra
const extra = {
crs_account_id: account.id,
crs_kind: 'openai-oauth-account',
crs_id: account.id,
crs_name: account.name,
crs_description: account.description || '',
crs_platform: account.platform || 'openai',
crs_is_active: account.isActive === 'true',
crs_schedulable: account.schedulable !== 'false',
crs_priority: Number.parseInt(account.priority, 10) || 50,
crs_status: account.status || 'active',
crs_scopes: scopes,
crs_email: account.email || undefined,
crs_chatgpt_account_id: account.accountId || undefined,
crs_chatgpt_user_id: account.chatgptUserId || undefined,
crs_organization_id: account.organizationId || undefined
}
openaiOAuthAccounts.push({
kind: 'openai-oauth-account',
id: account.id,
name: account.name,
description: account.description || '',
platform: account.platform || 'openai',
authType: 'oauth',
isActive: account.isActive === 'true',
schedulable: account.schedulable !== 'false',
priority: Number.parseInt(account.priority, 10) || 50,
status: account.status || 'active',
proxy,
credentials,
extra
})
}
}
// ===== OpenAI Responses API Key accounts =====
const openaiResponsesAccounts = []
const client = redis.getClientSafe()
const openaiResponseKeys = await client.keys('openai_responses_account:*')
for (const key of openaiResponseKeys) {
const id = key.split(':').slice(1).join(':')
const full = await openaiResponsesAccountService.getAccount(id)
if (!full) {
continue
}
const proxy = normalizeProxy(full.proxy)
const credentials = {
api_key: full.apiKey,
base_url: full.baseApi
}
if (full.userAgent) {
credentials.user_agent = full.userAgent
}
openaiResponsesAccounts.push({
kind: 'openai-responses-account',
id: full.id,
name: full.name,
description: full.description || '',
platform: full.platform || 'openai-responses',
isActive: full.isActive === 'true',
schedulable: full.schedulable !== 'false',
priority: Number.parseInt(full.priority, 10) || 50,
status: full.status || 'active',
proxy,
credentials,
extra: {
crs_account_id: full.id,
crs_kind: 'openai-responses-account',
crs_id: full.id,
crs_name: full.name,
crs_description: full.description || '',
crs_platform: full.platform || 'openai-responses',
crs_is_active: full.isActive === 'true',
crs_schedulable: full.schedulable !== 'false',
crs_priority: Number.parseInt(full.priority, 10) || 50,
crs_status: full.status || 'active'
}
})
}
return res.json({
success: true,
data: {
exportedAt: new Date().toISOString(),
claudeAccounts,
claudeConsoleAccounts,
openaiOAuthAccounts,
openaiResponsesAccounts
}
})
} catch (error) {
logger.error('❌ Failed to export accounts for sync:', error)
return res.status(500).json({
success: false,
error: 'export_failed',
message: error.message
})
}
})
module.exports = router

View File

@@ -1,8 +1,10 @@
const express = require('express')
const apiKeyService = require('../../services/apiKeyService')
const ccrAccountService = require('../../services/ccrAccountService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const droidAccountService = require('../../services/droidAccountService')
@@ -14,6 +16,65 @@ const pricingService = require('../../services/pricingService')
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
}
}
// 📊 账户使用统计
// 获取所有账户的使用统计
@@ -148,7 +209,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
accountData = await geminiAccountService.getAccount(accountId)
break
case 'gemini-api': {
const geminiApiAccountService = require('../../services/geminiApiAccountService')
accountData = await geminiApiAccountService.getAccount(accountId)
break
}
@@ -369,7 +429,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
logger.info(` endDate (raw): ${endDate}`)
logger.info(` startTime (parsed): ${startTime.toISOString()}`)
logger.info(` endTime (parsed): ${endTime.toISOString()}`)
logger.info(` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`)
logger.info(
` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`
)
} else {
// 默认最近24小时
endTime = new Date()
@@ -890,7 +952,6 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
})
]
} else if (group === 'gemini') {
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
geminiAccountService.getAllAccounts(),
geminiApiAccountService.getAllAccounts(true)
@@ -1818,4 +1879,628 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
}
})
// 获取 API Key 的请求记录时间线
router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
const {
page = 1,
pageSize = 50,
startDate,
endDate,
model,
accountId,
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 apiKeyInfo = await redis.getApiKey(keyId)
if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) {
return res.status(404).json({ success: false, error: 'API key not found' })
}
const rawRecords = await redis.getUsageRecords(keyId, 5000)
const accountServices = [
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
{ type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) },
{ type: 'openai', getter: (id) => openaiAccountService.getAccount(id) },
{ type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) },
{ type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) },
{ type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) },
{ type: 'droid', getter: (id) => droidAccountService.getAccount(id) }
]
const accountCache = new Map()
const resolveAccountInfo = async (id, type) => {
if (!id) {
return null
}
const cacheKey = `${type || 'any'}:${id}`
if (accountCache.has(cacheKey)) {
return accountCache.get(cacheKey)
}
let servicesToTry = type
? accountServices.filter((svc) => svc.type === type)
: accountServices
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
if (!servicesToTry.length) {
servicesToTry = accountServices
}
for (const service of servicesToTry) {
try {
const account = await service.getter(id)
if (account) {
const info = {
id,
name: account.name || account.email || id,
type: service.type,
status: account.status || account.isActive
}
accountCache.set(cacheKey, info)
return info
}
} catch (error) {
logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`)
}
}
accountCache.set(cacheKey, null)
return null
}
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 = rawRecords.filter((record) => {
if (!withinRange(record)) {
return false
}
if (model && record.model !== model) {
return false
}
if (accountId && record.accountId !== accountId) {
return false
}
return true
})
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
}
const modelSet = new Set()
const accountOptionMap = new Map()
let earliestTimestamp = null
let latestTimestamp = null
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
if (record.model) {
modelSet.add(record.model)
}
if (record.accountId) {
const normalizedType = record.accountType || 'unknown'
if (!accountOptionMap.has(record.accountId)) {
accountOptionMap.set(record.accountId, {
id: record.accountId,
accountTypes: new Set([normalizedType])
})
} else {
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
}
}
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
}
}
}
}
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
const accountInfo = await resolveAccountInfo(record.accountId, record.accountType)
const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown'
enrichedRecords.push({
timestamp: record.timestamp,
model: record.model || 'unknown',
accountId: record.accountId || null,
accountName: accountInfo?.name || null,
accountStatus: accountInfo?.status ?? null,
accountType: resolvedAccountType,
accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道',
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
})
}
const accountOptions = []
for (const option of accountOptionMap.values()) {
const types = Array.from(option.accountTypes || [])
// 优先按历史出现的 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({
id: option.id,
name: resolvedInfo?.name || option.id,
accountType: chosenType,
accountTypeName: chosenTypeName,
rawTypes: types
})
}
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,
accountId: accountId || null,
sortOrder: normalizedSortOrder
},
apiKeyInfo: {
id: keyId,
name: apiKeyInfo.name || apiKeyInfo.label || keyId
},
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),
accounts: accountOptions,
dateRange: {
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
latest: latestTimestamp ? latestTimestamp.toISOString() : null
}
}
}
})
} catch (error) {
logger.error('❌ Failed to get API key usage records:', error)
return res
.status(500)
.json({ error: 'Failed to get API key usage records', message: error.message })
}
})
// 获取账户的请求记录时间线
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

View File

@@ -33,7 +33,9 @@ function mapExpiryField(updates, accountType, accountId) {
if ('expiresAt' in mappedUpdates) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
delete mappedUpdates.expiresAt
logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
logger.info(
`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`
)
}
return mappedUpdates
}

View File

@@ -11,7 +11,20 @@ const logger = require('../utils/logger')
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const claudeAccountService = require('../services/claudeAccountService')
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const {
isWarmupRequest,
buildMockWarmupResponse,
sendMockWarmupStream
} = require('../utils/warmupInterceptor')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
const {
handleAnthropicMessagesToGemini,
handleAnthropicCountTokensToGemini
} = require('../services/anthropicGeminiBridgeService')
const router = express.Router()
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
@@ -37,17 +50,80 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
})
}
/**
* 判断是否为旧会话(污染的会话)
* Claude Code 发送的请求特点:
* - messages 数组通常只有 1 个元素
* - 历史对话记录嵌套在单个 message 的 content 数组中
* - content 数组中包含 <system-reminder> 开头的系统注入内容
*
* 污染会话的特征:
* 1. messages.length > 1
* 2. messages.length === 1 但 content 中有多个用户输入
* 3. "warmup" 请求:单条简单消息 + 无 tools真正新会话会带 tools
*
* @param {Object} body - 请求体
* @returns {boolean} 是否为旧会话
*/
function isOldSession(body) {
const messages = body?.messages
const tools = body?.tools
if (!messages || messages.length === 0) {
return false
}
// 1. 多条消息 = 旧会话
if (messages.length > 1) {
return true
}
// 2. 单条消息,分析 content
const firstMessage = messages[0]
const content = firstMessage?.content
if (!content) {
return false
}
// 如果 content 是字符串,只有一条输入,需要检查 tools
if (typeof content === 'string') {
// 有 tools = 正常新会话,无 tools = 可疑
return !tools || tools.length === 0
}
// 如果 content 是数组,统计非 system-reminder 的元素
if (Array.isArray(content)) {
const userInputs = content.filter((item) => {
if (item.type !== 'text') {
return false
}
const text = item.text || ''
// 剔除以 <system-reminder> 开头的
return !text.trimStart().startsWith('<system-reminder>')
})
// 多个用户输入 = 旧会话
if (userInputs.length > 1) {
return true
}
// Warmup 检测:单个消息 + 无 tools = 旧会话
if (userInputs.length === 1 && (!tools || tools.length === 0)) {
return true
}
}
return false
}
// 🔧 共享的消息处理函数
async function handleMessagesRequest(req, res) {
try {
const startTime = Date.now()
// Claude 服务权限校验,阻止未授权的 Key
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
return res.status(403).json({
error: {
type: 'permission_error',
@@ -100,6 +176,50 @@ async function handleMessagesRequest(req, res) {
}
}
const forcedVendor = req._anthropicVendor || null
logger.api('📥 /v1/messages request received', {
model: req.body.model || null,
forcedVendor,
stream: req.body.stream === true
})
dumpAnthropicMessagesRequest(req, {
route: '/v1/messages',
forcedVendor,
model: req.body?.model || null,
stream: req.body?.stream === true
})
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
}
// Claude 服务权限校验,阻止未授权的 Key默认路径保持不变
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 检查是否为流式请求
const isStream = req.body.stream === true
@@ -122,12 +242,42 @@ async function handleMessagesRequest(req, res) {
)
if (isStream) {
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before stream response could start for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
// 流式响应 - 只使用官方真实usage数据
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
// ⚠️ 检查 headers 是否已发送(可能在排队心跳时已设置)
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
// 当并发队列功能启用时auth.js 会设置 Connection: close 来禁用 Keep-Alive
// 这里只在没有设置过 Connection 头时才设置 keep-alive
const existingConnection = res.getHeader('Connection')
if (!existingConnection) {
res.setHeader('Connection', 'keep-alive')
} else {
logger.api(
`🔌 [STREAM] Preserving existing Connection header: ${existingConnection} for key: ${req.apiKey?.name || 'unknown'}`
)
}
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲
} else {
logger.debug(
`📤 [STREAM] Headers already sent, skipping setHeader for key: ${req.apiKey?.name || 'unknown'}`
)
}
// 禁用 Nagle 算法,确保数据立即发送
if (res.socket && typeof res.socket.setNoDelay === 'function') {
@@ -141,6 +291,56 @@ async function handleMessagesRequest(req, res) {
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 🔒 全局会话绑定验证
let forcedAccount = null
let needSessionBinding = false
let originalSessionIdForBinding = null
try {
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
if (globalBindingEnabled) {
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
if (originalSessionId) {
const validation = await claudeRelayConfigService.validateNewSession(
req.body,
originalSessionId
)
if (!validation.valid) {
logger.api(
`❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}`
)
return res.status(403).json({
error: {
type: 'session_binding_error',
message: validation.error
}
})
}
// 如果已有绑定,使用绑定的账户
if (validation.binding) {
forcedAccount = validation.binding
logger.api(
`🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}`
)
}
// 标记需要在调度成功后建立绑定
if (validation.isNewSession) {
needSessionBinding = true
originalSessionIdForBinding = originalSessionId
logger.api(`📝 New session detected, will create binding: ${originalSessionId}`)
}
}
}
} catch (error) {
logger.error('❌ Error in global session binding check:', error)
// 配置服务出错时不阻断请求
}
// 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model
let accountId
@@ -149,10 +349,21 @@ async function handleMessagesRequest(req, res) {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
requestedModel,
forcedAccount
)
;({ accountId, accountType } = selection)
} catch (error) {
// 处理会话绑定账户不可用的错误
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
return res.status(403).json({
error: {
type: 'session_binding_error',
message: errorMessage
}
})
}
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
@@ -170,6 +381,57 @@ async function handleMessagesRequest(req, res) {
throw error
}
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
// claude-official 只接受1) 新会话 2) 已绑定的会话
if (
needSessionBinding &&
originalSessionIdForBinding &&
accountId &&
accountType === 'claude-official'
) {
// 🚫 检测旧会话(污染的会话)
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
)
return res.status(400).json({
error: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
// 创建绑定
try {
await claudeRelayConfigService.setOriginalSessionBinding(
originalSessionIdForBinding,
accountId,
accountType
)
} catch (bindingError) {
logger.warn(`⚠️ Failed to create session binding:`, bindingError)
}
}
// 🔥 预热请求拦截检查(在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`)
if (isStream) {
return sendMockWarmupStream(res, req.body.model)
} else {
return res.json(buildMockWarmupResponse(req.body.model))
}
}
}
// 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号
@@ -494,15 +756,113 @@ async function handleMessagesRequest(req, res) {
}
}, 1000) // 1秒后检查
} else {
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
// 非流式响应 - 只使用官方真实usage数据
logger.info('📄 Starting non-streaming request', {
apiKeyId: req.apiKey.id,
apiKeyName: req.apiKey.name
})
// 📊 监听 socket 事件以追踪连接状态变化
const nonStreamSocket = res.socket
let _clientClosedConnection = false
let _socketCloseTime = null
if (nonStreamSocket) {
const onSocketEnd = () => {
_clientClosedConnection = true
_socketCloseTime = Date.now()
logger.warn(
`⚠️ [NON-STREAM] Socket 'end' event - client sent FIN | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms`
)
}
const onSocketClose = () => {
_clientClosedConnection = true
logger.warn(
`⚠️ [NON-STREAM] Socket 'close' event | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms, ` +
`hadError: ${nonStreamSocket.destroyed}`
)
}
const onSocketError = (err) => {
logger.error(
`❌ [NON-STREAM] Socket error | key: ${req.apiKey?.name}, ` +
`requestId: ${req.requestId}, error: ${err.message}`
)
}
nonStreamSocket.once('end', onSocketEnd)
nonStreamSocket.once('close', onSocketClose)
nonStreamSocket.once('error', onSocketError)
// 清理监听器(在响应结束后)
res.once('finish', () => {
nonStreamSocket.removeListener('end', onSocketEnd)
nonStreamSocket.removeListener('close', onSocketClose)
nonStreamSocket.removeListener('error', onSocketError)
})
}
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 🔒 全局会话绑定验证(非流式)
let forcedAccountNonStream = null
let needSessionBindingNonStream = false
let originalSessionIdForBindingNonStream = null
try {
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
if (globalBindingEnabled) {
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
if (originalSessionId) {
const validation = await claudeRelayConfigService.validateNewSession(
req.body,
originalSessionId
)
if (!validation.valid) {
logger.api(
`❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}`
)
return res.status(403).json({
error: {
type: 'session_binding_error',
message: validation.error
}
})
}
if (validation.binding) {
forcedAccountNonStream = validation.binding
logger.api(
`🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}`
)
}
if (validation.isNewSession) {
needSessionBindingNonStream = true
originalSessionIdForBindingNonStream = originalSessionId
logger.api(
`📝 New session detected (non-stream), will create binding: ${originalSessionId}`
)
}
}
}
} catch (error) {
logger.error('❌ Error in global session binding check (non-stream):', error)
}
// 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model
let accountId
@@ -511,10 +871,20 @@ async function handleMessagesRequest(req, res) {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
requestedModel,
forcedAccountNonStream
)
;({ accountId, accountType } = selection)
} catch (error) {
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
return res.status(403).json({
error: {
type: 'session_binding_error',
message: errorMessage
}
})
}
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
@@ -527,6 +897,55 @@ async function handleMessagesRequest(req, res) {
throw error
}
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
// claude-official 只接受1) 新会话 2) 已绑定的会话
if (
needSessionBindingNonStream &&
originalSessionIdForBindingNonStream &&
accountId &&
accountType === 'claude-official'
) {
// 🚫 检测旧会话(污染的会话)
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
)
return res.status(400).json({
error: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
// 创建绑定
try {
await claudeRelayConfigService.setOriginalSessionBinding(
originalSessionIdForBindingNonStream,
accountId,
accountType
)
} catch (bindingError) {
logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError)
}
}
// 🔥 预热请求拦截检查(非流式,在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
)
return res.json(buildMockWarmupResponse(req.body.model))
}
}
// 根据账号类型选择对应的转发服务
let response
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
@@ -611,6 +1030,15 @@ async function handleMessagesRequest(req, res) {
bodyLength: response.body ? response.body.length : 0
})
// 🔍 检查客户端连接是否仍然有效
// 在长时间请求过程中,客户端可能已经断开连接(超时、用户取消等)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before non-stream response could be sent for key: ${req.apiKey?.name || 'unknown'}`
)
return undefined
}
res.status(response.statusCode)
// 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突
@@ -641,8 +1069,8 @@ async function handleMessagesRequest(req, res) {
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
const rawModel = jsonData.model || req.body.model || 'unknown'
const { baseModel } = parseVendorPrefixedModel(rawModel)
const model = baseModel || rawModel
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
const model = usageBaseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response
@@ -676,10 +1104,12 @@ async function handleMessagesRequest(req, res) {
logger.warn('⚠️ No usage data found in Claude API JSON response')
}
// 使用 Express 内建的 res.json() 发送响应(简单可靠)
res.json(jsonData)
} catch (parseError) {
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message)
logger.info('📄 Raw response body:', response.body)
// 使用 Express 内建的 res.send() 发送响应(简单可靠)
res.send(response.body)
}
@@ -816,6 +1246,66 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
router.get('/v1/models', authenticateApiKey, async (req, res) => {
try {
// Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表
//(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const geminiAccountService = require('../services/geminiAccountService')
let accountSelection
try {
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
null,
null,
{ oauthProvider: 'antigravity' }
)
} catch (error) {
logger.error('Failed to select Gemini OAuth account (antigravity models):', error)
return res.status(503).json({ error: 'No available Gemini OAuth accounts' })
}
const account = await geminiAccountService.getAccount(accountSelection.accountId)
if (!account) {
return res.status(503).json({ error: 'Gemini OAuth account not found' })
}
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig =
typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const models = await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
proxyConfig,
account.refreshToken
)
// 可选:根据 API Key 的模型限制过滤(黑名单语义)
let filteredModels = models
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
}
return res.json({ object: 'list', data: filteredModels })
}
const modelService = require('../services/modelService')
// 从 modelService 获取所有支持的模型
@@ -824,7 +1314,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
// 可选:根据 API Key 的模型限制过滤
let filteredModels = models
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({
@@ -951,6 +1442,22 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
// 🔢 Token计数端点 - count_tokens beta API
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Gemini'
}
})
}
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 检查权限
if (
req.apiKey.permissions &&
@@ -965,6 +1472,41 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
})
}
// 🔗 会话绑定验证(与 messages 端点保持一致)
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
const sessionValidation = await claudeRelayConfigService.validateNewSession(
req.body,
originalSessionId
)
if (!sessionValidation.valid) {
logger.warn(
`🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}`
)
return res.status(400).json({
error: {
type: 'session_binding_error',
message: sessionValidation.error
}
})
}
// 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查
if (sessionValidation.isNewSession && originalSessionId) {
if (isOldSession(req.body)) {
const cfg = await claudeRelayConfigService.getConfig()
logger.warn(
`🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
)
return res.status(400).json({
error: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
}
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
const sessionHash = sessionHelper.generateSessionHash(req.body)
@@ -972,9 +1514,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
const maxAttempts = 2
let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
@@ -1170,5 +1709,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
}
})
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
router.post('/api/event_logging/batch', (req, res) => {
res.status(200).json({ success: true })
})
module.exports = router
module.exports.handleMessagesRequest = handleMessagesRequest

View File

@@ -206,74 +206,85 @@ router.post('/api/user-stats', async (req, res) => {
// 获取验证结果中的完整keyData包含isActive状态和cost信息
const fullKeyData = keyData
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
// 计算总费用 - 优先使用持久化的总费用计数器
let totalCost = 0
let formattedCost = '$0.000000'
try {
const client = redis.getClientSafe()
// 获取所有月度模型统计与model-stats接口相同的逻辑
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map()
// 读取累积的总费用(没有 TTL 的持久键
const totalCostKey = `usage:cost:total:${keyId}`
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
for (const key of allModelKeys) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
if (allTimeCost > 0) {
totalCost = allTimeCost
formattedCost = CostCalculator.formatCost(allTimeCost)
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
} else {
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map()
const model = modelMatch[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
for (const key of allModelKeys) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
const modelUsage = modelUsageMap.get(model)
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
const model = modelMatch[1]
const data = await client.hgetall(key)
// 按模型计算费用并汇总
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
}
const modelUsage = modelUsageMap.get(model)
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
const costResult = CostCalculator.calculateCost(usageData, model)
totalCost += costResult.costs.total
}
// 按模型计算费用并汇总
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
}
// 如果没有模型级别的详细数据,回退到总体数据计算
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
const costResult = CostCalculator.calculateCost(usageData, model)
totalCost += costResult.costs.total
}
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
totalCost = costResult.costs.total
}
// 如果没有模型级别的详细数据,回退到总体数据计算
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
}
formattedCost = CostCalculator.formatCost(totalCost)
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
totalCost = costResult.costs.total
}
formattedCost = CostCalculator.formatCost(totalCost)
}
} catch (error) {
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error)
logger.warn(`Failed to calculate cost for key ${keyId}:`, error)
// 回退到简单计算
if (fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total

View File

@@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth')
const droidRelayService = require('../services/droidRelayService')
const sessionHelper = require('../utils/sessionHelper')
const logger = require('../utils/logger')
const apiKeyService = require('../services/apiKeyService')
const router = express.Router()
function hasDroidPermission(apiKeyData) {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === 'droid'
return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid')
}
/**

View File

@@ -15,6 +15,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const pricingService = require('../services/pricingService')
const { getEffectiveModel } = require('../utils/modelHelper')
// 🔧 辅助函数:检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
@@ -75,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
}
]
// 如果启用了模型限制,过滤模型列表
// 如果启用了模型限制,视为黑名单:过滤掉受限模型
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({
@@ -114,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
})
}
// 检查模型限制
// 模型限制(黑名单):命中则直接拒绝
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
if (!apiKeyData.restrictedModels.includes(modelId)) {
if (apiKeyData.restrictedModels.includes(modelId)) {
return res.status(404).json({
error: {
message: `Model '${modelId}' not found`,
@@ -199,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) {
// 转换 OpenAI 请求为 Claude 格式
const claudeRequest = openaiToClaude.convertRequest(req.body)
// 检查模型限制
// 模型限制(黑名单):命中受限模型则拒绝
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({
error: {
message: `Model ${req.body.model} is not allowed for this API key`,

View File

@@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const { getAvailableModels } = require('../services/geminiRelayService')
const crypto = require('crypto')
const apiKeyService = require('../services/apiKeyService')
// 生成会话哈希
function generateSessionHash(req) {
@@ -19,10 +20,19 @@ function generateSessionHash(req) {
return crypto.createHash('sha256').update(sessionData).digest('hex')
}
function ensureAntigravityProjectId(account) {
if (account.projectId) {
return account.projectId
}
if (account.tempProjectId) {
return account.tempProjectId
}
return `ag-${crypto.randomBytes(8).toString('hex')}`
}
// 检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
const permissions = apiKeyData.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
// 转换 OpenAI 消息格式到 Gemini 格式
@@ -335,25 +345,48 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
const client = await geminiAccountService.getOauthClient(
account.accessToken,
account.refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
if (actualStream) {
// 流式响应
const oauthProvider = account.oauthProvider || 'gemini-cli'
let { projectId } = account
if (oauthProvider === 'antigravity') {
projectId = ensureAntigravityProjectId(account)
if (!account.projectId && account.tempProjectId !== projectId) {
await geminiAccountService.updateTempProjectId(account.id, projectId)
account.tempProjectId = projectId
}
}
logger.info('StreamGenerateContent request', {
model,
projectId: account.projectId,
projectId,
apiKeyId: apiKeyData.id
})
const streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
account.projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
)
const streamResponse =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentStreamAntigravity(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
projectId,
apiKeyData.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
)
: await geminiAccountService.generateContentStream(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
)
// 设置流式响应头
res.setHeader('Content-Type', 'text/event-stream')
@@ -499,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 记录使用统计
if (!usageReported && totalUsage.totalTokenCount > 0) {
try {
const apiKeyService = require('../services/apiKeyService')
await apiKeyService.recordUsage(
apiKeyData.id,
totalUsage.promptTokenCount || 0,
@@ -559,20 +591,41 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
})
} else {
// 非流式响应
const oauthProvider = account.oauthProvider || 'gemini-cli'
let { projectId } = account
if (oauthProvider === 'antigravity') {
projectId = ensureAntigravityProjectId(account)
if (!account.projectId && account.tempProjectId !== projectId) {
await geminiAccountService.updateTempProjectId(account.id, projectId)
account.tempProjectId = projectId
}
}
logger.info('GenerateContent request', {
model,
projectId: account.projectId,
projectId,
apiKeyId: apiKeyData.id
})
const response = await geminiAccountService.generateContent(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
account.projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
)
const response =
oauthProvider === 'antigravity'
? await geminiAccountService.generateContentAntigravity(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
projectId,
apiKeyData.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
)
: await geminiAccountService.generateContent(
client,
{ model, request: geminiRequestBody },
null, // user_prompt_id
projectId, // 使用有权限的项目ID
apiKeyData.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
)
// 转换为 OpenAI 格式并返回
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false)
@@ -580,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
// 记录使用统计
if (openaiResponse.usage) {
try {
const apiKeyService = require('../services/apiKeyService')
await apiKeyService.recordUsage(
apiKeyData.id,
openaiResponse.usage.prompt_tokens || 0,
@@ -604,7 +656,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
const duration = Date.now() - startTime
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
} catch (error) {
logger.error('OpenAI-Gemini request error:', error)
const statusForLog = error?.status || error?.response?.status
logger.error('OpenAI-Gemini request error', {
message: error?.message,
status: statusForLog,
code: error?.code,
requestUrl: error?.config?.url,
requestMethod: error?.config?.method,
upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id']
})
// 处理速率限制
if (error.status === 429) {
@@ -665,8 +725,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
let models = []
if (account) {
// 获取实际的模型列表
models = await getAvailableModels(account.accessToken, account.proxy)
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
try {
const oauthProvider = account.oauthProvider || 'gemini-cli'
models =
oauthProvider === 'antigravity'
? await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
account.proxy,
account.refreshToken
)
: await getAvailableModels(account.accessToken, account.proxy)
} catch (error) {
logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error)
models = []
}
} else {
// 返回默认模型列表
models = [
@@ -679,6 +752,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
]
}
if (!models || models.length === 0) {
models = [
{
id: 'gemini-2.0-flash-exp',
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: 'google'
}
]
}
// 如果启用了模型限制,过滤模型列表
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))

View File

@@ -20,8 +20,7 @@ function createProxyAgent(proxy) {
// 检查 API Key 是否具备 OpenAI 权限
function checkOpenAIPermissions(apiKeyData) {
const permissions = apiKeyData?.permissions || 'all'
return permissions === 'all' || permissions === 'openai'
return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai')
}
function normalizeHeaders(headers = {}) {
@@ -247,9 +246,11 @@ const handleResponses = async (req, res) => {
// 从请求体中提取模型和流式标志
let requestedModel = req.body?.model || null
const isCodexModel =
typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex')
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07则覆盖为 gpt-5
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') {
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07并且不是 Codex 系列,则覆盖为 gpt-5
if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) {
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
requestedModel = 'gpt-5'
req.body.model = 'gpt-5' // 同时更新请求体中的模型

View File

@@ -8,6 +8,7 @@ const {
handleStreamGenerateContent: geminiHandleStreamGenerateContent
} = require('../handlers/geminiHandlers')
const openaiRoutes = require('./openaiRoutes')
const apiKeyService = require('../services/apiKeyService')
const router = express.Router()
@@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) {
return await openaiRoutes.handleResponses(req, res)
} else if (backend === 'gemini') {
// Gemini 后端
if (permissions !== 'all' && permissions !== 'gemini') {
if (!apiKeyService.hasPermission(permissions, 'gemini')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access Gemini',

View File

@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
// 获取当前会话
const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复:检查空对象
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
})
}
// 🔒 安全修复:验证会话完整性
if (!sessionData.username || !sessionData.loginTime) {
logger.security(
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
)
await redis.deleteSession(token)
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 获取当前管理员信息
const adminData = await redis.getSession('admin_credentials')
if (!adminData) {
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
// 获取当前会话
const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复:检查空对象
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
})
}
// 🔒 安全修复:验证会话完整性
if (!sessionData.username || !sessionData.loginTime) {
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
await redis.deleteSession(token)
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 获取管理员信息
const adminData = await redis.getSession('admin_credentials')
if (!adminData) {
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
const sessionData = await redis.getSession(token)
if (!sessionData) {
// 🔒 安全修复检查空对象hgetall 对不存在的 key 返回 {}
if (!sessionData || Object.keys(sessionData).length === 0) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
})
}
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime
if (!sessionData.username || !sessionData.loginTime) {
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
await redis.deleteSession(token) // 清理无效/伪造的会话
return res.status(401).json({
error: 'Invalid session',
message: 'Session data corrupted or incomplete'
})
}
// 更新最后活动时间
sessionData.lastActivity = new Date().toISOString()
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)

View File

@@ -0,0 +1,748 @@
const redis = require('../models/redis')
const balanceScriptService = require('./balanceScriptService')
const logger = require('../utils/logger')
const CostCalculator = require('../utils/costCalculator')
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
class AccountBalanceService {
constructor(options = {}) {
this.redis = options.redis || redis
this.logger = options.logger || logger
this.providers = new Map()
this.CACHE_TTL_SECONDS = 3600
this.LOCAL_TTL_SECONDS = 300
this.LOW_BALANCE_THRESHOLD = 10
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
this.DEFAULT_CONCURRENCY = 10
}
getSupportedPlatforms() {
return [
'claude',
'claude-console',
'gemini',
'gemini-api',
'openai',
'openai-responses',
'azure_openai',
'bedrock',
'droid',
'ccr'
]
}
normalizePlatform(platform) {
if (!platform) {
return null
}
const value = String(platform).trim().toLowerCase()
// 兼容实施文档与历史命名
if (value === 'claude-official') {
return 'claude'
}
if (value === 'azure-openai') {
return 'azure_openai'
}
// 保持前端平台键一致
return value
}
registerProvider(platform, provider) {
const normalized = this.normalizePlatform(platform)
if (!normalized) {
throw new Error('registerProvider: 缺少 platform')
}
if (!provider || typeof provider.queryBalance !== 'function') {
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
}
this.providers.set(normalized, provider)
}
async getAccountBalance(accountId, platform, options = {}) {
const normalizedPlatform = this.normalizePlatform(platform)
const account = await this.getAccount(accountId, normalizedPlatform)
if (!account) {
return null
}
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
}
async refreshAccountBalance(accountId, platform) {
const normalizedPlatform = this.normalizePlatform(platform)
const account = await this.getAccount(accountId, normalizedPlatform)
if (!account) {
return null
}
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
queryApi: true,
useCache: false
})
}
async getAllAccountsBalance(platform, options = {}) {
const normalizedPlatform = this.normalizePlatform(platform)
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
const queryApi = this._parseBoolean(options.queryApi) || false
const useCache = options.useCache !== false
const results = await this._mapWithConcurrency(
accounts,
this.DEFAULT_CONCURRENCY,
async (acc) => {
try {
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
queryApi,
useCache
})
return { ...balance, name: acc.name || '' }
} catch (error) {
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
return {
success: true,
data: {
accountId: acc?.id,
platform: normalizedPlatform,
balance: null,
quota: null,
statistics: {},
source: 'local',
lastRefreshAt: new Date().toISOString(),
cacheExpiresAt: null,
status: 'error',
error: error.message || '批量查询失败'
},
name: acc?.name || ''
}
}
}
)
return results
}
async getBalanceSummary() {
const platforms = this.getSupportedPlatforms()
const summary = {
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
}
for (const platform of platforms) {
const accounts = await this.getAllAccountsByPlatform(platform)
const platformData = {
count: accounts.length,
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
accounts: []
}
const balances = await this._mapWithConcurrency(
accounts,
this.DEFAULT_CONCURRENCY,
async (acc) => {
const balance = await this._getAccountBalanceForAccount(acc, platform, {
queryApi: false,
useCache: true
})
return { ...balance, name: acc.name || '' }
}
)
for (const item of balances) {
platformData.accounts.push(item)
const amount = item?.data?.balance?.amount
const percentage = item?.data?.quota?.percentage
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
const isHighUsage =
typeof percentage === 'number' &&
Number.isFinite(percentage) &&
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
if (hasAmount) {
platformData.totalBalance += amount
}
if (isLowBalance || isHighUsage) {
platformData.lowBalanceCount += 1
summary.lowBalanceCount += 1
}
platformData.totalCost += totalCost
}
summary.platforms[platform] = platformData
summary.totalBalance += platformData.totalBalance
summary.totalCost += platformData.totalCost
}
return summary
}
async clearCache(accountId, platform) {
const normalizedPlatform = this.normalizePlatform(platform)
if (!normalizedPlatform) {
throw new Error('缺少 platform 参数')
}
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
}
async getAccount(accountId, platform) {
if (!accountId || !platform) {
return null
}
const serviceMap = {
claude: require('./claudeAccountService'),
'claude-console': require('./claudeConsoleAccountService'),
gemini: require('./geminiAccountService'),
'gemini-api': require('./geminiApiAccountService'),
openai: require('./openaiAccountService'),
'openai-responses': require('./openaiResponsesAccountService'),
azure_openai: require('./azureOpenaiAccountService'),
bedrock: require('./bedrockAccountService'),
droid: require('./droidAccountService'),
ccr: require('./ccrAccountService')
}
const service = serviceMap[platform]
if (!service || typeof service.getAccount !== 'function') {
return null
}
return await service.getAccount(accountId)
}
async getAllAccountsByPlatform(platform) {
if (!platform) {
return []
}
const serviceMap = {
claude: require('./claudeAccountService'),
'claude-console': require('./claudeConsoleAccountService'),
gemini: require('./geminiAccountService'),
'gemini-api': require('./geminiApiAccountService'),
openai: require('./openaiAccountService'),
'openai-responses': require('./openaiResponsesAccountService'),
azure_openai: require('./azureOpenaiAccountService'),
bedrock: require('./bedrockAccountService'),
droid: require('./droidAccountService'),
ccr: require('./ccrAccountService')
}
const service = serviceMap[platform]
if (!service) {
return []
}
// Bedrock 特殊:返回 { success, data }
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
const result = await service.getAllAccounts()
return result?.success ? result.data || [] : []
}
if (platform === 'openai-responses') {
return await service.getAllAccounts(true)
}
if (typeof service.getAllAccounts !== 'function') {
return []
}
return await service.getAllAccounts()
}
async _getAccountBalanceForAccount(account, platform, options = {}) {
const queryApi = this._parseBoolean(options.queryApi) || false
const useCache = options.useCache !== false
const accountId = account?.id
if (!accountId) {
throw new Error('账户缺少 id')
}
// 余额脚本配置状态(用于前端控制“刷新余额”按钮)
let scriptConfig = null
let scriptConfigured = false
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
scriptConfigured = !!(
scriptConfig &&
scriptConfig.scriptBody &&
String(scriptConfig.scriptBody).trim().length > 0
)
}
const scriptEnabled = isBalanceScriptEnabled()
const scriptMeta = { scriptEnabled, scriptConfigured }
const localBalance = await this._getBalanceFromLocal(accountId, platform)
const localStatistics = localBalance.statistics || {}
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
// 非强制查询:优先读缓存
if (!queryApi) {
if (useCache) {
const cached = await this.redis.getAccountBalance(platform, accountId)
if (cached && cached.status === 'success') {
return this._buildResponse(
{
status: cached.status,
errorMessage: cached.errorMessage,
balance: quotaFromLocal.balance ?? cached.balance,
currency: quotaFromLocal.currency || cached.currency || 'USD',
quota: quotaFromLocal.quota || cached.quota || null,
statistics: localStatistics,
lastRefreshAt: cached.lastRefreshAt
},
accountId,
platform,
'cache',
cached.ttlSeconds,
scriptMeta
)
}
}
return this._buildResponse(
{
status: 'success',
errorMessage: null,
balance: quotaFromLocal.balance,
currency: quotaFromLocal.currency || 'USD',
quota: quotaFromLocal.quota,
statistics: localStatistics,
lastRefreshAt: localBalance.lastCalculated
},
accountId,
platform,
'local',
null,
scriptMeta
)
}
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider失败自动降级到本地统计
let providerResult
if (scriptEnabled && scriptConfigured) {
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
} else {
const provider = this.providers.get(platform)
if (!provider) {
return this._buildResponse(
{
status: 'error',
errorMessage: `不支持的平台: ${platform}`,
balance: quotaFromLocal.balance,
currency: quotaFromLocal.currency || 'USD',
quota: quotaFromLocal.quota,
statistics: localStatistics,
lastRefreshAt: new Date().toISOString()
},
accountId,
platform,
'local',
null,
scriptMeta
)
}
providerResult = await this._getBalanceFromProvider(provider, account)
}
const isRemoteSuccess =
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
if (isRemoteSuccess) {
await this.redis.setAccountBalance(
platform,
accountId,
providerResult,
this.CACHE_TTL_SECONDS
)
}
const source = isRemoteSuccess ? 'api' : 'local'
return this._buildResponse(
{
status: providerResult.status,
errorMessage: providerResult.errorMessage,
balance: quotaFromLocal.balance ?? providerResult.balance,
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
quota: quotaFromLocal.quota || providerResult.quota || null,
statistics: localStatistics,
lastRefreshAt: providerResult.lastRefreshAt
},
accountId,
platform,
source,
null,
scriptMeta
)
}
async _getBalanceFromScript(scriptConfig, accountId, platform) {
try {
const result = await balanceScriptService.execute({
scriptBody: scriptConfig.scriptBody,
timeoutSeconds: scriptConfig.timeoutSeconds || 10,
variables: {
baseUrl: scriptConfig.baseUrl || '',
apiKey: scriptConfig.apiKey || '',
token: scriptConfig.token || '',
accountId,
platform,
extra: scriptConfig.extra || ''
}
})
const mapped = result?.mapped || {}
return {
status: mapped.status || 'error',
balance: typeof mapped.balance === 'number' ? mapped.balance : null,
currency: mapped.currency || 'USD',
quota: mapped.quota || null,
queryMethod: 'api',
rawData: mapped.rawData || result?.response?.data || null,
lastRefreshAt: new Date().toISOString(),
errorMessage: mapped.errorMessage || ''
}
} catch (error) {
return {
status: 'error',
balance: null,
currency: 'USD',
quota: null,
queryMethod: 'api',
rawData: null,
lastRefreshAt: new Date().toISOString(),
errorMessage: error.message || '脚本执行失败'
}
}
}
async _getBalanceFromProvider(provider, account) {
try {
const result = await provider.queryBalance(account)
return {
status: 'success',
balance: typeof result?.balance === 'number' ? result.balance : null,
currency: result?.currency || 'USD',
quota: result?.quota || null,
queryMethod: result?.queryMethod || 'api',
rawData: result?.rawData || null,
lastRefreshAt: new Date().toISOString(),
errorMessage: ''
}
} catch (error) {
return {
status: 'error',
balance: null,
currency: 'USD',
quota: null,
queryMethod: 'api',
rawData: null,
lastRefreshAt: new Date().toISOString(),
errorMessage: error.message || '查询失败'
}
}
}
async _getBalanceFromLocal(accountId, platform) {
const cached = await this.redis.getLocalBalance(platform, accountId)
if (cached && cached.statistics) {
return cached
}
const statistics = await this._computeLocalStatistics(accountId)
const localBalance = {
status: 'success',
balance: null,
currency: 'USD',
statistics,
queryMethod: 'local',
lastCalculated: new Date().toISOString()
}
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
return localBalance
}
async _computeLocalStatistics(accountId) {
const safeNumber = (value) => {
const num = Number(value)
return Number.isFinite(num) ? num : 0
}
try {
const usageStats = await this.redis.getAccountUsageStats(accountId)
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
const monthlyCost = await this._computeMonthlyCost(accountId)
const totalCost = await this._computeTotalCost(accountId)
return {
totalCost,
dailyCost,
monthlyCost,
totalRequests: safeNumber(usageStats?.total?.requests || 0),
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
}
} catch (error) {
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
return {
totalCost: 0,
dailyCost: 0,
monthlyCost: 0,
totalRequests: 0,
dailyRequests: 0,
monthlyRequests: 0
}
}
}
async _computeMonthlyCost(accountId) {
const tzDate = this.redis.getDateInTimezone(new Date())
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
return await this._sumModelCostsByKeysPattern(pattern)
}
async _computeTotalCost(accountId) {
const pattern = `account_usage:model:monthly:${accountId}:*:*`
return await this._sumModelCostsByKeysPattern(pattern)
}
async _sumModelCostsByKeysPattern(pattern) {
try {
const client = this.redis.getClientSafe()
let totalCost = 0
let cursor = '0'
const scanCount = 200
let iterations = 0
const maxIterations = 2000
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
cursor = nextCursor
iterations += 1
if (!keys || keys.length === 0) {
continue
}
const pipeline = client.pipeline()
keys.forEach((key) => pipeline.hgetall(key))
const results = await pipeline.exec()
for (let i = 0; i < results.length; i += 1) {
const [, data] = results[i] || []
if (!data || Object.keys(data).length === 0) {
continue
}
const parts = String(keys[i]).split(':')
const model = parts[4] || 'unknown'
const usage = {
input_tokens: parseInt(data.inputTokens || 0),
output_tokens: parseInt(data.outputTokens || 0),
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
}
const costResult = CostCalculator.calculateCost(usage, model)
totalCost += costResult.costs.total || 0
}
if (iterations >= maxIterations) {
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
break
}
} while (cursor !== '0')
return totalCost
} catch (error) {
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
return 0
}
}
_buildQuotaFromLocal(account, statistics) {
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
return { balance: null, currency: null, quota: null }
}
const dailyQuota = Number(account.dailyQuota || 0)
const used = Number(statistics?.dailyCost || 0)
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
// 不限制
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
return {
balance: null,
currency: 'USD',
quota: {
daily: Infinity,
used,
remaining: Infinity,
percentage: 0,
unlimited: true,
resetAt
}
}
}
const remaining = Math.max(0, dailyQuota - used)
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
return {
balance: remaining,
currency: 'USD',
quota: {
daily: dailyQuota,
used,
remaining,
resetAt,
percentage: Math.round(percentage * 100) / 100
}
}
}
_computeNextResetAt(resetTime) {
const now = new Date()
const tzNow = this.redis.getDateInTimezone(now)
const offsetMs = tzNow.getTime() - now.getTime()
const [h, m] = String(resetTime || '00:00')
.split(':')
.map((n) => parseInt(n, 10))
const resetHour = Number.isFinite(h) ? h : 0
const resetMinute = Number.isFinite(m) ? m : 0
const year = tzNow.getUTCFullYear()
const month = tzNow.getUTCMonth()
const day = tzNow.getUTCDate()
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
if (resetAtMs <= now.getTime()) {
resetAtMs += 24 * 60 * 60 * 1000
}
return new Date(resetAtMs).toISOString()
}
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
const now = new Date()
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
const currency = balanceData.currency || 'USD'
let cacheExpiresAt = null
if (source === 'cache') {
const ttl =
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
}
return {
success: true,
data: {
accountId,
platform,
balance:
typeof amount === 'number'
? {
amount,
currency,
formattedAmount: this._formatCurrency(amount, currency)
}
: null,
quota: balanceData.quota || null,
statistics: balanceData.statistics || {},
source,
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
cacheExpiresAt,
status: balanceData.status || 'success',
error: balanceData.errorMessage || null,
...(extraData && typeof extraData === 'object' ? extraData : {})
}
}
}
_formatCurrency(amount, currency = 'USD') {
try {
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
return 'N/A'
}
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
} catch (error) {
return `$${amount.toFixed(2)}`
}
}
_parseBoolean(value) {
if (typeof value === 'boolean') {
return value
}
if (typeof value !== 'string') {
return null
}
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return true
}
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
return false
}
return null
}
async _mapWithConcurrency(items, limit, mapper) {
const concurrency = Math.max(1, Number(limit) || 1)
const list = Array.isArray(items) ? items : []
const results = new Array(list.length)
let nextIndex = 0
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
while (nextIndex < list.length) {
const currentIndex = nextIndex
nextIndex += 1
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
}
})
await Promise.all(workers)
return results
}
}
const accountBalanceService = new AccountBalanceService()
module.exports = accountBalanceService
module.exports.AccountBalanceService = AccountBalanceService

View File

@@ -0,0 +1,420 @@
/**
* 账户定时测试调度服务
* 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务
*/
const cron = require('node-cron')
const redis = require('../models/redis')
const logger = require('../utils/logger')
class AccountTestSchedulerService {
constructor() {
// 存储每个账户的 cron 任务: Map<string, { task: ScheduledTask, cronExpression: string }>
this.scheduledTasks = new Map()
// 定期刷新配置的间隔 (毫秒)
this.refreshIntervalMs = 60 * 1000
this.refreshInterval = null
// 当前正在测试的账户
this.testingAccounts = new Set()
// 是否已启动
this.isStarted = false
}
/**
* 验证 cron 表达式是否有效
* @param {string} cronExpression - cron 表达式
* @returns {boolean}
*/
validateCronExpression(cronExpression) {
// 长度检查(防止 DoS
if (!cronExpression || cronExpression.length > 100) {
return false
}
return cron.validate(cronExpression)
}
/**
* 启动调度器
*/
async start() {
if (this.isStarted) {
logger.warn('⚠️ Account test scheduler is already running')
return
}
this.isStarted = true
logger.info('🚀 Starting account test scheduler service (node-cron mode)')
// 初始化所有已配置账户的定时任务
await this._refreshAllTasks()
// 定期刷新配置,以便动态添加/修改的配置能生效
this.refreshInterval = setInterval(() => {
this._refreshAllTasks()
}, this.refreshIntervalMs)
logger.info(
`📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)`
)
}
/**
* 停止调度器
*/
stop() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
// 停止所有 cron 任务
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
taskInfo.task.stop()
logger.debug(`🛑 Stopped cron task for ${accountKey}`)
}
this.scheduledTasks.clear()
this.isStarted = false
logger.info('🛑 Account test scheduler stopped')
}
/**
* 刷新所有账户的定时任务
* @private
*/
async _refreshAllTasks() {
try {
const platforms = ['claude', 'gemini', 'openai']
const activeAccountKeys = new Set()
// 并行加载所有平台的配置
const allEnabledAccounts = await Promise.all(
platforms.map((platform) =>
redis
.getEnabledTestAccounts(platform)
.then((accounts) => accounts.map((acc) => ({ ...acc, platform })))
.catch((error) => {
logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error)
return []
})
)
)
// 展平平台数据
const flatAccounts = allEnabledAccounts.flat()
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
if (!cronExpression) {
logger.warn(
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
)
continue
}
const accountKey = `${platform}:${accountId}`
activeAccountKeys.add(accountKey)
// 检查是否需要更新任务
const existingTask = this.scheduledTasks.get(accountKey)
if (existingTask) {
// 如果 cron 表达式和模型都没变,不需要更新
if (existingTask.cronExpression === cronExpression && existingTask.model === model) {
continue
}
// 配置变了,停止旧任务
existingTask.task.stop()
logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
} else {
logger.info(` Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
}
// 创建新的 cron 任务
this._createCronTask(accountId, platform, cronExpression, model)
}
// 清理已删除或禁用的账户任务
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
if (!activeAccountKeys.has(accountKey)) {
taskInfo.task.stop()
this.scheduledTasks.delete(accountKey)
logger.info(` Removed cron task for ${accountKey} (disabled or deleted)`)
}
}
} catch (error) {
logger.error('❌ Error refreshing account test tasks:', error)
}
}
/**
* 为单个账户创建 cron 任务
* @param {string} accountId
* @param {string} platform
* @param {string} cronExpression
* @param {string} model - 测试使用的模型
* @private
*/
_createCronTask(accountId, platform, cronExpression, model) {
const accountKey = `${platform}:${accountId}`
// 验证 cron 表达式
if (!this.validateCronExpression(cronExpression)) {
logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`)
return
}
const task = cron.schedule(
cronExpression,
async () => {
await this._runAccountTest(accountId, platform, model)
},
{
scheduled: true,
timezone: process.env.TZ || 'Asia/Shanghai'
}
)
this.scheduledTasks.set(accountKey, {
task,
cronExpression,
model,
accountId,
platform
})
}
/**
* 执行单个账户测试
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {string} model - 测试使用的模型
* @private
*/
async _runAccountTest(accountId, platform, model) {
const accountKey = `${platform}:${accountId}`
// 避免重复测试
if (this.testingAccounts.has(accountKey)) {
logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`)
return
}
this.testingAccounts.add(accountKey)
try {
logger.info(
`🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})`
)
let testResult
// 根据平台调用对应的测试方法
switch (platform) {
case 'claude':
testResult = await this._testClaudeAccount(accountId, model)
break
case 'gemini':
testResult = await this._testGeminiAccount(accountId, model)
break
case 'openai':
testResult = await this._testOpenAIAccount(accountId, model)
break
default:
testResult = {
success: false,
error: `Unsupported platform: ${platform}`,
timestamp: new Date().toISOString()
}
}
// 保存测试结果
await redis.saveAccountTestResult(accountId, platform, testResult)
// 更新最后测试时间
await redis.setAccountLastTestTime(accountId, platform)
// 记录日志
if (testResult.success) {
logger.info(
`✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)`
)
} else {
logger.warn(
`❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}`
)
}
return testResult
} catch (error) {
logger.error(`❌ Error testing ${platform} account ${accountId}:`, error)
const errorResult = {
success: false,
error: error.message,
timestamp: new Date().toISOString()
}
await redis.saveAccountTestResult(accountId, platform, errorResult)
await redis.setAccountLastTestTime(accountId, platform)
return errorResult
} finally {
this.testingAccounts.delete(accountKey)
}
}
/**
* 测试 Claude 账户
* @param {string} accountId
* @param {string} model - 测试使用的模型
* @private
*/
async _testClaudeAccount(accountId, model) {
const claudeRelayService = require('./claudeRelayService')
return await claudeRelayService.testAccountConnectionSync(accountId, model)
}
/**
* 测试 Gemini 账户
* @param {string} _accountId
* @param {string} _model
* @private
*/
async _testGeminiAccount(_accountId, _model) {
// Gemini 测试暂时返回未实现
return {
success: false,
error: 'Gemini scheduled test not implemented yet',
timestamp: new Date().toISOString()
}
}
/**
* 测试 OpenAI 账户
* @param {string} _accountId
* @param {string} _model
* @private
*/
async _testOpenAIAccount(_accountId, _model) {
// OpenAI 测试暂时返回未实现
return {
success: false,
error: 'OpenAI scheduled test not implemented yet',
timestamp: new Date().toISOString()
}
}
/**
* 手动触发账户测试
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {string} model - 测试使用的模型
* @returns {Promise<Object>} 测试结果
*/
async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') {
logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`)
return await this._runAccountTest(accountId, platform, model)
}
/**
* 获取账户测试历史
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 测试历史
*/
async getTestHistory(accountId, platform) {
return await redis.getAccountTestHistory(accountId, platform)
}
/**
* 获取账户测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>}
*/
async getTestConfig(accountId, platform) {
return await redis.getAccountTestConfig(accountId, platform)
}
/**
* 设置账户测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string }
* @returns {Promise<void>}
*/
async setTestConfig(accountId, platform, testConfig) {
// 验证 cron 表达式
if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) {
throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`)
}
await redis.saveAccountTestConfig(accountId, platform, testConfig)
logger.info(
`📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}`
)
// 立即刷新任务,使配置立即生效
if (this.isStarted) {
await this._refreshAllTasks()
}
}
/**
* 更新单个账户的定时任务(配置变更时调用)
* @param {string} accountId
* @param {string} platform
*/
async refreshAccountTask(accountId, platform) {
if (!this.isStarted) {
return
}
const accountKey = `${platform}:${accountId}`
const testConfig = await redis.getAccountTestConfig(accountId, platform)
// 停止现有任务
const existingTask = this.scheduledTasks.get(accountKey)
if (existingTask) {
existingTask.task.stop()
this.scheduledTasks.delete(accountKey)
}
// 如果启用且有有效的 cron 表达式,创建新任务
if (testConfig?.enabled && testConfig?.cronExpression) {
this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model)
logger.info(
`🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}`
)
}
}
/**
* 获取调度器状态
* @returns {Object}
*/
getStatus() {
const tasks = []
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
tasks.push({
accountKey,
accountId: taskInfo.accountId,
platform: taskInfo.platform,
cronExpression: taskInfo.cronExpression,
model: taskInfo.model
})
}
return {
running: this.isStarted,
refreshIntervalMs: this.refreshIntervalMs,
scheduledTasksCount: this.scheduledTasks.size,
scheduledTasks: tasks,
currentlyTesting: Array.from(this.testingAccounts)
}
}
}
// 单例模式
const accountTestSchedulerService = new AccountTestSchedulerService()
module.exports = accountTestSchedulerService

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,559 @@
const axios = require('axios')
const https = require('https')
const { v4: uuidv4 } = require('uuid')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const {
mapAntigravityUpstreamModel,
normalizeAntigravityModelInput,
getAntigravityModelMetadata
} = require('../utils/antigravityModel')
const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner')
const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump')
const keepAliveAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30000,
timeout: 120000,
maxSockets: 100,
maxFreeSockets: 10
})
function getAntigravityApiUrl() {
return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com'
}
function normalizeBaseUrl(url) {
const str = String(url || '').trim()
return str.endsWith('/') ? str.slice(0, -1) : str
}
function getAntigravityApiUrlCandidates() {
const configured = normalizeBaseUrl(getAntigravityApiUrl())
const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com'
const prod = 'https://cloudcode-pa.googleapis.com'
// 若显式配置了自定义 base url则只使用该地址不做 fallback避免意外路由到别的环境
if (process.env.ANTIGRAVITY_API_URL) {
return [configured]
}
// 默认行为:优先 daily与旧逻辑一致失败时再尝试 prod对齐 CLIProxyAPI
if (configured === normalizeBaseUrl(daily)) {
return [configured, prod]
}
if (configured === normalizeBaseUrl(prod)) {
return [configured, daily]
}
return [configured, prod, daily].filter(Boolean)
}
function getAntigravityHeaders(accessToken, baseUrl) {
const resolvedBaseUrl = baseUrl || getAntigravityApiUrl()
let host = 'daily-cloudcode-pa.sandbox.googleapis.com'
try {
host = new URL(resolvedBaseUrl).host || host
} catch (e) {
// ignore
}
return {
Host: host,
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Accept-Encoding': 'gzip'
}
}
function generateAntigravityProjectId() {
return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}`
}
function generateAntigravitySessionId() {
return `sess-${uuidv4()}`
}
function resolveAntigravityProjectId(projectId, requestData) {
const candidate = projectId || requestData?.project || requestData?.projectId || null
return candidate || generateAntigravityProjectId()
}
function resolveAntigravitySessionId(sessionId, requestData) {
const candidate =
sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null
return candidate || generateAntigravitySessionId()
}
function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) {
const model = mapAntigravityUpstreamModel(requestData?.model)
const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData)
const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData)
const requestPayload = {
...(requestData?.request || {})
}
if (requestPayload.session_id !== undefined) {
delete requestPayload.session_id
}
requestPayload.sessionId = resolvedSessionId
const envelope = {
project: resolvedProjectId,
requestId: `req-${uuidv4()}`,
model,
userAgent: 'antigravity',
request: {
...requestPayload
}
}
if (userPromptId) {
envelope.user_prompt_id = userPromptId
envelope.userPromptId = userPromptId
}
normalizeAntigravityEnvelope(envelope)
return { model, envelope }
}
function normalizeAntigravityThinking(model, requestPayload) {
if (!requestPayload || typeof requestPayload !== 'object') {
return
}
const { generationConfig } = requestPayload
if (!generationConfig || typeof generationConfig !== 'object') {
return
}
const { thinkingConfig } = generationConfig
if (!thinkingConfig || typeof thinkingConfig !== 'object') {
return
}
const normalizedModel = normalizeAntigravityModelInput(model)
if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) {
delete thinkingConfig.thinkingLevel
}
const metadata = getAntigravityModelMetadata(normalizedModel)
if (metadata && !metadata.thinking) {
delete generationConfig.thinkingConfig
return
}
if (!metadata || !metadata.thinking) {
return
}
const budgetRaw = Number(thinkingConfig.thinkingBudget)
if (!Number.isFinite(budgetRaw)) {
return
}
let budget = Math.trunc(budgetRaw)
const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null
const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null
if (maxBudget !== null && budget > maxBudget) {
budget = maxBudget
}
let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens)
? generationConfig.maxOutputTokens
: null
let setDefaultMax = false
if (!effectiveMax && metadata.maxCompletionTokens) {
effectiveMax = metadata.maxCompletionTokens
setDefaultMax = true
}
if (effectiveMax && budget >= effectiveMax) {
budget = Math.max(0, effectiveMax - 1)
}
if (minBudget !== null && budget >= 0 && budget < minBudget) {
delete generationConfig.thinkingConfig
return
}
thinkingConfig.thinkingBudget = budget
if (setDefaultMax) {
generationConfig.maxOutputTokens = effectiveMax
}
}
function normalizeAntigravityEnvelope(envelope) {
if (!envelope || typeof envelope !== 'object') {
return
}
const model = String(envelope.model || '')
const requestPayload = envelope.request
if (!requestPayload || typeof requestPayload !== 'object') {
return
}
if (requestPayload.safetySettings !== undefined) {
delete requestPayload.safetySettings
}
// 对齐 CLIProxyAPI有 tools 时默认启用 VALIDATED除非显式 NONE
if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {
const existing = requestPayload?.toolConfig?.functionCallingConfig || null
if (existing?.mode !== 'NONE') {
const nextCfg = { ...(existing || {}), mode: 'VALIDATED' }
requestPayload.toolConfig = { functionCallingConfig: nextCfg }
}
}
// 对齐 CLIProxyAPI非 Claude 模型移除 maxOutputTokensAntigravity 环境不稳定)
normalizeAntigravityThinking(model, requestPayload)
if (!model.includes('claude')) {
if (requestPayload.generationConfig && typeof requestPayload.generationConfig === 'object') {
delete requestPayload.generationConfig.maxOutputTokens
}
return
}
// Claude 模型parametersJsonSchema -> parameters + schema 清洗(避免 $schema / additionalProperties 等触发 400
if (!Array.isArray(requestPayload.tools)) {
return
}
for (const tool of requestPayload.tools) {
if (!tool || typeof tool !== 'object') {
continue
}
const decls = Array.isArray(tool.functionDeclarations)
? tool.functionDeclarations
: Array.isArray(tool.function_declarations)
? tool.function_declarations
: null
if (!decls) {
continue
}
for (const decl of decls) {
if (!decl || typeof decl !== 'object') {
continue
}
let schema =
decl.parametersJsonSchema !== undefined ? decl.parametersJsonSchema : decl.parameters
if (typeof schema === 'string' && schema) {
try {
schema = JSON.parse(schema)
} catch (_) {
schema = null
}
}
decl.parameters = cleanJsonSchemaForGemini(schema)
delete decl.parametersJsonSchema
}
}
}
async function request({
accessToken,
proxyConfig = null,
requestData,
projectId = null,
sessionId = null,
userPromptId = null,
stream = false,
signal = null,
params = null,
timeoutMs = null
}) {
const { model, envelope } = buildAntigravityEnvelope({
requestData,
projectId,
sessionId,
userPromptId
})
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
let endpoints = getAntigravityApiUrlCandidates()
// Claude 模型在 sandbox(daily) 环境下对 tool_use/tool_result 的兼容性不稳定,优先走 prod。
// 保持可配置优先:若用户显式设置了 ANTIGRAVITY_API_URL则不改变顺序。
if (!process.env.ANTIGRAVITY_API_URL && String(model).includes('claude')) {
const prodHost = 'cloudcode-pa.googleapis.com'
const dailyHost = 'daily-cloudcode-pa.sandbox.googleapis.com'
const ordered = []
for (const u of endpoints) {
if (String(u).includes(prodHost)) {
ordered.push(u)
}
}
for (const u of endpoints) {
if (!String(u).includes(prodHost)) {
ordered.push(u)
}
}
// 去重并保持 prod -> daily 的稳定顺序
endpoints = Array.from(new Set(ordered)).sort((a, b) => {
const av = String(a)
const bv = String(b)
const aScore = av.includes(prodHost) ? 0 : av.includes(dailyHost) ? 1 : 2
const bScore = bv.includes(prodHost) ? 0 : bv.includes(dailyHost) ? 1 : 2
return aScore - bScore
})
}
const isRetryable = (error) => {
const status = error?.response?.status
if (status === 429) {
return true
}
// 400/404 的 “model unavailable / not found” 在不同环境间可能表现不同,允许 fallback。
if (status === 400 || status === 404) {
const data = error?.response?.data
const safeToString = (value) => {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
// axios responseType=stream 时data 可能是 stream存在循环引用不能 JSON.stringify
if (typeof value === 'object' && typeof value.pipe === 'function') {
return ''
}
if (Buffer.isBuffer(value)) {
try {
return value.toString('utf8')
} catch (_) {
return ''
}
}
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch (_) {
return ''
}
}
return String(value)
}
const text = safeToString(data)
const msg = (text || '').toLowerCase()
return (
msg.includes('requested model is currently unavailable') ||
msg.includes('tool_use') ||
msg.includes('tool_result') ||
msg.includes('requested entity was not found') ||
msg.includes('not found')
)
}
return false
}
let lastError = null
let retriedAfterDelay = false
const attemptRequest = async () => {
for (let index = 0; index < endpoints.length; index += 1) {
const baseUrl = endpoints[index]
const url = `${baseUrl}/v1internal:${stream ? 'streamGenerateContent' : 'generateContent'}`
const axiosConfig = {
url,
method: 'POST',
...(params ? { params } : {}),
headers: getAntigravityHeaders(accessToken, baseUrl),
data: envelope,
timeout: stream ? 0 : timeoutMs || 600000,
...(stream ? { responseType: 'stream' } : {})
}
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
if (index === 0) {
logger.info(
`🌐 Using proxy for Antigravity ${stream ? 'streamGenerateContent' : 'generateContent'}: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
}
} else {
axiosConfig.httpsAgent = keepAliveAgent
}
if (signal) {
axiosConfig.signal = signal
}
try {
dumpAntigravityUpstreamRequest({
requestId: envelope.requestId,
model,
stream,
url,
baseUrl,
params: axiosConfig.params || null,
headers: axiosConfig.headers,
envelope
}).catch(() => {})
const response = await axios(axiosConfig)
return { model, response }
} catch (error) {
lastError = error
const status = error?.response?.status || null
const hasNext = index + 1 < endpoints.length
if (hasNext && isRetryable(error)) {
logger.warn('⚠️ Antigravity upstream error, retrying with fallback baseUrl', {
status,
from: baseUrl,
to: endpoints[index + 1],
model
})
continue
}
throw error
}
}
throw lastError || new Error('Antigravity request failed')
}
try {
return await attemptRequest()
} catch (error) {
// 如果是 429 RESOURCE_EXHAUSTED 且尚未重试过,等待 2 秒后重试一次
const status = error?.response?.status
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
const data = error?.response?.data
const msg = typeof data === 'string' ? data : JSON.stringify(data || '')
if (
msg.toLowerCase().includes('resource_exhausted') ||
msg.toLowerCase().includes('no capacity')
) {
retriedAfterDelay = true
logger.warn('⏳ Antigravity 429 RESOURCE_EXHAUSTED, waiting 2s before retry', { model })
await new Promise((resolve) => setTimeout(resolve, 2000))
return await attemptRequest()
}
}
throw error
}
}
async function fetchAvailableModels({ accessToken, proxyConfig = null, timeoutMs = 30000 }) {
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
const endpoints = getAntigravityApiUrlCandidates()
let lastError = null
for (let index = 0; index < endpoints.length; index += 1) {
const baseUrl = endpoints[index]
const url = `${baseUrl}/v1internal:fetchAvailableModels`
const axiosConfig = {
url,
method: 'POST',
headers: getAntigravityHeaders(accessToken, baseUrl),
data: {},
timeout: timeoutMs
}
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
if (index === 0) {
logger.info(
`🌐 Using proxy for Antigravity fetchAvailableModels: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
}
} else {
axiosConfig.httpsAgent = keepAliveAgent
}
try {
const response = await axios(axiosConfig)
return response.data
} catch (error) {
lastError = error
const status = error?.response?.status
const hasNext = index + 1 < endpoints.length
if (hasNext && (status === 429 || status === 404)) {
continue
}
throw error
}
}
throw lastError || new Error('Antigravity fetchAvailableModels failed')
}
async function countTokens({
accessToken,
proxyConfig = null,
contents,
model,
timeoutMs = 30000
}) {
const upstreamModel = mapAntigravityUpstreamModel(model)
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
const endpoints = getAntigravityApiUrlCandidates()
let lastError = null
for (let index = 0; index < endpoints.length; index += 1) {
const baseUrl = endpoints[index]
const url = `${baseUrl}/v1internal:countTokens`
const axiosConfig = {
url,
method: 'POST',
headers: getAntigravityHeaders(accessToken, baseUrl),
data: {
request: {
model: `models/${upstreamModel}`,
contents
}
},
timeout: timeoutMs
}
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
axiosConfig.proxy = false
if (index === 0) {
logger.info(
`🌐 Using proxy for Antigravity countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
}
} else {
axiosConfig.httpsAgent = keepAliveAgent
}
try {
const response = await axios(axiosConfig)
return response.data
} catch (error) {
lastError = error
const status = error?.response?.status
const hasNext = index + 1 < endpoints.length
if (hasNext && (status === 429 || status === 404)) {
continue
}
throw error
}
}
throw lastError || new Error('Antigravity countTokens failed')
}
module.exports = {
getAntigravityApiUrl,
getAntigravityApiUrlCandidates,
getAntigravityHeaders,
buildAntigravityEnvelope,
request,
fetchAvailableModels,
countTokens
}

View File

@@ -0,0 +1,170 @@
const apiKeyService = require('./apiKeyService')
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
const antigravityClient = require('./antigravityClient')
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
const requestedModel = normalizeAntigravityModelInput(model)
const { contents, systemInstruction } = convertMessagesToGemini(messages)
const requestData = {
model: requestedModel,
request: {
contents,
generationConfig: {
temperature,
maxOutputTokens: maxTokens,
candidateCount: 1,
topP: 0.95,
topK: 40
},
...(sessionId ? { sessionId } : {})
}
}
if (systemInstruction) {
requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] }
}
return requestData
}
async function* handleStreamResponse(response, model, apiKeyId, accountId) {
let buffer = ''
let totalUsage = {
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0
}
let usageRecorded = false
try {
for await (const chunk of response.data) {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) {
continue
}
let jsonData = line
if (line.startsWith('data: ')) {
jsonData = line.substring(6).trim()
}
if (!jsonData || jsonData === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonData)
const payload = data?.response || data
if (payload?.usageMetadata) {
totalUsage = payload.usageMetadata
}
const openaiChunk = convertGeminiResponse(payload, model, true)
if (openaiChunk) {
yield `data: ${JSON.stringify(openaiChunk)}\n\n`
const finishReason = openaiChunk.choices?.[0]?.finish_reason
if (finishReason === 'stop') {
yield 'data: [DONE]\n\n'
if (apiKeyId && totalUsage.totalTokenCount > 0) {
await apiKeyService.recordUsage(
apiKeyId,
totalUsage.promptTokenCount || 0,
totalUsage.candidatesTokenCount || 0,
0,
0,
model,
accountId
)
usageRecorded = true
}
return
}
}
} catch (e) {
// ignore chunk parse errors
}
}
}
} finally {
if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) {
await apiKeyService.recordUsage(
apiKeyId,
totalUsage.promptTokenCount || 0,
totalUsage.candidatesTokenCount || 0,
0,
0,
model,
accountId
)
}
}
}
async function sendAntigravityRequest({
messages,
model,
temperature = 0.7,
maxTokens = 4096,
stream = false,
accessToken,
proxy,
apiKeyId,
signal,
projectId,
accountId = null
}) {
const requestedModel = normalizeAntigravityModelInput(model)
const requestData = buildRequestData({
messages,
model: requestedModel,
temperature,
maxTokens,
sessionId: apiKeyId
})
const { response } = await antigravityClient.request({
accessToken,
proxyConfig: proxy,
requestData,
projectId,
sessionId: apiKeyId,
stream,
signal,
params: { alt: 'sse' }
})
if (stream) {
return handleStreamResponse(response, requestedModel, apiKeyId, accountId)
}
const payload = response.data?.response || response.data
const openaiResponse = convertGeminiResponse(payload, requestedModel, false)
if (apiKeyId && openaiResponse?.usage) {
await apiKeyService.recordUsage(
apiKeyId,
openaiResponse.usage.prompt_tokens || 0,
openaiResponse.usage.completion_tokens || 0,
0,
0,
requestedModel,
accountId
)
}
return openaiResponse
}
module.exports = {
sendAntigravityRequest
}

View File

@@ -37,6 +37,51 @@ const ACCOUNT_CATEGORY_MAP = {
droid: 'droid'
}
/**
* 规范化权限数据,兼容旧格式(字符串)和新格式(数组)
* @param {string|array} permissions - 权限数据
* @returns {array} - 权限数组,空数组表示全部服务
*/
function normalizePermissions(permissions) {
if (!permissions) {
return [] // 空 = 全部服务
}
if (Array.isArray(permissions)) {
return permissions
}
// 尝试解析 JSON 字符串(新格式存储)
if (typeof permissions === 'string') {
if (permissions.startsWith('[')) {
try {
const parsed = JSON.parse(permissions)
if (Array.isArray(parsed)) {
return parsed
}
} catch (e) {
// 解析失败,继续处理为普通字符串
}
}
// 旧格式 'all' 转为空数组
if (permissions === 'all') {
return []
}
// 旧单个字符串转为数组
return [permissions]
}
return []
}
/**
* 检查是否有访问特定服务的权限
* @param {string|array} permissions - 权限数据
* @param {string} service - 服务名称claude/gemini/openai/droid
* @returns {boolean} - 是否有权限
*/
function hasPermission(permissions, service) {
const perms = normalizePermissions(permissions)
return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务
}
function normalizeAccountTypeKey(type) {
if (!type) {
return null
@@ -89,7 +134,7 @@ class ApiKeyService {
azureOpenaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
droidAccountId = null,
permissions = 'all', // 可选值:'claude''gemini'、'openai'、'droid' 或 'all'
permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini']
isActive = true,
concurrencyLimit = 0,
rateLimitWindow = null,
@@ -132,7 +177,7 @@ class ApiKeyService {
azureOpenaiAccountId: azureOpenaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
droidAccountId: droidAccountId || '',
permissions: permissions || 'all',
permissions: JSON.stringify(normalizePermissions(permissions)),
enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []),
enableClientRestriction: String(enableClientRestriction || false),
@@ -186,7 +231,7 @@ class ApiKeyService {
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions,
permissions: normalizePermissions(keyData.permissions),
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels),
enableClientRestriction: keyData.enableClientRestriction === 'true',
@@ -338,7 +383,7 @@ class ApiKeyService {
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all',
permissions: normalizePermissions(keyData.permissions),
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
@@ -467,7 +512,7 @@ class ApiKeyService {
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId,
droidAccountId: keyData.droidAccountId,
permissions: keyData.permissions || 'all',
permissions: normalizePermissions(keyData.permissions),
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
@@ -525,7 +570,7 @@ class ApiKeyService {
key.isActive = key.isActive === 'true'
key.enableModelRestriction = key.enableModelRestriction === 'true'
key.enableClientRestriction = key.enableClientRestriction === 'true'
key.permissions = key.permissions || 'all' // 兼容旧数据
key.permissions = normalizePermissions(key.permissions)
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
@@ -1568,7 +1613,7 @@ class ApiKeyService {
userId: keyData.userId,
userUsername: keyData.userUsername,
createdBy: keyData.createdBy,
permissions: keyData.permissions,
permissions: normalizePermissions(keyData.permissions),
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
// 所有平台账户绑定字段
@@ -1820,4 +1865,8 @@ const apiKeyService = new ApiKeyService()
// 为了方便其他服务调用,导出 recordUsage 方法
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
// 导出权限辅助函数供路由使用
apiKeyService.hasPermission = hasPermission
apiKeyService.normalizePermissions = normalizePermissions
module.exports = apiKeyService

View File

@@ -0,0 +1,133 @@
const axios = require('axios')
const logger = require('../../utils/logger')
const ProxyHelper = require('../../utils/proxyHelper')
/**
* Provider 抽象基类
* 各平台 Provider 需继承并实现 queryBalance(account)
*/
class BaseBalanceProvider {
constructor(platform) {
this.platform = platform
this.logger = logger
}
/**
* 查询余额(抽象方法)
* @param {object} account - 账户对象
* @returns {Promise<object>}
* 形如:
* {
* balance: number|null,
* currency?: string,
* quota?: { daily, used, remaining, resetAt, percentage, unlimited? },
* queryMethod?: 'api'|'field'|'local',
* rawData?: any
* }
*/
async queryBalance(_account) {
throw new Error('queryBalance 方法必须由子类实现')
}
/**
* 通用 HTTP 请求方法(支持代理)
* @param {string} url
* @param {object} options
* @param {object} account
*/
async makeRequest(url, options = {}, account = {}) {
const config = {
url,
method: options.method || 'GET',
headers: options.headers || {},
timeout: options.timeout || 15000,
data: options.data,
params: options.params,
responseType: options.responseType
}
const proxyConfig = account.proxyConfig || account.proxy
if (proxyConfig) {
const agent = ProxyHelper.createProxyAgent(proxyConfig)
if (agent) {
config.httpAgent = agent
config.httpsAgent = agent
config.proxy = false
}
}
try {
const response = await axios(config)
return {
success: true,
data: response.data,
status: response.status,
headers: response.headers
}
} catch (error) {
const status = error.response?.status
const message = error.response?.data?.message || error.message || '请求失败'
this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, {
status,
message
})
return { success: false, status, error: message }
}
}
/**
* 从账户字段读取 dailyQuota / dailyUsage通用降级方案
* 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准
*/
readQuotaFromFields(account) {
const dailyQuota = Number(account?.dailyQuota || 0)
const dailyUsage = Number(account?.dailyUsage || 0)
// 无限制
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
return {
balance: null,
currency: 'USD',
quota: {
daily: Infinity,
used: Number.isFinite(dailyUsage) ? dailyUsage : 0,
remaining: Infinity,
percentage: 0,
unlimited: true
},
queryMethod: 'field'
}
}
const used = Number.isFinite(dailyUsage) ? dailyUsage : 0
const remaining = Math.max(0, dailyQuota - used)
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
return {
balance: remaining,
currency: 'USD',
quota: {
daily: dailyQuota,
used,
remaining,
percentage: Math.round(percentage * 100) / 100
},
queryMethod: 'field'
}
}
parseCurrency(data) {
return data?.currency || data?.Currency || 'USD'
}
async safeExecute(fn, fallbackValue = null) {
try {
return await fn()
} catch (error) {
this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error)
return fallbackValue
}
}
}
module.exports = BaseBalanceProvider

View File

@@ -0,0 +1,30 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
const claudeAccountService = require('../claudeAccountService')
class ClaudeBalanceProvider extends BaseBalanceProvider {
constructor() {
super('claude')
}
/**
* ClaudeOAuth优先尝试获取 OAuth usage用于配额/使用信息),不强行提供余额金额
*/
async queryBalance(account) {
this.logger.debug(`查询 Claude 余额OAuth usage: ${account?.id}`)
// 仅 OAuth 账户可用;失败时降级
const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null)
if (!usageData) {
return { balance: null, currency: 'USD', queryMethod: 'local' }
}
return {
balance: null,
currency: 'USD',
queryMethod: 'api',
rawData: usageData
}
}
}
module.exports = ClaudeBalanceProvider

View File

@@ -0,0 +1,14 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
class ClaudeConsoleBalanceProvider extends BaseBalanceProvider {
constructor() {
super('claude-console')
}
async queryBalance(account) {
this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`)
return this.readQuotaFromFields(account)
}
}
module.exports = ClaudeConsoleBalanceProvider

View File

@@ -0,0 +1,23 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
class GenericBalanceProvider extends BaseBalanceProvider {
constructor(platform) {
super(platform)
}
async queryBalance(account) {
this.logger.debug(`${this.platform} 暂无专用余额 API实现降级策略`)
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
return this.readQuotaFromFields(account)
}
return {
balance: null,
currency: 'USD',
queryMethod: 'local'
}
}
}
module.exports = GenericBalanceProvider

View File

@@ -0,0 +1,24 @@
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
const GenericBalanceProvider = require('./genericBalanceProvider')
function registerAllProviders(balanceService) {
// Claude
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
// OpenAI / Codex
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
// 其他平台(降级)
balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini'))
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
}
module.exports = { registerAllProviders }

View File

@@ -0,0 +1,54 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
class OpenAIResponsesBalanceProvider extends BaseBalanceProvider {
constructor() {
super('openai-responses')
}
/**
* OpenAI-Responses
* - 优先使用 dailyQuota 字段(如果配置了额度)
* - 可选:尝试调用兼容 API不同服务商实现不一失败自动降级
*/
async queryBalance(account) {
this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`)
// 配置了额度时直接返回(字段法)
if (account?.dailyQuota && Number(account.dailyQuota) > 0) {
return this.readQuotaFromFields(account)
}
// 尝试调用 usage 接口(兼容性不保证)
if (account?.apiKey && account?.baseApi) {
const baseApi = String(account.baseApi).replace(/\/$/, '')
const response = await this.makeRequest(
`${baseApi}/v1/usage`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${account.apiKey}`,
'Content-Type': 'application/json'
}
},
account
)
if (response.success) {
return {
balance: null,
currency: this.parseCurrency(response.data),
queryMethod: 'api',
rawData: response.data
}
}
}
return {
balance: null,
currency: 'USD',
queryMethod: 'local'
}
}
}
module.exports = OpenAIResponsesBalanceProvider

View File

@@ -0,0 +1,161 @@
const vm = require('vm')
const axios = require('axios')
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
/**
* 可配置脚本余额查询执行器
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
*/
class BalanceScriptService {
/**
* 执行脚本:返回标准余额结构 + 原始响应
* @param {object} options
* - scriptBody: string
* - variables: Record<string,string>
* - timeoutSeconds: number
*/
async execute(options = {}) {
if (!isBalanceScriptEnabled()) {
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
error.code = 'BALANCE_SCRIPT_DISABLED'
throw error
}
const scriptBody = options.scriptBody?.trim()
if (!scriptBody) {
throw new Error('脚本内容为空')
}
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
const sandbox = {
console,
Math,
Date
}
let scriptResult
try {
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
const script = new vm.Script(wrapped)
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
} catch (error) {
throw new Error(`脚本解析失败: ${error.message}`)
}
if (!scriptResult || typeof scriptResult !== 'object') {
throw new Error('脚本返回格式无效(需返回 { request, extractor }')
}
const variables = options.variables || {}
const request = this.applyTemplates(scriptResult.request || {}, variables)
const { extractor } = scriptResult
if (!request?.url || typeof request.url !== 'string') {
throw new Error('脚本 request.url 不能为空')
}
if (typeof extractor !== 'function') {
throw new Error('脚本 extractor 必须是函数')
}
const axiosConfig = {
url: request.url,
method: (request.method || 'GET').toUpperCase(),
headers: request.headers || {},
timeout: timeoutMs
}
if (request.params) {
axiosConfig.params = request.params
}
if (request.body || request.data) {
axiosConfig.data = request.body || request.data
}
let httpResponse
try {
httpResponse = await axios(axiosConfig)
} catch (error) {
const { response } = error || {}
const { status, data } = response || {}
throw new Error(
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
)
}
const responseData = httpResponse?.data
let extracted = {}
try {
extracted = extractor(responseData) || {}
} catch (error) {
throw new Error(`extractor 执行失败: ${error.message}`)
}
const mapped = this.mapExtractorResult(extracted, responseData)
return {
mapped,
extracted,
response: {
status: httpResponse?.status,
headers: httpResponse?.headers,
data: responseData
}
}
}
applyTemplates(value, variables) {
if (typeof value === 'string') {
return value.replace(/{{(\w+)}}/g, (_, key) => {
const trimmed = key.trim()
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
})
}
if (Array.isArray(value)) {
return value.map((item) => this.applyTemplates(item, variables))
}
if (value && typeof value === 'object') {
const result = {}
Object.keys(value).forEach((k) => {
result[k] = this.applyTemplates(value[k], variables)
})
return result
}
return value
}
mapExtractorResult(result = {}, responseData) {
const isValid = result.isValid !== false
const remaining = Number(result.remaining)
const total = Number(result.total)
const used = Number(result.used)
const currency = result.unit || 'USD'
const quota =
Number.isFinite(total) || Number.isFinite(used)
? {
total: Number.isFinite(total) ? total : null,
used: Number.isFinite(used) ? used : null,
remaining: Number.isFinite(remaining) ? remaining : null,
percentage:
Number.isFinite(total) && total > 0 && Number.isFinite(used)
? (used / total) * 100
: null
}
: null
return {
status: isValid ? 'success' : 'error',
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
balance: Number.isFinite(remaining) ? remaining : null,
currency,
quota,
planName: result.planName || null,
extra: result.extra || null,
rawData: responseData || result.raw
}
}
}
module.exports = new BalanceScriptService()

View File

@@ -6,6 +6,7 @@ const {
const { fromEnv } = require('@aws-sdk/credential-providers')
const logger = require('../utils/logger')
const config = require('../../config/config')
const userMessageQueueService = require('./userMessageQueueService')
class BedrockRelayService {
constructor() {
@@ -69,7 +70,68 @@ class BedrockRelayService {
// 处理非流式请求
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
const accountId = bedrockAccount?.id
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in Bedrock handleNonStreamRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for Bedrock account ${accountId}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'x-user-message-queue-error': errorType
},
body: JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
}),
success: false
}
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
}
}
const modelId = this._selectModel(requestBody, bedrockAccount)
const region = this._selectRegion(modelId, bedrockAccount)
const client = this._getBedrockClient(region, bedrockAccount)
@@ -90,6 +152,23 @@ class BedrockRelayService {
const response = await client.send(command)
const duration = Date.now() - startTime
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for Bedrock account ${accountId}:`,
releaseError.message
)
}
}
// 解析响应
const responseBody = JSON.parse(new TextDecoder().decode(response.body))
const claudeResponse = this._convertFromBedrockFormat(responseBody)
@@ -106,12 +185,94 @@ class BedrockRelayService {
} catch (error) {
logger.error('❌ Bedrock非流式请求失败:', error)
throw this._handleBedrockError(error)
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for Bedrock account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for Bedrock account ${accountId}:`,
releaseError.message
)
}
}
}
}
// 处理流式请求
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
const accountId = bedrockAccount?.id
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in Bedrock handleStreamRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
stream: true,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for Bedrock account ${accountId} (stream)`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!res.headersSent) {
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
res.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
const errorEvent = `event: error\ndata: ${JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
})}\n\n`
res.write(errorEvent)
res.write('data: [DONE]\n\n')
res.end()
return { success: false, error: errorType }
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
const modelId = this._selectModel(requestBody, bedrockAccount)
const region = this._selectRegion(modelId, bedrockAccount)
const client = this._getBedrockClient(region, bedrockAccount)
@@ -131,11 +292,35 @@ class BedrockRelayService {
const startTime = Date.now()
const response = await client.send(command)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for Bedrock stream account ${accountId}:`,
releaseError.message
)
}
}
// 设置SSE响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
const existingConnection = res.getHeader ? res.getHeader('Connection') : null
if (existingConnection) {
logger.debug(
`🔌 [Bedrock Stream] Preserving existing Connection header: ${existingConnection}`
)
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
})
@@ -191,6 +376,21 @@ class BedrockRelayService {
res.end()
throw this._handleBedrockError(error)
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`,
releaseError.message
)
}
}
}
}

View File

@@ -3,6 +3,8 @@ const ccrAccountService = require('./ccrAccountService')
const logger = require('../utils/logger')
const config = require('../../config/config')
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
class CcrRelayService {
constructor() {
@@ -21,8 +23,67 @@ class CcrRelayService {
) {
let abortController = null
let account = null
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in CCR relayRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for CCR account ${accountId}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'x-user-message-queue-error': errorType
},
body: JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
}),
accountId
}
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await ccrAccountService.getAccount(accountId)
if (!account) {
@@ -162,6 +223,23 @@ class CcrRelayService {
)
const response = await axios(requestConfig)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for CCR account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for CCR account ${accountId}:`,
releaseError.message
)
}
}
// 移除监听器(请求成功完成)
if (clientRequest) {
clientRequest.removeListener('close', handleClientDisconnect)
@@ -233,6 +311,21 @@ class CcrRelayService {
)
throw error
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for CCR account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for CCR account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -248,7 +341,77 @@ class CcrRelayService {
options = {}
) {
let account = null
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error(
'❌ accountId missing for queue lock in CCR relayStreamRequestWithUsageCapture'
)
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志用于后续<E5908E><E7BBAD>
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
stream: true,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for CCR account ${accountId} (stream)`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
const errorEvent = `event: error\ndata: ${JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
})}\n\n`
responseStream.write(errorEvent)
responseStream.write('data: [DONE]\n\n')
responseStream.end()
return
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await ccrAccountService.getAccount(accountId)
if (!account) {
@@ -296,14 +459,53 @@ class CcrRelayService {
accountId,
usageCallback,
streamTransformer,
options
options,
// 📬 回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for CCR stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for CCR stream account ${accountId}:`,
releaseError.message
)
}
}
}
)
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(
`🔌 CCR stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
)
} else {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
}
throw error
} finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for CCR stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for CCR stream account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -317,7 +519,8 @@ class CcrRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -380,8 +583,11 @@ class CcrRelayService {
// 发送请求
const request = axios(requestConfig)
// 注意:使用 .then(async ...) 模式处理响应
// - 内部的 releaseQueueLock 有独立的 try-catch不会导致未捕获异常
// - queueLockAcquired = false 的赋值会在 finally 执行前完成JS 单线程保证)
request
.then((response) => {
.then(async (response) => {
logger.debug(`🌊 CCR stream response status: ${response.status}`)
// 错误响应处理
@@ -404,10 +610,13 @@ class CcrRelayService {
// 设置错误响应的状态码和响应头
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
const errorHeaders = {
'Content-Type': response.headers['content-type'] || 'application/json',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
}
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
delete errorHeaders['Transfer-Encoding']
@@ -417,13 +626,13 @@ class CcrRelayService {
// 直接透传错误数据,不进行包装
response.data.on('data', (chunk) => {
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(chunk)
}
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.end()
}
resolve() // 不抛出异常,正常完成流处理
@@ -431,6 +640,19 @@ class CcrRelayService {
return
}
// 📬 收到成功响应头HTTP 200调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
try {
await onResponseHeaderReceived()
} catch (callbackError) {
logger.error(
`❌ Failed to execute onResponseHeaderReceived callback for CCR stream account ${accountId}:`,
callbackError.message
)
}
}
// 成功响应,检查并移除错误状态
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
@@ -444,11 +666,20 @@ class CcrRelayService {
})
// 设置响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
if (existingConnection) {
logger.debug(
`🔌 [CCR Stream] Preserving existing Connection header: ${existingConnection}`
)
}
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: existingConnection || 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
}
@@ -487,12 +718,17 @@ class CcrRelayService {
}
// 写入到响应流
if (outputLine && !responseStream.destroyed) {
if (outputLine && isStreamWritable(responseStream)) {
responseStream.write(`${outputLine}\n`)
} else if (outputLine) {
// 客户端连接已断开,记录警告
logger.warn(
`⚠️ [CCR] Client disconnected during stream, skipping data for account: ${accountId}`
)
}
} else {
// 空行也需要传递
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write('\n')
}
}
@@ -503,10 +739,6 @@ class CcrRelayService {
})
response.data.on('end', () => {
if (!responseStream.destroyed) {
responseStream.end()
}
// 如果收集到使用统计数据,调用回调
if (usageCallback && Object.keys(collectedUsage).length > 0) {
try {
@@ -518,12 +750,26 @@ class CcrRelayService {
}
}
resolve()
if (isStreamWritable(responseStream)) {
// 等待数据完全 flush 到客户端后再 resolve
responseStream.end(() => {
logger.debug(
`🌊 CCR stream response completed and flushed | bytesWritten: ${responseStream.bytesWritten || 'unknown'}`
)
resolve()
})
} else {
// 连接已断开,记录警告
logger.warn(
`⚠️ [CCR] Client disconnected before stream end, data may not have been received | account: ${accountId}`
)
resolve()
}
})
response.data.on('error', (err) => {
logger.error('❌ Stream data error:', err)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.end()
}
reject(err)
@@ -555,7 +801,7 @@ class CcrRelayService {
}
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
responseStream.end()
}

View File

@@ -16,6 +16,22 @@ const {
const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache')
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 {
constructor() {
@@ -75,7 +91,9 @@ class ClaudeAccountService {
useUnifiedClientId = false, // 是否使用统一的客户端标识
unifiedClientId = '', // 统一的客户端标识
expiresAt = null, // 账户订阅到期时间
extInfo = null // 额外扩展信息
extInfo = null, // 额外扩展信息
maxConcurrency = 0, // 账户级用户消息串行队列0=使用全局配置,>0=强制启用串行
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options
const accountId = uuidv4()
@@ -120,7 +138,11 @@ class ClaudeAccountService {
// 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '',
// 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
}
} else {
// 兼容旧格式
@@ -152,7 +174,11 @@ class ClaudeAccountService {
// 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '',
// 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
}
}
@@ -200,7 +226,8 @@ class ClaudeAccountService {
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId,
extInfo: normalizedExtInfo
extInfo: normalizedExtInfo,
interceptWarmup
}
}
@@ -558,7 +585,11 @@ class ClaudeAccountService {
// 添加停止原因
stoppedReason: account.stoppedReason || null,
// 扩展信息
extInfo: parsedExtInfo
extInfo: parsedExtInfo,
// 账户级用户消息串行队列限制
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
// 拦截预热请求
interceptWarmup: account.interceptWarmup === 'true'
}
})
)
@@ -650,7 +681,9 @@ class ClaudeAccountService {
'useUnifiedClientId',
'unifiedClientId',
'subscriptionExpiresAt',
'extInfo'
'extInfo',
'maxConcurrency',
'interceptWarmup'
]
const updatedData = { ...accountData }
let shouldClearAutoStopFields = false
@@ -665,7 +698,7 @@ class ClaudeAccountService {
updatedData[field] = this._encryptSensitiveData(value)
} else if (field === 'proxy') {
updatedData[field] = value ? JSON.stringify(value) : ''
} else if (field === 'priority') {
} else if (field === 'priority' || field === 'maxConcurrency') {
updatedData[field] = value.toString()
} else if (field === 'subscriptionInfo') {
// 处理订阅信息更新
@@ -852,31 +885,39 @@ class ClaudeAccountService {
!this.isSubscriptionExpired(account)
)
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
// Filter Opus models based on account type and model version
if (modelName && modelName.toLowerCase().includes('opus')) {
const isNewOpus = isOpus45OrNewer(modelName)
activeAccounts = activeAccounts.filter((account) => {
// 检查账号的订阅信息
if (account.subscriptionInfo) {
try {
const info = JSON.parse(account.subscriptionInfo)
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
return false // Claude Pro 不支持 Opus
// Free account: does not support any Opus model
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) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
// Parse failed, assume legacy data (Max), default support
return true
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
// Account without subscription info, default to supported (legacy data compatibility)
return true
})
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 +1011,39 @@ class ClaudeAccountService {
!this.isSubscriptionExpired(account)
)
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
// Filter Opus models based on account type and model version
if (modelName && modelName.toLowerCase().includes('opus')) {
const isNewOpus = isOpus45OrNewer(modelName)
sharedAccounts = sharedAccounts.filter((account) => {
// 检查账号的订阅信息
if (account.subscriptionInfo) {
try {
const info = JSON.parse(account.subscriptionInfo)
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
return false // Claude Pro 不支持 Opus
// Free account: does not support any Opus model
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) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
// Parse failed, assume legacy data (Max), default support
return true
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
// Account without subscription info, default to supported (legacy data compatibility)
return true
})
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

@@ -67,7 +67,9 @@ class ClaudeConsoleAccountService {
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0 // 最大并发任务数0表示无限制
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false, // 是否关闭自动防护429/401/400/529 不自动禁用)
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options
// 验证必填字段
@@ -115,7 +117,9 @@ class ClaudeConsoleAccountService {
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数0表示无限制
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
}
const client = redis.getClientSafe()
@@ -153,6 +157,8 @@ class ClaudeConsoleAccountService {
quotaResetTime,
quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关
interceptWarmup, // 新增:返回预热请求拦截开关
activeTaskCount: 0 // 新增新建账户当前并发数为0
}
}
@@ -213,7 +219,10 @@ class ClaudeConsoleAccountService {
// 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount
activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true',
// 拦截预热请求
interceptWarmup: accountData.interceptWarmup === 'true'
})
}
}
@@ -259,6 +268,7 @@ class ClaudeConsoleAccountService {
}
accountData.isActive = accountData.isActive === 'true'
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
if (accountData.proxy) {
accountData.proxy = JSON.parse(accountData.proxy)
@@ -367,6 +377,12 @@ class ClaudeConsoleAccountService {
if (updates.maxConcurrentTasks !== undefined) {
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
}
if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
}
if (updates.interceptWarmup !== undefined) {
updatedData.interceptWarmup = updates.interceptWarmup.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段

View File

@@ -9,6 +9,9 @@ const {
sanitizeErrorMessage,
isAccountDisabledError
} = require('../utils/errorSanitizer')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
const { filterForClaude } = require('../utils/headerFilter')
class ClaudeConsoleRelayService {
constructor() {
@@ -29,14 +32,76 @@ class ClaudeConsoleRelayService {
let account = null
const requestId = uuidv4() // 用于并发追踪
let concurrencyAcquired = false
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error('❌ accountId missing for queue lock in console relayRequest')
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
apiKeyName: apiKeyData.name,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for console account ${accountId}, key: ${apiKeyData.name}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'x-user-message-queue-error': errorType
},
body: JSON.stringify({
type: 'error',
error: {
type: errorType,
code: errorCode,
message: errorMessage
}
}),
accountId
}
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
throw new Error('Claude Console Claude account not found')
}
const autoProtectionDisabled = account.disableAutoProtection === true
logger.info(
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
)
@@ -201,6 +266,23 @@ class ClaudeConsoleRelayService {
)
const response = await axios(requestConfig)
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for console account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for console account ${accountId}:`,
releaseError.message
)
}
}
// 移除监听器(请求成功完成)
if (clientRequest) {
clientRequest.removeListener('close', handleClientDisconnect)
@@ -248,27 +330,41 @@ class ClaudeConsoleRelayService {
// 检查错误状态并相应处理
if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
logger.warn(
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
const errorDetails =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
}
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
logger.warn(
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 收到429先检查是否因为超过了手动配置的每日额度
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
await claudeConsoleAccountService.markAccountRateLimited(accountId)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountOverloaded(accountId)
logger.warn(
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
} else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
@@ -350,6 +446,21 @@ class ClaudeConsoleRelayService {
)
}
}
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for console account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -368,8 +479,71 @@ class ClaudeConsoleRelayService {
const requestId = uuidv4() // 用于并发追踪
let concurrencyAcquired = false
let leaseRefreshInterval = null // 租约刷新定时器
let queueLockAcquired = false
let queueRequestId = null
try {
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
// 校验 accountId 非空,避免空值污染队列锁键
if (!accountId || accountId === '') {
logger.error(
'❌ accountId missing for queue lock in console relayStreamRequestWithUsageCapture'
)
throw new Error('accountId missing for queue lock')
}
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error'
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
const errorMessage = isBackendError
? 'Queue service temporarily unavailable, please retry later'
: 'User message queue wait timeout, please retry later'
const statusCode = isBackendError ? 500 : 503
// 结构化性能日志,用于后续统计
logger.performance('user_message_queue_error', {
errorType,
errorCode,
accountId,
statusCode,
stream: true,
apiKeyName: apiKeyData.name,
backendError: isBackendError ? queueResult.errorMessage : undefined
})
logger.warn(
`📬 User message queue ${errorType} for console account ${accountId} (stream), key: ${apiKeyData.name}`,
isBackendError ? { backendError: queueResult.errorMessage } : {}
)
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: existingConnection || 'keep-alive',
'x-user-message-queue-error': errorType
})
}
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: errorType, code: errorCode, message: errorMessage } })}\n\n`
responseStream.write(errorEvent)
responseStream.write('data: [DONE]\n\n')
responseStream.end()
return
}
if (queueResult.acquired && !queueResult.skipped) {
queueLockAcquired = true
queueRequestId = queueResult.requestId
logger.debug(
`📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}`
)
}
}
// 获取账户信息
account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
@@ -467,16 +641,40 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer,
options
options,
// 📬 回调:在收到响应头时释放队列锁
async () => {
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
logger.debug(
`📬 User message queue lock released early for console stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock early for console stream account ${accountId}:`,
releaseError.message
)
}
}
}
)
// 更新最后使用时间
await this._updateLastUsedTime(accountId)
} catch (error) {
logger.error(
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
error
)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(
`🔌 Claude Console stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
)
} else {
logger.error(
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
error
)
}
throw error
} finally {
// 🛑 清理租约刷新定时器
@@ -501,6 +699,21 @@ class ClaudeConsoleRelayService {
)
}
}
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
if (queueLockAcquired && queueRequestId && accountId) {
try {
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
logger.debug(
`📬 User message queue lock released in finally for console stream account ${accountId}, requestId: ${queueRequestId}`
)
} catch (releaseError) {
logger.error(
`❌ Failed to release user message queue lock for stream account ${accountId}:`,
releaseError.message
)
}
}
}
}
@@ -514,7 +727,8 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -577,8 +791,11 @@ class ClaudeConsoleRelayService {
// 发送请求
const request = axios(requestConfig)
// 注意:使用 .then(async ...) 模式处理响应
// - 内部的 releaseQueueLock 有独立的 try-catch不会导致未捕获异常
// - queueLockAcquired = false 的赋值会在 finally 执行前完成JS 单线程保证)
request
.then((response) => {
.then(async (response) => {
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
// 错误响应处理
@@ -597,6 +814,7 @@ class ClaudeConsoleRelayService {
})
response.data.on('end', async () => {
const autoProtectionDisabled = account.disableAutoProtection === true
// 记录原始错误消息到日志(方便调试,包含供应商信息)
logger.error(
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
@@ -609,24 +827,41 @@ class ClaudeConsoleRelayService {
)
if (response.status === 401) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
logger.warn(
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
}
} else if (response.status === 429) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
logger.warn(
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
logger.warn(
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
}
// 设置响应头
@@ -648,7 +883,7 @@ class ClaudeConsoleRelayService {
`🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}`
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(JSON.stringify(sanitizedError))
responseStream.end()
}
@@ -656,7 +891,7 @@ class ClaudeConsoleRelayService {
const sanitizedText = sanitizeErrorMessage(errorDataForCheck)
logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
responseStream.write(sanitizedText)
responseStream.end()
}
@@ -667,6 +902,19 @@ class ClaudeConsoleRelayService {
return
}
// 📬 收到成功响应头HTTP 200调用回调释放队列锁
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
try {
await onResponseHeaderReceived()
} catch (callbackError) {
logger.error(
`❌ Failed to execute onResponseHeaderReceived callback for console stream account ${accountId}:`,
callbackError.message
)
}
}
// 成功响应,检查并移除错误状态
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
if (isRateLimited) {
@@ -680,11 +928,22 @@ class ClaudeConsoleRelayService {
})
// 设置响应头
// ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close
// 当并发队列功能启用时auth.js 会设置 Connection: close 来禁用 Keep-Alive
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
const connectionHeader = existingConnection || 'keep-alive'
if (existingConnection) {
logger.debug(
`🔌 [Console Stream] Preserving existing Connection header: ${existingConnection}`
)
}
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Connection: connectionHeader,
'X-Accel-Buffering': 'no'
})
}
@@ -710,20 +969,33 @@ class ClaudeConsoleRelayService {
buffer = lines.pop() || ''
// 转发数据并解析usage
if (lines.length > 0 && !responseStream.destroyed) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
if (lines.length > 0) {
// 检查流是否可写(客户端连接是否有效)
if (isStreamWritable(responseStream)) {
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
// 应用流转换器如果有
if (streamTransformer) {
const transformed = streamTransformer(linesToForward)
if (transformed) {
responseStream.write(transformed)
// 应用流转换器如果有
let dataToWrite = linesToForward
if (streamTransformer) {
const transformed = streamTransformer(linesToForward)
if (transformed) {
dataToWrite = transformed
} else {
dataToWrite = null
}
}
if (dataToWrite) {
responseStream.write(dataToWrite)
}
} else {
responseStream.write(linesToForward)
// 客户端连接已断开记录警告但仍继续解析usage
logger.warn(
`⚠️ [Console] Client disconnected during stream, skipping ${lines.length} lines for account: ${account?.name || accountId}`
)
}
// 解析SSE数据寻找usage信息
// 解析SSE数据寻找usage信息(无论连接状态如何)
for (const line of lines) {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trimStart()
@@ -831,7 +1103,7 @@ class ClaudeConsoleRelayService {
`❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -854,7 +1126,7 @@ class ClaudeConsoleRelayService {
response.data.on('end', () => {
try {
// 处理缓冲区中剩余的数据
if (buffer.trim() && !responseStream.destroyed) {
if (buffer.trim() && isStreamWritable(responseStream)) {
if (streamTransformer) {
const transformed = streamTransformer(buffer)
if (transformed) {
@@ -903,12 +1175,33 @@ class ClaudeConsoleRelayService {
}
// 确保流正确结束
if (!responseStream.destroyed) {
responseStream.end()
}
if (isStreamWritable(responseStream)) {
// 📊 诊断日志:流结束前状态
logger.info(
`📤 [STREAM] Ending response | destroyed: ${responseStream.destroyed}, ` +
`socketDestroyed: ${responseStream.socket?.destroyed}, ` +
`socketBytesWritten: ${responseStream.socket?.bytesWritten || 0}`
)
logger.debug('🌊 Claude Console Claude stream response completed')
resolve()
// 禁用 Nagle 算法确保数据立即发送
if (responseStream.socket && !responseStream.socket.destroyed) {
responseStream.socket.setNoDelay(true)
}
// 等待数据完全 flush 到客户端后再 resolve
responseStream.end(() => {
logger.info(
`✅ [STREAM] Response ended and flushed | socketBytesWritten: ${responseStream.socket?.bytesWritten || 'unknown'}`
)
resolve()
})
} else {
// 连接已断开,记录警告
logger.warn(
`⚠️ [Console] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}`
)
resolve()
}
} catch (error) {
logger.error('❌ Error processing stream end:', error)
reject(error)
@@ -920,7 +1213,7 @@ class ClaudeConsoleRelayService {
`❌ Claude Console stream error (Account: ${account?.name || accountId}):`,
error
)
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -968,14 +1261,17 @@ class ClaudeConsoleRelayService {
// 发送错误响应
if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader
? responseStream.getHeader('Connection')
: null
responseStream.writeHead(error.response?.status || 500, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
Connection: existingConnection || 'keep-alive'
})
}
if (!responseStream.destroyed) {
if (isStreamWritable(responseStream)) {
// 如果有 streamTransformer如测试请求使用前端期望的格式
if (streamTransformer) {
responseStream.write(
@@ -1007,30 +1303,9 @@ class ClaudeConsoleRelayService {
// 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
const sensitiveHeaders = [
'content-type',
'user-agent',
'authorization',
'x-api-key',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
'anthropic-version'
]
const filteredHeaders = {}
Object.keys(clientHeaders || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!sensitiveHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
})
return filteredHeaders
// 使用统一的 headerFilter 工具类(白名单模式)
// 与 claudeRelayService 保持一致,避免透传 CDN headers 触发上游 API 安全检查
return filterForClaude(clientHeaders)
}
// 🕐 更新最后使用时间
@@ -1145,7 +1420,7 @@ class ClaudeConsoleRelayService {
'Cache-Control': 'no-cache'
})
}
if (!responseStream.destroyed && !responseStream.writableEnded) {
if (isStreamWritable(responseStream)) {
responseStream.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
)

View File

@@ -0,0 +1,453 @@
/**
* Claude 转发配置服务
* 管理全局 Claude Code 限制和会话绑定配置
*/
const redis = require('../models/redis')
const logger = require('../utils/logger')
const CONFIG_KEY = 'claude_relay_config'
const SESSION_BINDING_PREFIX = 'original_session_binding:'
// 默认配置
const DEFAULT_CONFIG = {
claudeCodeOnlyEnabled: false,
globalSessionBindingEnabled: false,
sessionBindingErrorMessage: '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: 30, // 会话绑定 TTL默认30天
// 用户消息队列配置
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
userMessageQueueLockTtlMs: 5000, // 锁TTL毫秒请求发送后立即释放无需长TTL
// 并发请求排队配置
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
concurrentRequestQueueMaxSize: 3, // 固定最小排队数默认3
concurrentRequestQueueMaxSizeMultiplier: 0, // 并发数的倍数默认0仅使用固定值
concurrentRequestQueueTimeoutMs: 10000, // 排队超时毫秒默认10秒
concurrentRequestQueueMaxRedisFailCount: 5, // 连续 Redis 失败阈值默认5次
// 排队健康检查配置
concurrentRequestQueueHealthCheckEnabled: true, // 是否启用排队健康检查(默认开启)
concurrentRequestQueueHealthThreshold: 0.8, // 健康检查阈值P90 >= 超时 × 阈值时拒绝新请求)
updatedAt: null,
updatedBy: null
}
// 内存缓存(避免频繁 Redis 查询)
let configCache = null
let configCacheTime = 0
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
class ClaudeRelayConfigService {
/**
* 从 metadata.user_id 中提取原始 sessionId
* 格式: user_{64位十六进制}_account__session_{uuid}
* @param {Object} requestBody - 请求体
* @returns {string|null} 原始 sessionId 或 null
*/
extractOriginalSessionId(requestBody) {
if (!requestBody?.metadata?.user_id) {
return null
}
const userId = requestBody.metadata.user_id
const match = userId.match(/session_([a-f0-9-]{36})$/i)
return match ? match[1] : null
}
/**
* 获取配置(带缓存)
* @returns {Promise<Object>} 配置对象
*/
async getConfig() {
try {
// 检查缓存
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
return configCache
}
const client = redis.getClient()
if (!client) {
logger.warn('⚠️ Redis not connected, using default config')
return { ...DEFAULT_CONFIG }
}
const data = await client.get(CONFIG_KEY)
if (data) {
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
} else {
configCache = { ...DEFAULT_CONFIG }
}
configCacheTime = Date.now()
return configCache
} catch (error) {
logger.error('❌ Failed to get Claude relay config:', error)
return { ...DEFAULT_CONFIG }
}
}
/**
* 更新配置
* @param {Object} newConfig - 新配置
* @param {string} updatedBy - 更新者
* @returns {Promise<Object>} 更新后的配置
*/
async updateConfig(newConfig, updatedBy) {
try {
const client = redis.getClientSafe()
const currentConfig = await this.getConfig()
const updatedConfig = {
...currentConfig,
...newConfig,
updatedAt: new Date().toISOString(),
updatedBy
}
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
// 更新缓存
configCache = updatedConfig
configCacheTime = Date.now()
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled,
concurrentRequestQueueEnabled: updatedConfig.concurrentRequestQueueEnabled
})
return updatedConfig
} catch (error) {
logger.error('❌ Failed to update Claude relay config:', error)
throw error
}
}
/**
* 检查是否启用全局 Claude Code 限制
* @returns {Promise<boolean>}
*/
async isClaudeCodeOnlyEnabled() {
const cfg = await this.getConfig()
return cfg.claudeCodeOnlyEnabled === true
}
/**
* 检查是否启用全局会话绑定
* @returns {Promise<boolean>}
*/
async isGlobalSessionBindingEnabled() {
const cfg = await this.getConfig()
return cfg.globalSessionBindingEnabled === true
}
/**
* 获取会话绑定错误信息
* @returns {Promise<string>}
*/
async getSessionBindingErrorMessage() {
const cfg = await this.getConfig()
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
}
/**
* 获取原始会话绑定
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<Object|null>} 绑定信息或 null
*/
async getOriginalSessionBinding(originalSessionId) {
if (!originalSessionId) {
return null
}
try {
const client = redis.getClient()
if (!client) {
return null
}
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
const data = await client.get(key)
if (data) {
return JSON.parse(data)
}
return null
} catch (error) {
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
return null
}
}
/**
* 设置原始会话绑定
* @param {string} originalSessionId - 原始会话ID
* @param {string} accountId - 账户ID
* @param {string} accountType - 账户类型
* @returns {Promise<Object>} 绑定信息
*/
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
if (!originalSessionId || !accountId || !accountType) {
throw new Error('Invalid parameters for session binding')
}
try {
const client = redis.getClientSafe()
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
const binding = {
accountId,
accountType,
createdAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString()
}
// 使用配置的 TTL默认30天
const cfg = await this.getConfig()
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
logger.info(
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
)
return binding
} catch (error) {
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
throw error
}
}
/**
* 更新会话绑定的最后使用时间(续期)
* @param {string} originalSessionId - 原始会话ID
*/
async touchOriginalSessionBinding(originalSessionId) {
if (!originalSessionId) {
return
}
try {
const binding = await this.getOriginalSessionBinding(originalSessionId)
if (!binding) {
return
}
binding.lastUsedAt = new Date().toISOString()
const client = redis.getClientSafe()
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
// 使用配置的 TTL默认30天
const cfg = await this.getConfig()
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
} catch (error) {
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
}
}
/**
* 检查原始会话是否已绑定
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<boolean>}
*/
async isOriginalSessionBound(originalSessionId) {
const binding = await this.getOriginalSessionBinding(originalSessionId)
return binding !== null
}
/**
* 验证绑定的账户是否可用
* @param {Object} binding - 绑定信息
* @returns {Promise<boolean>}
*/
async validateBoundAccount(binding) {
if (!binding || !binding.accountId || !binding.accountType) {
return false
}
try {
const { accountType } = binding
const { accountId } = binding
let accountService
switch (accountType) {
case 'claude-official':
accountService = require('./claudeAccountService')
break
case 'claude-console':
accountService = require('./claudeConsoleAccountService')
break
case 'bedrock':
accountService = require('./bedrockAccountService')
break
case 'ccr':
accountService = require('./ccrAccountService')
break
default:
logger.warn(`Unknown account type for validation: ${accountType}`)
return false
}
const account = await accountService.getAccount(accountId)
// getAccount() 直接返回账户数据对象或 null不是 { success, data } 格式
if (!account) {
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
return false
}
const accountData = account
// 检查账户是否激活
if (accountData.isActive === false || accountData.isActive === 'false') {
logger.warn(
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
)
return false
}
// 检查账户状态(如果存在)
if (accountData.status && accountData.status === 'error') {
logger.warn(
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
)
return false
}
return true
} catch (error) {
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
return false
}
}
/**
* 验证新会话请求
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
* @param {string} originalSessionId - 原始会话ID
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
*/
async validateNewSession(_requestBody, originalSessionId) {
const cfg = await this.getConfig()
if (!cfg.globalSessionBindingEnabled) {
return { valid: true }
}
// 如果没有 sessionId跳过验证可能是非 Claude Code 客户端)
if (!originalSessionId) {
return { valid: true }
}
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
// 如果会话已存在绑定
if (existingBinding) {
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
// 其他类型bedrock, ccr, claude-console等忽略绑定走正常调度
if (existingBinding.accountType !== 'claude-official') {
logger.info(
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
)
return { valid: true }
}
const accountValid = await this.validateBoundAccount(existingBinding)
if (!accountValid) {
return {
valid: false,
error: cfg.sessionBindingErrorMessage,
code: 'SESSION_BINDING_INVALID'
}
}
// 续期
await this.touchOriginalSessionBinding(originalSessionId)
// 已有绑定,允许继续(这是正常的会话延续)
return { valid: true, binding: existingBinding }
}
// 没有绑定,是新会话
// 注意messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
// 绑定会在调度后创建,仅针对 claude-official 账户
return { valid: true, isNewSession: true }
}
/**
* 删除原始会话绑定
* @param {string} originalSessionId - 原始会话ID
*/
async deleteOriginalSessionBinding(originalSessionId) {
if (!originalSessionId) {
return
}
try {
const client = redis.getClient()
if (!client) {
return
}
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
await client.del(key)
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
} catch (error) {
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
}
}
/**
* 获取会话绑定统计
* @returns {Promise<Object>}
*/
async getSessionBindingStats() {
try {
const client = redis.getClient()
if (!client) {
return { totalBindings: 0 }
}
let cursor = '0'
let count = 0
do {
const [newCursor, keys] = await client.scan(
cursor,
'MATCH',
`${SESSION_BINDING_PREFIX}*`,
'COUNT',
100
)
cursor = newCursor
count += keys.length
} while (cursor !== '0')
return {
totalBindings: count
}
} catch (error) {
logger.error('❌ Failed to get session binding stats:', error)
return { totalBindings: 0 }
}
}
/**
* 清除配置缓存(用于测试或强制刷新)
*/
clearCache() {
configCache = null
configCacheTime = 0
}
}
module.exports = new ClaudeRelayConfigService()

File diff suppressed because it is too large Load Diff

View File

@@ -556,7 +556,8 @@ class DroidAccountService {
tokenType = 'Bearer',
authenticationMethod = '',
expiresIn = null,
apiKeys = []
apiKeys = [],
userAgent = '' // 自定义 User-Agent
} = options
const accountId = uuidv4()
@@ -832,7 +833,8 @@ class DroidAccountService {
: '',
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
apiKeyStrategy: hasApiKeys ? 'random_sticky' : ''
apiKeyStrategy: hasApiKeys ? 'random_sticky' : '',
userAgent: userAgent || '' // 自定义 User-Agent
}
await redis.setDroidAccount(accountId, accountData)
@@ -931,6 +933,11 @@ class DroidAccountService {
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
}
// 处理 userAgent 字段
if (typeof sanitizedUpdates.userAgent === 'string') {
sanitizedUpdates.userAgent = sanitizedUpdates.userAgent.trim()
}
const parseProxyConfig = (value) => {
if (!value) {
return null

View File

@@ -26,7 +26,7 @@ class DroidRelayService {
comm: '/o/v1/chat/completions'
}
this.userAgent = 'factory-cli/0.19.12'
this.userAgent = 'factory-cli/0.32.1'
this.systemPrompt = SYSTEM_PROMPT
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
}
@@ -241,7 +241,8 @@ class DroidRelayService {
accessToken,
normalizedRequestBody,
normalizedEndpoint,
clientHeaders
clientHeaders,
account
)
if (selectedApiKey) {
@@ -335,7 +336,12 @@ class DroidRelayService {
)
}
} catch (error) {
logger.error(`❌ Droid relay error: ${error.message}`, error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info(`🔌 Droid relay ended: Client disconnected`)
} else {
logger.error(`❌ Droid relay error: ${error.message}`, error)
}
const status = error?.response?.status
if (status >= 400 && status < 500) {
@@ -633,7 +639,7 @@ class DroidRelayService {
// 客户端断开连接时清理
clientResponse.on('close', () => {
if (req && !req.destroyed) {
req.destroy()
req.destroy(new Error('Client disconnected'))
}
})
@@ -737,6 +743,14 @@ class DroidRelayService {
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)
}
@@ -758,6 +772,14 @@ class DroidRelayService {
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)
}
} catch (parseError) {
@@ -966,11 +988,13 @@ class DroidRelayService {
/**
* 构建请求头
*/
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) {
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}, account = null) {
// 使用账户配置的 userAgent 或默认值
const userAgent = account?.userAgent || this.userAgent
const headers = {
'content-type': 'application/json',
authorization: `Bearer ${accessToken}`,
'user-agent': this.userAgent,
'user-agent': userAgent,
'x-factory-client': 'cli',
connection: 'keep-alive'
}
@@ -987,9 +1011,15 @@ class DroidRelayService {
}
}
// OpenAI 特定头
// OpenAI 特定头 - 根据模型动态选择 provider
if (endpointType === 'openai') {
headers['x-api-provider'] = 'azure_openai'
const model = (requestBody?.model || '').toLowerCase()
// -max 模型使用 openai provider其他使用 azure_openai
if (model.includes('-max')) {
headers['x-api-provider'] = 'openai'
} else {
headers['x-api-provider'] = 'azure_openai'
}
}
// Comm 端点根据模型动态设置 provider

View File

@@ -16,11 +16,62 @@ const {
} = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache')
const antigravityClient = require('./antigravityClient')
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
const OAUTH_CLIENT_SECRET = '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'
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
const OAUTH_PROVIDERS = {
[OAUTH_PROVIDER_GEMINI_CLI]: {
// Gemini CLI OAuth 配置(公开)
clientId:
process.env.GEMINI_OAUTH_CLIENT_ID ||
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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',
scopes: ['https://www.googleapis.com/auth/cloud-platform']
},
[OAUTH_PROVIDER_ANTIGRAVITY]: {
// Antigravity OAuth 配置(参考 gcli2api
clientId:
process.env.ANTIGRAVITY_OAUTH_CLIENT_ID ||
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
clientSecret:
process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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',
scopes: [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs'
]
}
}
if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) {
logger.warn(
'⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
)
}
if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) {
logger.warn(
'⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
)
}
function normalizeOauthProvider(oauthProvider) {
if (!oauthProvider) {
return OAUTH_PROVIDER_GEMINI_CLI
}
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
? OAUTH_PROVIDER_ANTIGRAVITY
: OAUTH_PROVIDER_GEMINI_CLI
}
function getOauthProviderConfig(oauthProvider) {
const normalized = normalizeOauthProvider(oauthProvider)
return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI]
}
// 🌐 TCP Keep-Alive Agent 配置
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
@@ -34,6 +85,117 @@ const keepAliveAgent = new https.Agent({
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
async function fetchAvailableModelsAntigravity(
accessToken,
proxyConfig = null,
refreshToken = null
) {
try {
let effectiveToken = accessToken
if (refreshToken) {
try {
const client = await getOauthClient(
accessToken,
refreshToken,
proxyConfig,
OAUTH_PROVIDER_ANTIGRAVITY
)
if (client && client.getAccessToken) {
const latest = await client.getAccessToken()
if (latest?.token) {
effectiveToken = latest.token
}
}
} catch (error) {
logger.warn('Failed to refresh Antigravity access token for models list:', {
message: error.message
})
}
}
const data = await antigravityClient.fetchAvailableModels({
accessToken: effectiveToken,
proxyConfig
})
const modelsDict = data?.models
const created = Math.floor(Date.now() / 1000)
const models = []
const seen = new Set()
const {
getAntigravityModelAlias,
getAntigravityModelMetadata,
normalizeAntigravityModelInput
} = require('../utils/antigravityModel')
const pushModel = (modelId) => {
if (!modelId || seen.has(modelId)) {
return
}
seen.add(modelId)
const metadata = getAntigravityModelMetadata(modelId)
const entry = {
id: modelId,
object: 'model',
created,
owned_by: 'antigravity'
}
if (metadata?.name) {
entry.name = metadata.name
}
if (metadata?.maxCompletionTokens) {
entry.max_completion_tokens = metadata.maxCompletionTokens
}
if (metadata?.thinking) {
entry.thinking = metadata.thinking
}
models.push(entry)
}
if (modelsDict && typeof modelsDict === 'object') {
for (const modelId of Object.keys(modelsDict)) {
const normalized = normalizeAntigravityModelInput(modelId)
const alias = getAntigravityModelAlias(normalized)
if (!alias) {
continue
}
pushModel(alias)
if (alias.endsWith('-thinking')) {
pushModel(alias.replace(/-thinking$/, ''))
}
if (alias.startsWith('gemini-claude-')) {
pushModel(alias.replace(/^gemini-/, ''))
}
}
}
return models
} catch (error) {
logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message)
return [
{
id: 'gemini-2.5-flash',
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: 'antigravity'
}
]
}
}
async function countTokensAntigravity(client, contents, model, proxyConfig = null) {
const { token } = await client.getAccessToken()
const response = await antigravityClient.countTokens({
accessToken: token,
proxyConfig,
contents,
model
})
return response
}
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'gemini-account-salt'
@@ -124,14 +286,15 @@ setInterval(
)
// 创建 OAuth2 客户端(支持代理配置)
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) {
// 如果没有提供 redirectUri使用默认值
const uri = redirectUri || 'http://localhost:45462'
const oauthConfig = getOauthProviderConfig(oauthProvider)
// 准备客户端选项
const clientOptions = {
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
clientId: oauthConfig.clientId,
clientSecret: oauthConfig.clientSecret,
redirectUri: uri
}
@@ -152,10 +315,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
}
// 生成授权 URL (支持 PKCE 和代理)
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
async function generateAuthUrl(
state = null,
redirectUri = null,
proxyConfig = null,
oauthProvider = null
) {
// 使用新的 redirect URI
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider)
if (proxyConfig) {
logger.info(
@@ -172,7 +342,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
const authUrl = oAuth2Client.generateAuthUrl({
redirect_uri: finalRedirectUri,
access_type: 'offline',
scope: OAUTH_SCOPES,
scope: oauthConfig.scopes,
code_challenge_method: 'S256',
code_challenge: codeVerifier.codeChallenge,
state: stateValue,
@@ -183,7 +353,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
authUrl,
state: stateValue,
codeVerifier: codeVerifier.codeVerifier,
redirectUri: finalRedirectUri
redirectUri: finalRedirectUri,
oauthProvider: normalizedProvider
}
}
@@ -244,11 +415,14 @@ async function exchangeCodeForTokens(
code,
redirectUri = null,
codeVerifier = null,
proxyConfig = null
proxyConfig = null,
oauthProvider = null
) {
try {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider)
if (proxyConfig) {
logger.info(
@@ -274,7 +448,7 @@ async function exchangeCodeForTokens(
return {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scope: tokens.scope || OAUTH_SCOPES.join(' '),
scope: tokens.scope || oauthConfig.scopes.join(' '),
token_type: tokens.token_type || 'Bearer',
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
}
@@ -285,9 +459,11 @@ async function exchangeCodeForTokens(
}
// 刷新访问令牌
async function refreshAccessToken(refreshToken, proxyConfig = null) {
async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(null, proxyConfig)
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider)
try {
// 设置 refresh_token
@@ -319,7 +495,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
return {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
scope: credentials.scope || OAUTH_SCOPES.join(' '),
scope: credentials.scope || oauthConfig.scopes.join(' '),
token_type: credentials.token_type || 'Bearer',
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
}
@@ -339,6 +515,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
async function createAccount(accountData) {
const id = uuidv4()
const now = new Date().toISOString()
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
const oauthConfig = getOauthProviderConfig(oauthProvider)
// 处理凭证数据
let geminiOauth = null
@@ -371,7 +549,7 @@ async function createAccount(accountData) {
geminiOauth = JSON.stringify({
access_token: accessToken,
refresh_token: refreshToken,
scope: accountData.scope || OAUTH_SCOPES.join(' '),
scope: accountData.scope || oauthConfig.scopes.join(' '),
token_type: accountData.tokenType || 'Bearer',
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
})
@@ -399,7 +577,8 @@ async function createAccount(accountData) {
refreshToken: refreshToken ? encrypt(refreshToken) : '',
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
// 只有OAuth方式才有scopes手动添加的没有
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '',
oauthProvider,
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
@@ -508,6 +687,10 @@ async function updateAccount(accountId, updates) {
updates.schedulable = updates.schedulable.toString()
}
if (updates.oauthProvider !== undefined) {
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
}
// 加密敏感字段
if (updates.geminiOauth) {
updates.geminiOauth = encrypt(
@@ -885,12 +1068,13 @@ async function refreshAccountToken(accountId) {
// 重新获取账户数据(可能已被其他进程刷新)
const updatedAccount = await getAccount(accountId)
if (updatedAccount && updatedAccount.accessToken) {
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
const accessToken = decrypt(updatedAccount.accessToken)
return {
access_token: accessToken,
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
scope: updatedAccount.scopes || oauthConfig.scopes.join(' '),
token_type: 'Bearer'
}
}
@@ -904,7 +1088,11 @@ async function refreshAccountToken(accountId) {
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
// 传入账户的代理配置
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
const newTokens = await refreshAccessToken(
account.refreshToken,
account.proxy,
account.oauthProvider
)
// 更新账户信息
const updates = {
@@ -1036,14 +1224,15 @@ async function getAccountRateLimitInfo(accountId) {
}
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法支持代理
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
const client = createOAuth2Client(null, proxyConfig)
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
const creds = {
access_token: accessToken,
refresh_token: refreshToken,
scope:
'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
scope: oauthConfig.scopes.join(' '),
token_type: 'Bearer',
expiry_date: 1754269905646
}
@@ -1509,6 +1698,43 @@ async function generateContent(
return response.data
}
// 调用 Antigravity 上游生成内容(非流式)
async function generateContentAntigravity(
client,
requestData,
userPromptId,
projectId = null,
sessionId = null,
proxyConfig = null
) {
const { token } = await client.getAccessToken()
const { model } = antigravityClient.buildAntigravityEnvelope({
requestData,
projectId,
sessionId,
userPromptId
})
logger.info('🪐 Antigravity generateContent API调用开始', {
model,
userPromptId,
projectId,
sessionId
})
const { response } = await antigravityClient.request({
accessToken: token,
proxyConfig,
requestData,
projectId,
sessionId,
userPromptId,
stream: false
})
logger.info('✅ Antigravity generateContent API调用成功')
return response.data
}
// 调用 Code Assist API 生成内容(流式)
async function generateContentStream(
client,
@@ -1593,6 +1819,46 @@ async function generateContentStream(
return response.data // 返回流对象
}
// 调用 Antigravity 上游生成内容(流式)
async function generateContentStreamAntigravity(
client,
requestData,
userPromptId,
projectId = null,
sessionId = null,
signal = null,
proxyConfig = null
) {
const { token } = await client.getAccessToken()
const { model } = antigravityClient.buildAntigravityEnvelope({
requestData,
projectId,
sessionId,
userPromptId
})
logger.info('🌊 Antigravity streamGenerateContent API调用开始', {
model,
userPromptId,
projectId,
sessionId
})
const { response } = await antigravityClient.request({
accessToken: token,
proxyConfig,
requestData,
projectId,
sessionId,
userPromptId,
stream: true,
signal,
params: { alt: 'sse' }
})
logger.info('✅ Antigravity streamGenerateContent API调用成功开始流式传输')
return response.data
}
// 更新账户的临时项目 ID
async function updateTempProjectId(accountId, tempProjectId) {
if (!tempProjectId) {
@@ -1687,10 +1953,12 @@ module.exports = {
generateEncryptionKey,
decryptCache, // 暴露缓存对象以便测试和监控
countTokens,
countTokensAntigravity,
generateContent,
generateContentStream,
generateContentAntigravity,
generateContentStreamAntigravity,
fetchAvailableModelsAntigravity,
updateTempProjectId,
resetAccountStatus,
OAUTH_CLIENT_ID,
OAUTH_SCOPES
resetAccountStatus
}

View File

@@ -426,9 +426,9 @@ class OpenAIResponsesRelayService {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
if (line.startsWith('data:')) {
try {
const jsonStr = line.slice(6)
const jsonStr = line.slice(5).trim()
if (jsonStr === '[DONE]') {
continue
}

View File

@@ -36,15 +36,28 @@ class OpenAIToClaudeConverter {
// 如果 OpenAI 请求中包含系统消息,提取并检查
const systemMessage = this._extractSystemMessage(openaiRequest.messages)
if (systemMessage && systemMessage.includes('You are currently in Xcode')) {
// Xcode 系统提示词
const passThroughSystemPrompt =
String(process.env.CRS_PASSTHROUGH_SYSTEM_PROMPT || '').toLowerCase() === 'true'
if (
systemMessage &&
(passThroughSystemPrompt || systemMessage.includes('You are currently in Xcode'))
) {
claudeRequest.system = systemMessage
logger.info(
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
)
if (systemMessage.includes('You are currently in Xcode')) {
logger.info(
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)`
)
} else {
logger.info(
`🧩 Using caller-provided system prompt (${systemMessage.length} chars) because CRS_PASSTHROUGH_SYSTEM_PROMPT=true`
)
}
logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`)
} else {
// 使用 Claude Code 默认系统提示词
// 默认行为:兼容 Claude Code(忽略外部 system
claudeRequest.system = claudeCodeSystemMessage
logger.debug(
`📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}`

View File

@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
'x-stainless-runtime',
'x-stainless-runtime-version'
]
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
const STAINLESS_HEADER_CASE_MAP = {
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
'x-stainless-timeout': 'X-Stainless-Timeout',
'x-stainless-lang': 'X-Stainless-Lang',
'x-stainless-package-version': 'X-Stainless-Package-Version',
'x-stainless-os': 'X-Stainless-OS',
'x-stainless-arch': 'X-Stainless-Arch',
'x-stainless-runtime': 'X-Stainless-Runtime',
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
}
const MIN_FINGERPRINT_FIELDS = 4
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
return
}
removeHeaderCaseInsensitive(nextHeaders, key)
nextHeaders[key] = fingerprint[key]
// 使用正确的大小写格式返回给上游
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
nextHeaders[properCaseKey] = fingerprint[key]
})
return nextHeaders

View File

@@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService')
const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
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 {
constructor() {
@@ -46,8 +72,14 @@ class UnifiedClaudeScheduler {
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')) {
const isNewOpus = isOpus45OrNewer(requestedModel)
if (account.subscriptionInfo) {
try {
const info =
@@ -55,27 +87,36 @@ class UnifiedClaudeScheduler {
? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo
// Pro 和 Free 账号不支持 Opus
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
// Free account: does not support any Opus model
if (info.accountType === 'free') {
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
}
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
logger.info(
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
)
return false
// 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(
`🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}`
)
return false
}
// Opus 4.5+ supported
return true
}
// Max account: supports all Opus versions (no restriction)
} catch (e) {
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max
// Parse failed, assume legacy data (Max), default support
logger.debug(
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
)
}
}
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
// Account without subscription info, default to supported (legacy data compatibility)
}
}
@@ -139,8 +180,56 @@ class UnifiedClaudeScheduler {
}
// 🎯 统一调度Claude账号官方和Console
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
async selectAccountForApiKey(
apiKeyData,
sessionHash = null,
requestedModel = null,
forcedAccount = null
) {
try {
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
// 其他类型bedrock, ccr, claude-console等忽略绑定走正常调度
if (forcedAccount.accountType !== 'claude-official') {
logger.info(
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
)
// 不使用 forcedAccount继续走下面的正常调度逻辑
} else {
// claude-official 类型需要检查可用性并强制使用
logger.info(
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
)
const isAvailable = await this._isAccountAvailableForSessionBinding(
forcedAccount.accountId,
forcedAccount.accountType,
requestedModel
)
if (isAvailable) {
logger.info(
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
)
return {
accountId: forcedAccount.accountId,
accountType: forcedAccount.accountType
}
} else {
// 绑定账户不可用,抛出特定错误(不 fallback
logger.warn(
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
)
const error = new Error('Session binding account unavailable')
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
error.accountId = forcedAccount.accountId
error.accountType = forcedAccount.accountType
throw error
}
}
}
// 解析供应商前缀
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
@@ -177,30 +266,41 @@ class UnifiedClaudeScheduler {
// 普通专属账户
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
const error = new Error('Dedicated Claude account is rate limited')
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
if (!this._isSchedulable(boundAccount.schedulable)) {
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
boundAccount.id,
'claude-official'
)
if (isTempUnavailable) {
logger.warn(
` Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
` Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool`
)
} else {
if (isOpusRequest) {
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
const error = new Error('Dedicated Claude account is rate limited')
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.claudeAccountId,
accountType: 'claude-official'
if (!this._isSchedulable(boundAccount.schedulable)) {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
)
} else {
if (isOpusRequest) {
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
}
logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.claudeAccountId,
accountType: 'claude-official'
}
}
}
} else {
@@ -221,12 +321,23 @@ class UnifiedClaudeScheduler {
boundConsoleAccount.status === 'active' &&
this._isSchedulable(boundConsoleAccount.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
boundConsoleAccount.id,
'claude-console'
)
return {
accountId: apiKeyData.claudeConsoleAccountId,
accountType: 'claude-console'
if (isTempUnavailable) {
logger.warn(
`⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool`
)
} else {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.claudeConsoleAccountId,
accountType: 'claude-console'
}
}
} else {
logger.warn(
@@ -245,12 +356,23 @@ class UnifiedClaudeScheduler {
boundBedrockAccountResult.data.isActive === true &&
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
) {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
apiKeyData.bedrockAccountId,
'bedrock'
)
return {
accountId: apiKeyData.bedrockAccountId,
accountType: 'bedrock'
if (isTempUnavailable) {
logger.warn(
`⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool`
)
} else {
logger.info(
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
)
return {
accountId: apiKeyData.bedrockAccountId,
accountType: 'bedrock'
}
}
} else {
logger.warn(
@@ -496,6 +618,18 @@ class UnifiedClaudeScheduler {
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)
if (isRateLimited) {
@@ -584,6 +718,18 @@ class UnifiedClaudeScheduler {
// 继续处理该账号
}
// 检查是否临时不可用
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
currentAccount.id,
'claude-console'
)
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable`
)
continue
}
// 检查是否被限流
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
currentAccount.id
@@ -682,7 +828,15 @@ class UnifiedClaudeScheduler {
account.accountType === 'shared' &&
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({
...account,
@@ -731,6 +885,13 @@ class UnifiedClaudeScheduler {
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 isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
@@ -1099,6 +1260,42 @@ class UnifiedClaudeScheduler {
}
}
// ⏱️ 标记账户为临时不可用状态用于5xx等临时故障默认5分钟后自动恢复
async markAccountTemporarilyUnavailable(
accountId,
accountType,
sessionHash = null,
ttlSeconds = 300
) {
try {
const client = redis.getClientSafe()
const key = `temp_unavailable:${accountType}:${accountId}`
await client.setex(key, ttlSeconds, '1')
if (sessionHash) {
await this._deleteSessionMapping(sessionHash)
}
logger.warn(
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
return { success: false }
}
}
// 🔍 检查账户是否临时不可用
async isAccountTemporarilyUnavailable(accountId, accountType) {
try {
const client = redis.getClientSafe()
const key = `temp_unavailable:${accountType}:${accountId}`
return (await client.exists(key)) === 1
} catch (error) {
logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error)
return false
}
}
// 🚫 标记账户为限流状态
async markAccountRateLimited(
accountId,
@@ -1562,6 +1759,67 @@ class UnifiedClaudeScheduler {
return []
}
}
/**
* 🔒 检查 claude-official 账户是否可用于会话绑定
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
* @param {string} accountId - 账户ID
* @param {string} accountType - 账户类型(应为 'claude-official'
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
* @returns {Promise<boolean>}
*/
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
try {
// 此方法仅处理 claude-official 类型
if (accountType !== 'claude-official') {
logger.warn(
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
)
return true // 非 claude-official 类型不受限制
}
const account = await redis.getClaudeAccount(accountId)
if (!account) {
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
return false
}
const isActive = account.isActive === 'true' || account.isActive === true
const { status } = account
if (!isActive) {
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
return false
}
if (status === 'error' || status === 'temp_error') {
logger.warn(
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
)
return false
}
// 检查是否被限流
if (await claudeAccountService.isAccountRateLimited(accountId)) {
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
return false
}
// 检查临时不可用
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
return false
}
return true
} catch (error) {
logger.error(
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
error
)
return false
}
}
}
module.exports = new UnifiedClaudeScheduler()

View File

@@ -4,11 +4,35 @@ const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY]
function normalizeOauthProvider(oauthProvider) {
if (!oauthProvider) {
return OAUTH_PROVIDER_GEMINI_CLI
}
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
? OAUTH_PROVIDER_ANTIGRAVITY
: OAUTH_PROVIDER_GEMINI_CLI
}
class UnifiedGeminiScheduler {
constructor() {
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'
}
_getSessionMappingKey(sessionHash, oauthProvider = null) {
if (!sessionHash) {
return null
}
if (!oauthProvider) {
return `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
}
const normalized = normalizeOauthProvider(oauthProvider)
return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}`
}
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
_isSchedulable(schedulable) {
// 如果是 undefined 或 null默认为可调度
@@ -32,7 +56,8 @@ class UnifiedGeminiScheduler {
requestedModel = null,
options = {}
) {
const { allowApiAccounts = false } = options
const { allowApiAccounts = false, oauthProvider = null } = options
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
try {
// 如果API Key绑定了专属账户或分组优先使用
@@ -83,14 +108,23 @@ class UnifiedGeminiScheduler {
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
logger.info(
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId)
return {
accountId: apiKeyData.geminiAccountId,
accountType: 'gemini'
if (
normalizedOauthProvider &&
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
) {
logger.warn(
`⚠️ Bound Gemini OAuth account ${boundAccount.name} oauthProvider=${normalizeOauthProvider(boundAccount.oauthProvider)} does not match requested oauthProvider=${normalizedOauthProvider}, falling back to pool`
)
} else {
logger.info(
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId)
return {
accountId: apiKeyData.geminiAccountId,
accountType: 'gemini'
}
}
} else {
logger.warn(
@@ -102,7 +136,7 @@ class UnifiedGeminiScheduler {
// 如果有会话哈希,检查是否有已映射的账户
if (sessionHash) {
const mappedAccount = await this._getSessionMapping(sessionHash)
const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider)
if (mappedAccount) {
// 验证映射的账户是否仍然可用
const isAvailable = await this._isAccountAvailable(
@@ -111,7 +145,7 @@ class UnifiedGeminiScheduler {
)
if (isAvailable) {
// 🚀 智能会话续期(续期 unified 映射键,按配置)
await this._extendSessionMappingTTL(sessionHash)
await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
@@ -132,11 +166,10 @@ class UnifiedGeminiScheduler {
}
// 获取所有可用账户
const availableAccounts = await this._getAllAvailableAccounts(
apiKeyData,
requestedModel,
allowApiAccounts
)
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, {
allowApiAccounts,
oauthProvider: normalizedOauthProvider
})
if (availableAccounts.length === 0) {
// 提供更详细的错误信息
@@ -160,7 +193,8 @@ class UnifiedGeminiScheduler {
await this._setSessionMapping(
sessionHash,
selectedAccount.accountId,
selectedAccount.accountType
selectedAccount.accountType,
normalizedOauthProvider
)
logger.info(
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
@@ -189,7 +223,18 @@ class UnifiedGeminiScheduler {
}
// 📋 获取所有可用账户
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) {
async _getAllAvailableAccounts(
apiKeyData,
requestedModel = null,
allowApiAccountsOrOptions = false
) {
const options =
allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object'
? allowApiAccountsOrOptions
: { allowApiAccounts: allowApiAccountsOrOptions }
const { allowApiAccounts = false, oauthProvider = null } = options
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
const availableAccounts = []
// 如果API Key绑定了专属账户优先返回
@@ -254,6 +299,12 @@ class UnifiedGeminiScheduler {
this._isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
if (
normalizedOauthProvider &&
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
) {
return availableAccounts
}
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) {
// 检查模型支持
@@ -303,6 +354,12 @@ class UnifiedGeminiScheduler {
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
this._isSchedulable(account.schedulable)
) {
if (
normalizedOauthProvider &&
normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider
) {
continue
}
// 检查是否可调度
// 检查token是否过期
@@ -437,9 +494,10 @@ class UnifiedGeminiScheduler {
}
// 🔗 获取会话映射
async _getSessionMapping(sessionHash) {
async _getSessionMapping(sessionHash, oauthProvider = null) {
const client = redis.getClientSafe()
const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
const mappingData = key ? await client.get(key) : null
if (mappingData) {
try {
@@ -454,27 +512,42 @@ class UnifiedGeminiScheduler {
}
// 💾 设置会话映射
async _setSessionMapping(sessionHash, accountId, accountType) {
async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) {
const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType })
// 依据配置设置TTL小时
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
if (!key) {
return
}
await client.setex(key, ttlSeconds, mappingData)
}
// 🗑️ 删除会话映射
async _deleteSessionMapping(sessionHash) {
const client = redis.getClientSafe()
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
if (!sessionHash) {
return
}
const keys = [this._getSessionMappingKey(sessionHash)]
for (const provider of KNOWN_OAUTH_PROVIDERS) {
keys.push(this._getSessionMappingKey(sessionHash, provider))
}
await client.del(keys.filter(Boolean))
}
// 🔁 续期统一调度会话映射TTL针对 unified_gemini_session_mapping:* 键),遵循会话配置
async _extendSessionMappingTTL(sessionHash) {
async _extendSessionMappingTTL(sessionHash, oauthProvider = null) {
try {
const client = redis.getClientSafe()
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
if (!key) {
return false
}
const remainingTTL = await client.ttl(key)
if (remainingTTL === -2) {

View File

@@ -9,6 +9,26 @@ class UnifiedOpenAIScheduler {
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
}
// 🔢 按优先级和最后使用时间排序账户(与 Claude/Gemini 调度保持一致)
_sortAccountsByPriority(accounts) {
return accounts.sort((a, b) => {
const aPriority = Number.parseInt(a.priority, 10)
const bPriority = Number.parseInt(b.priority, 10)
const normalizedAPriority = Number.isFinite(aPriority) ? aPriority : 50
const normalizedBPriority = Number.isFinite(bPriority) ? bPriority : 50
// 首先按优先级排序(数字越小优先级越高)
if (normalizedAPriority !== normalizedBPriority) {
return normalizedAPriority - normalizedBPriority
}
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed
})
}
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
_isSchedulable(schedulable) {
// 如果是 undefined 或 null默认为可调度
@@ -244,13 +264,7 @@ class UnifiedOpenAIScheduler {
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
if (accountType === 'openai') {
await openaiAccountService.recordUsage(boundAccount.id, 0)
} else {
await openaiResponsesAccountService.updateAccount(boundAccount.id, {
lastUsedAt: new Date().toISOString()
})
}
await this.updateAccountLastUsed(boundAccount.id, accountType)
return {
accountId: boundAccount.id,
accountType
@@ -292,7 +306,7 @@ class UnifiedOpenAIScheduler {
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(mappedAccount.accountId, 0)
await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType)
return mappedAccount
} else {
logger.warn(
@@ -321,12 +335,8 @@ class UnifiedOpenAIScheduler {
}
}
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
const sortedAccounts = availableAccounts.sort((a, b) => {
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed // 最久未使用的优先
})
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
@@ -344,11 +354,11 @@ class UnifiedOpenAIScheduler {
}
logger.info(
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for API key ${apiKeyData.name}`
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(selectedAccount.accountId, 0)
await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType)
return {
accountId: selectedAccount.accountId,
@@ -494,21 +504,6 @@ class UnifiedOpenAIScheduler {
return availableAccounts
}
// 🔢 按优先级和最后使用时间排序账户(已废弃,改为与 Claude 保持一致,只按最后使用时间排序)
// _sortAccountsByPriority(accounts) {
// return accounts.sort((a, b) => {
// // 首先按优先级排序(数字越小优先级越高)
// if (a.priority !== b.priority) {
// return a.priority - b.priority
// }
// // 优先级相同时,按最后使用时间排序(最久未使用的优先)
// const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
// const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
// return aLastUsed - bLastUsed
// })
// }
// 🔍 检查账户是否可用
async _isAccountAvailable(accountId, accountType) {
try {
@@ -817,7 +812,7 @@ class UnifiedOpenAIScheduler {
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(mappedAccount.accountId, 0)
await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType)
return mappedAccount
}
}
@@ -909,12 +904,8 @@ class UnifiedOpenAIScheduler {
throw error
}
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
const sortedAccounts = availableAccounts.sort((a, b) => {
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed // 最久未使用的优先
})
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
@@ -932,11 +923,11 @@ class UnifiedOpenAIScheduler {
}
logger.info(
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId})`
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50})`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(selectedAccount.accountId, 0)
await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType)
return {
accountId: selectedAccount.accountId,
@@ -958,9 +949,12 @@ class UnifiedOpenAIScheduler {
async updateAccountLastUsed(accountId, accountType) {
try {
if (accountType === 'openai') {
await openaiAccountService.updateAccount(accountId, {
lastUsedAt: new Date().toISOString()
})
await openaiAccountService.recordUsage(accountId, 0)
return
}
if (accountType === 'openai-responses') {
await openaiResponsesAccountService.recordUsage(accountId, 0)
}
} catch (error) {
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)

View File

@@ -0,0 +1,359 @@
/**
* 用户消息队列服务
* 为 Claude 账户实现基于消息类型的串行排队机制
*
* 当请求的最后一条消息是用户输入role: user
* 同一账户的此类请求需要串行等待,并在请求之间添加延迟
*/
const { v4: uuidv4 } = require('uuid')
const redis = require('../models/redis')
const config = require('../../config/config')
const logger = require('../utils/logger')
// 清理任务间隔
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
// 轮询等待配置
const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
class UserMessageQueueService {
constructor() {
this.cleanupTimer = null
}
/**
* 检测请求是否为真正的用户消息请求
* 区分真正的用户输入和 tool_result 消息
*
* Claude API 消息格式:
* - 用户文本消息: { role: 'user', content: 'text' } 或 { role: 'user', content: [{ type: 'text', text: '...' }] }
* - 工具结果消息: { role: 'user', content: [{ type: 'tool_result', tool_use_id: '...', content: '...' }] }
*
* @param {Object} requestBody - 请求体
* @returns {boolean} - 是否为真正的用户消息(排除 tool_result
*/
isUserMessageRequest(requestBody) {
const messages = requestBody?.messages
if (!Array.isArray(messages) || messages.length === 0) {
return false
}
const lastMessage = messages[messages.length - 1]
// 检查 role 是否为 user
if (lastMessage?.role !== 'user') {
return false
}
// 检查 content 是否包含 tool_result 类型
const { content } = lastMessage
if (Array.isArray(content)) {
// 如果 content 数组中任何元素是 tool_result则不是真正的用户消息
const hasToolResult = content.some(
(block) => block?.type === 'tool_result' || block?.type === 'tool_use_result'
)
if (hasToolResult) {
return false
}
}
// role 是 user 且不包含 tool_result是真正的用户消息
return true
}
/**
* 获取当前配置(支持 Web 界面配置优先)
* @returns {Promise<Object>} 配置对象
*/
async getConfig() {
// 默认配置(防止 config.userMessageQueue 未定义)
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
const queueConfig = config.userMessageQueue || {}
const defaults = {
enabled: queueConfig.enabled ?? false,
delayMs: queueConfig.delayMs ?? 200,
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000因为锁持有时间短
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 50005秒足以覆盖请求发送
}
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
try {
const claudeRelayConfigService = require('./claudeRelayConfigService')
const webConfig = await claudeRelayConfigService.getConfig()
return {
enabled:
webConfig.userMessageQueueEnabled !== undefined
? webConfig.userMessageQueueEnabled
: defaults.enabled,
delayMs:
webConfig.userMessageQueueDelayMs !== undefined
? webConfig.userMessageQueueDelayMs
: defaults.delayMs,
timeoutMs:
webConfig.userMessageQueueTimeoutMs !== undefined
? webConfig.userMessageQueueTimeoutMs
: defaults.timeoutMs,
lockTtlMs:
webConfig.userMessageQueueLockTtlMs !== undefined
? webConfig.userMessageQueueLockTtlMs
: defaults.lockTtlMs
}
} catch {
// 回退到环境变量配置
return defaults
}
}
/**
* 检查功能是否启用
* @returns {Promise<boolean>}
*/
async isEnabled() {
const cfg = await this.getConfig()
return cfg.enabled === true
}
/**
* 获取账户队列锁(阻塞等待)
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID可选会自动生成
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用=0使用全局配置
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
*/
async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
const cfg = await this.getConfig()
// 账户级配置优先maxConcurrency > 0 时强制启用,忽略全局开关
let queueEnabled = cfg.enabled
if (accountConfig && accountConfig.maxConcurrency > 0) {
queueEnabled = true
logger.debug(
`📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})`
)
}
if (!queueEnabled) {
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
}
const reqId = requestId || uuidv4()
const timeout = timeoutMs || cfg.timeoutMs
const startTime = Date.now()
let retryCount = 0
logger.debug(`📬 User message queue: attempting to acquire lock for account ${accountId}`, {
requestId: reqId,
timeoutMs: timeout
})
while (Date.now() - startTime < timeout) {
const result = await redis.acquireUserMessageLock(
accountId,
reqId,
cfg.lockTtlMs,
cfg.delayMs
)
// 检测 Redis 错误,立即返回系统错误而非继续轮询
if (result.redisError) {
logger.error(`📬 User message queue: Redis error while acquiring lock`, {
accountId,
requestId: reqId,
errorMessage: result.errorMessage
})
return {
acquired: false,
requestId: reqId,
error: 'queue_backend_error',
errorMessage: result.errorMessage
}
}
if (result.acquired) {
logger.debug(`📬 User message queue: lock acquired for account ${accountId}`, {
requestId: reqId,
waitedMs: Date.now() - startTime,
retries: retryCount
})
return { acquired: true, requestId: reqId }
}
// 需要等待
if (result.waitMs > 0) {
// 需要延迟(上一个请求刚完成)
await this._sleep(Math.min(result.waitMs, timeout - (Date.now() - startTime)))
} else {
// 锁被占用,使用指数退避轮询等待
const basePollInterval = Math.min(
POLL_INTERVAL_BASE_MS * Math.pow(POLL_BACKOFF_FACTOR, retryCount),
POLL_INTERVAL_MAX_MS
)
// 添加 ±15% 随机抖动,避免高并发下的周期性碰撞
const jitter = basePollInterval * (0.85 + Math.random() * 0.3)
const pollInterval = Math.min(jitter, POLL_INTERVAL_MAX_MS)
await this._sleep(pollInterval)
retryCount++
}
}
// 超时
logger.warn(`📬 User message queue: timeout waiting for lock`, {
accountId,
requestId: reqId,
timeoutMs: timeout
})
return {
acquired: false,
requestId: reqId,
error: 'queue_timeout'
}
}
/**
* 释放账户队列锁
* @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID
* @returns {Promise<boolean>}
*/
async releaseQueueLock(accountId, requestId) {
if (!accountId || !requestId) {
return false
}
const released = await redis.releaseUserMessageLock(accountId, requestId)
if (released) {
logger.debug(`📬 User message queue: lock released for account ${accountId}`, {
requestId
})
} else {
logger.warn(`📬 User message queue: failed to release lock (not owner?)`, {
accountId,
requestId
})
}
return released
}
/**
* 获取队列统计信息
* @param {string} accountId - 账户ID
* @returns {Promise<Object>}
*/
async getQueueStats(accountId) {
return await redis.getUserMessageQueueStats(accountId)
}
/**
* 服务启动时清理所有残留的队列锁
* 防止服务重启后旧锁阻塞新请求
* @returns {Promise<number>} 清理的锁数量
*/
async cleanupStaleLocks() {
try {
const accountIds = await redis.scanUserMessageQueueLocks()
let cleanedCount = 0
for (const accountId of accountIds) {
try {
await redis.forceReleaseUserMessageLock(accountId)
cleanedCount++
logger.debug(`📬 User message queue: cleaned stale lock for account ${accountId}`)
} catch (error) {
logger.error(
`📬 User message queue: failed to clean lock for account ${accountId}:`,
error
)
}
}
if (cleanedCount > 0) {
logger.info(`📬 User message queue: cleaned ${cleanedCount} stale lock(s) on startup`)
}
return cleanedCount
} catch (error) {
logger.error('📬 User message queue: failed to cleanup stale locks on startup:', error)
return 0
}
}
/**
* 启动定时清理任务
* 始终启动,每次执行时检查配置以支持运行时动态启用/禁用
*/
startCleanupTask() {
if (this.cleanupTimer) {
return
}
this.cleanupTimer = setInterval(async () => {
// 每次运行时检查配置,以便在运行时动态启用/禁用
const currentConfig = await this.getConfig()
if (!currentConfig.enabled) {
logger.debug('📬 User message queue: cleanup skipped (feature disabled)')
return
}
await this._cleanupOrphanLocks()
}, CLEANUP_INTERVAL_MS)
logger.info('📬 User message queue: cleanup task started')
}
/**
* 停止定时清理任务
*/
stopCleanupTask() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer)
this.cleanupTimer = null
logger.info('📬 User message queue: cleanup task stopped')
}
}
/**
* 清理孤儿锁
* 检测异常情况锁存在但没有设置过期时间lockTtlRaw === -1
* 正常情况下所有锁都应该有 TTLRedis 会自动过期
* @private
*/
async _cleanupOrphanLocks() {
try {
const accountIds = await redis.scanUserMessageQueueLocks()
for (const accountId of accountIds) {
const stats = await redis.getUserMessageQueueStats(accountId)
// 检测异常情况锁存在isLocked=true但没有过期时间lockTtlRaw=-1
// 正常创建的锁都带有 PX 过期时间,如果没有说明是异常状态
if (stats.isLocked && stats.lockTtlRaw === -1) {
logger.warn(
`📬 User message queue: cleaning up orphan lock without TTL for account ${accountId}`,
{ lockHolder: stats.lockHolder }
)
await redis.forceReleaseUserMessageLock(accountId)
}
}
} catch (error) {
logger.error('📬 User message queue: cleanup task error:', error)
}
}
/**
* 睡眠辅助函数
* @param {number} ms - 毫秒
* @private
*/
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
module.exports = new UserMessageQueueService()

View File

@@ -0,0 +1,126 @@
const fs = require('fs/promises')
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP'
const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES'
const REQUEST_DUMP_FILENAME = 'anthropic-requests-dump.jsonl'
function isEnabled() {
const raw = process.env[REQUEST_DUMP_ENV]
if (!raw) {
return false
}
return raw === '1' || raw.toLowerCase() === 'true'
}
function getMaxBytes() {
const raw = process.env[REQUEST_DUMP_MAX_BYTES_ENV]
if (!raw) {
return 2 * 1024 * 1024
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2 * 1024 * 1024
}
return parsed
}
function maskSecret(value) {
if (value === null || value === undefined) {
return value
}
const str = String(value)
if (str.length <= 8) {
return '***'
}
return `${str.slice(0, 4)}...${str.slice(-4)}`
}
function sanitizeHeaders(headers) {
const sensitive = new Set([
'authorization',
'proxy-authorization',
'x-api-key',
'cookie',
'set-cookie',
'x-forwarded-for',
'x-real-ip'
])
const out = {}
for (const [k, v] of Object.entries(headers || {})) {
const key = k.toLowerCase()
if (sensitive.has(key)) {
out[key] = maskSecret(v)
continue
}
out[key] = v
}
return out
}
function safeJsonStringify(payload, maxBytes) {
let json = ''
try {
json = JSON.stringify(payload)
} catch (e) {
return JSON.stringify({
type: 'anthropic_request_dump_error',
error: 'JSON.stringify_failed',
message: e?.message || String(e)
})
}
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
return json
}
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
return JSON.stringify({
type: 'anthropic_request_dump_truncated',
maxBytes,
originalBytes: Buffer.byteLength(json, 'utf8'),
partialJson: truncated
})
}
async function dumpAnthropicMessagesRequest(req, meta = {}) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), REQUEST_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
requestId: req?.requestId || null,
method: req?.method || null,
url: req?.originalUrl || req?.url || null,
ip: req?.ip || null,
meta,
headers: sanitizeHeaders(req?.headers || {}),
body: req?.body || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await fs.appendFile(filename, line, { encoding: 'utf8' })
} catch (e) {
logger.warn('Failed to dump Anthropic request', {
filename,
requestId: req?.requestId || null,
error: e?.message || String(e)
})
}
}
module.exports = {
dumpAnthropicMessagesRequest,
REQUEST_DUMP_ENV,
REQUEST_DUMP_MAX_BYTES_ENV,
REQUEST_DUMP_FILENAME
}

View File

@@ -0,0 +1,125 @@
const fs = require('fs/promises')
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
const RESPONSE_DUMP_FILENAME = 'anthropic-responses-dump.jsonl'
function isEnabled() {
const raw = process.env[RESPONSE_DUMP_ENV]
if (!raw) {
return false
}
return raw === '1' || raw.toLowerCase() === 'true'
}
function getMaxBytes() {
const raw = process.env[RESPONSE_DUMP_MAX_BYTES_ENV]
if (!raw) {
return 2 * 1024 * 1024
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2 * 1024 * 1024
}
return parsed
}
function safeJsonStringify(payload, maxBytes) {
let json = ''
try {
json = JSON.stringify(payload)
} catch (e) {
return JSON.stringify({
type: 'anthropic_response_dump_error',
error: 'JSON.stringify_failed',
message: e?.message || String(e)
})
}
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
return json
}
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
return JSON.stringify({
type: 'anthropic_response_dump_truncated',
maxBytes,
originalBytes: Buffer.byteLength(json, 'utf8'),
partialJson: truncated
})
}
function summarizeAnthropicResponseBody(body) {
const content = Array.isArray(body?.content) ? body.content : []
const toolUses = content.filter((b) => b && b.type === 'tool_use')
const texts = content
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('')
return {
id: body?.id || null,
model: body?.model || null,
stop_reason: body?.stop_reason || null,
usage: body?.usage || null,
content_blocks: content.map((b) => (b ? b.type : null)).filter(Boolean),
tool_use_names: toolUses.map((b) => b.name).filter(Boolean),
text_preview: texts ? texts.slice(0, 800) : ''
}
}
async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
requestId: req?.requestId || null,
url: req?.originalUrl || req?.url || null,
meta,
response: responseInfo
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await fs.appendFile(filename, line, { encoding: 'utf8' })
} catch (e) {
logger.warn('Failed to dump Anthropic response', {
filename,
requestId: req?.requestId || null,
error: e?.message || String(e)
})
}
}
async function dumpAnthropicNonStreamResponse(req, statusCode, body, meta = {}) {
return dumpAnthropicResponse(
req,
{ kind: 'non-stream', statusCode, summary: summarizeAnthropicResponseBody(body), body },
meta
)
}
async function dumpAnthropicStreamSummary(req, summary, meta = {}) {
return dumpAnthropicResponse(req, { kind: 'stream', summary }, meta)
}
async function dumpAnthropicStreamError(req, error, meta = {}) {
return dumpAnthropicResponse(req, { kind: 'stream-error', error }, meta)
}
module.exports = {
dumpAnthropicNonStreamResponse,
dumpAnthropicStreamSummary,
dumpAnthropicStreamError,
RESPONSE_DUMP_ENV,
RESPONSE_DUMP_MAX_BYTES_ENV,
RESPONSE_DUMP_FILENAME
}

View File

@@ -0,0 +1,138 @@
const DEFAULT_ANTIGRAVITY_MODEL = 'gemini-2.5-flash'
const UPSTREAM_TO_ALIAS = {
'rev19-uic3-1p': 'gemini-2.5-computer-use-preview-10-2025',
'gemini-3-pro-image': 'gemini-3-pro-image-preview',
'gemini-3-pro-high': 'gemini-3-pro-preview',
'gemini-3-flash': 'gemini-3-flash-preview',
'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5',
'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking',
'claude-opus-4-5-thinking': 'gemini-claude-opus-4-5-thinking',
chat_20706: '',
chat_23310: '',
'gemini-2.5-flash-thinking': '',
'gemini-3-pro-low': '',
'gemini-2.5-pro': ''
}
const ALIAS_TO_UPSTREAM = {
'gemini-2.5-computer-use-preview-10-2025': 'rev19-uic3-1p',
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
'gemini-3-pro-preview': 'gemini-3-pro-high',
'gemini-3-flash-preview': 'gemini-3-flash',
'gemini-claude-sonnet-4-5': 'claude-sonnet-4-5',
'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
'gemini-claude-opus-4-5-thinking': 'claude-opus-4-5-thinking'
}
const ANTIGRAVITY_MODEL_METADATA = {
'gemini-2.5-flash': {
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
name: 'models/gemini-2.5-flash'
},
'gemini-2.5-flash-lite': {
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
name: 'models/gemini-2.5-flash-lite'
},
'gemini-2.5-computer-use-preview-10-2025': {
name: 'models/gemini-2.5-computer-use-preview-10-2025'
},
'gemini-3-pro-preview': {
thinking: {
min: 128,
max: 32768,
zeroAllowed: false,
dynamicAllowed: true,
levels: ['low', 'high']
},
name: 'models/gemini-3-pro-preview'
},
'gemini-3-pro-image-preview': {
thinking: {
min: 128,
max: 32768,
zeroAllowed: false,
dynamicAllowed: true,
levels: ['low', 'high']
},
name: 'models/gemini-3-pro-image-preview'
},
'gemini-3-flash-preview': {
thinking: {
min: 128,
max: 32768,
zeroAllowed: false,
dynamicAllowed: true,
levels: ['minimal', 'low', 'medium', 'high']
},
name: 'models/gemini-3-flash-preview'
},
'gemini-claude-sonnet-4-5-thinking': {
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
maxCompletionTokens: 64000
},
'gemini-claude-opus-4-5-thinking': {
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
maxCompletionTokens: 64000
}
}
function normalizeAntigravityModelInput(model, defaultModel = DEFAULT_ANTIGRAVITY_MODEL) {
if (!model) {
return defaultModel
}
return model.startsWith('models/') ? model.slice('models/'.length) : model
}
function getAntigravityModelAlias(modelName) {
const normalized = normalizeAntigravityModelInput(modelName)
if (Object.prototype.hasOwnProperty.call(UPSTREAM_TO_ALIAS, normalized)) {
return UPSTREAM_TO_ALIAS[normalized]
}
return normalized
}
function getAntigravityModelMetadata(modelName) {
const normalized = normalizeAntigravityModelInput(modelName)
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, normalized)) {
return ANTIGRAVITY_MODEL_METADATA[normalized]
}
if (normalized.startsWith('claude-')) {
const prefixed = `gemini-${normalized}`
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, prefixed)) {
return ANTIGRAVITY_MODEL_METADATA[prefixed]
}
const thinkingAlias = `${prefixed}-thinking`
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, thinkingAlias)) {
return ANTIGRAVITY_MODEL_METADATA[thinkingAlias]
}
}
return null
}
function mapAntigravityUpstreamModel(model) {
const normalized = normalizeAntigravityModelInput(model)
let upstream = Object.prototype.hasOwnProperty.call(ALIAS_TO_UPSTREAM, normalized)
? ALIAS_TO_UPSTREAM[normalized]
: normalized
if (upstream.startsWith('gemini-claude-')) {
upstream = upstream.replace(/^gemini-/, '')
}
const mapping = {
// Opus上游更常见的是 thinking 变体CLIProxyAPI 也按此处理)
'claude-opus-4-5': 'claude-opus-4-5-thinking',
// Gemini thinking 变体回退
'gemini-2.5-flash-thinking': 'gemini-2.5-flash'
}
return mapping[upstream] || upstream
}
module.exports = {
normalizeAntigravityModelInput,
getAntigravityModelAlias,
getAntigravityModelMetadata,
mapAntigravityUpstreamModel
}

View File

@@ -0,0 +1,121 @@
const fs = require('fs/promises')
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP'
const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES'
const UPSTREAM_REQUEST_DUMP_FILENAME = 'antigravity-upstream-requests-dump.jsonl'
function isEnabled() {
const raw = process.env[UPSTREAM_REQUEST_DUMP_ENV]
if (!raw) {
return false
}
const normalized = String(raw).trim().toLowerCase()
return normalized === '1' || normalized === 'true'
}
function getMaxBytes() {
const raw = process.env[UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV]
if (!raw) {
return 2 * 1024 * 1024
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2 * 1024 * 1024
}
return parsed
}
function redact(value) {
if (!value) {
return value
}
const s = String(value)
if (s.length <= 10) {
return '***'
}
return `${s.slice(0, 3)}...${s.slice(-4)}`
}
function safeJsonStringify(payload, maxBytes) {
let json = ''
try {
json = JSON.stringify(payload)
} catch (e) {
return JSON.stringify({
type: 'antigravity_upstream_dump_error',
error: 'JSON.stringify_failed',
message: e?.message || String(e)
})
}
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
return json
}
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
return JSON.stringify({
type: 'antigravity_upstream_dump_truncated',
maxBytes,
originalBytes: Buffer.byteLength(json, 'utf8'),
partialJson: truncated
})
}
async function dumpAntigravityUpstreamRequest(requestInfo) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), UPSTREAM_REQUEST_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
type: 'antigravity_upstream_request',
requestId: requestInfo?.requestId || null,
model: requestInfo?.model || null,
stream: Boolean(requestInfo?.stream),
url: requestInfo?.url || null,
baseUrl: requestInfo?.baseUrl || null,
params: requestInfo?.params || null,
headers: requestInfo?.headers
? {
Host: requestInfo.headers.Host || requestInfo.headers.host || null,
'User-Agent':
requestInfo.headers['User-Agent'] || requestInfo.headers['user-agent'] || null,
Authorization: (() => {
const raw = requestInfo.headers.Authorization || requestInfo.headers.authorization
if (!raw) {
return null
}
const value = String(raw)
const m = value.match(/^Bearer\\s+(.+)$/i)
const token = m ? m[1] : value
return `Bearer ${redact(token)}`
})()
}
: null,
envelope: requestInfo?.envelope || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await fs.appendFile(filename, line, { encoding: 'utf8' })
} catch (e) {
logger.warn('Failed to dump Antigravity upstream request', {
filename,
requestId: requestInfo?.requestId || null,
error: e?.message || String(e)
})
}
}
module.exports = {
dumpAntigravityUpstreamRequest,
UPSTREAM_REQUEST_DUMP_ENV,
UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV,
UPSTREAM_REQUEST_DUMP_FILENAME
}

View File

@@ -84,6 +84,11 @@ const PROMPT_DEFINITIONS = {
title: 'Claude Code Compact System Prompt',
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
},
exploreAgentSystemPrompt: {
category: 'system',
title: 'Claude Code Explore Agent System Prompt',
text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude."
},
outputStyleInsightsPrompt: {
category: 'output_style',
title: 'Output Style Insights Addendum',

View File

@@ -55,16 +55,69 @@ function sanitizeUpstreamError(errorData) {
return errorData
}
// 深拷贝避免修改原始对象
const sanitized = JSON.parse(JSON.stringify(errorData))
// AxiosError / Error返回摘要避免泄露请求体/headers/token 等敏感信息
const looksLikeAxiosError =
errorData.isAxiosError ||
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
if (looksLikeAxiosError || looksLikeError) {
const statusCode = errorData.response?.status
const upstreamBody = errorData.response?.data
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '')
return {
name: errorData.name || 'Error',
code: errorData.code,
statusCode,
message: sanitizeErrorMessage(errorData.message || ''),
upstreamMessage: upstreamMessage || undefined,
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
}
}
// 递归清理嵌套的错误对象
const visited = new WeakSet()
const shouldRedactKey = (key) => {
if (!key) {
return false
}
const lowerKey = String(key).toLowerCase()
return (
lowerKey === 'authorization' ||
lowerKey === 'cookie' ||
lowerKey.includes('api_key') ||
lowerKey.includes('apikey') ||
lowerKey.includes('access_token') ||
lowerKey.includes('refresh_token') ||
lowerKey.endsWith('token') ||
lowerKey.includes('secret') ||
lowerKey.includes('password')
)
}
const sanitizeObject = (obj) => {
if (!obj || typeof obj !== 'object') {
return obj
}
if (visited.has(obj)) {
return '[Circular]'
}
visited.add(obj)
// 主动剔除常见“超大且敏感”的字段
if (obj.config || obj.request || obj.response) {
return '[Redacted]'
}
for (const key in obj) {
if (shouldRedactKey(key)) {
obj[key] = '[REDACTED]'
continue
}
// 清理所有字符串字段,不仅仅是 message
if (typeof obj[key] === 'string') {
obj[key] = sanitizeErrorMessage(obj[key])
@@ -76,7 +129,9 @@ function sanitizeUpstreamError(errorData) {
return obj
}
return sanitizeObject(sanitized)
// 尽量不修改原对象:浅拷贝后递归清理
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
return sanitizeObject(clone)
}
/**

44
src/utils/featureFlags.js Normal file
View File

@@ -0,0 +1,44 @@
let config = {}
try {
// config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js
// 为保证可运行,这里做容错处理
// eslint-disable-next-line global-require
config = require('../../config/config')
} catch (error) {
config = {}
}
const parseBooleanEnv = (value) => {
if (typeof value === 'boolean') {
return value
}
if (typeof value !== 'string') {
return false
}
const normalized = value.trim().toLowerCase()
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
}
/**
* 是否允许执行“余额脚本”(安全开关)
* 默认开启,便于保持现有行为;如需禁用请显式设置 BALANCE_SCRIPT_ENABLED=false环境变量优先
*/
const isBalanceScriptEnabled = () => {
if (
process.env.BALANCE_SCRIPT_ENABLED !== undefined &&
process.env.BALANCE_SCRIPT_ENABLED !== ''
) {
return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED)
}
const fromConfig =
config?.accountBalance?.enableBalanceScript ??
config?.features?.balanceScriptEnabled ??
config?.security?.enableBalanceScript
return typeof fromConfig === 'boolean' ? fromConfig : true
}
module.exports = {
isBalanceScriptEnabled
}

View File

@@ -0,0 +1,265 @@
function appendHint(description, hint) {
if (!hint) {
return description || ''
}
if (!description) {
return hint
}
return `${description} (${hint})`
}
function getRefHint(refValue) {
const ref = String(refValue || '')
if (!ref) {
return ''
}
const idx = ref.lastIndexOf('/')
const name = idx >= 0 ? ref.slice(idx + 1) : ref
return name ? `See: ${name}` : ''
}
function normalizeType(typeValue) {
if (typeof typeValue === 'string' && typeValue) {
return { type: typeValue, hint: '' }
}
if (!Array.isArray(typeValue) || typeValue.length === 0) {
return { type: '', hint: '' }
}
const raw = typeValue.map((t) => (t === null || t === undefined ? '' : String(t))).filter(Boolean)
const hasNull = raw.includes('null')
const nonNull = raw.filter((t) => t !== 'null')
const primary = nonNull[0] || 'string'
const hintParts = []
if (nonNull.length > 1) {
hintParts.push(`Accepts: ${nonNull.join(' | ')}`)
}
if (hasNull) {
hintParts.push('nullable')
}
return { type: primary, hint: hintParts.join('; ') }
}
const CONSTRAINT_KEYS = [
'minLength',
'maxLength',
'exclusiveMinimum',
'exclusiveMaximum',
'pattern',
'minItems',
'maxItems'
]
function scoreSchema(schema) {
if (!schema || typeof schema !== 'object') {
return { score: 0, type: '' }
}
const t = typeof schema.type === 'string' ? schema.type : ''
if (t === 'object' || (schema.properties && typeof schema.properties === 'object')) {
return { score: 3, type: t || 'object' }
}
if (t === 'array' || schema.items) {
return { score: 2, type: t || 'array' }
}
if (t && t !== 'null') {
return { score: 1, type: t }
}
return { score: 0, type: t || 'null' }
}
function pickBestFromAlternatives(alternatives) {
let bestIndex = 0
let bestScore = -1
const types = []
for (let i = 0; i < alternatives.length; i += 1) {
const alt = alternatives[i]
const { score, type } = scoreSchema(alt)
if (type) {
types.push(type)
}
if (score > bestScore) {
bestScore = score
bestIndex = i
}
}
return { best: alternatives[bestIndex], types: Array.from(new Set(types)).filter(Boolean) }
}
function cleanJsonSchemaForGemini(schema) {
if (schema === null || schema === undefined) {
return { type: 'object', properties: {} }
}
if (typeof schema !== 'object') {
return { type: 'object', properties: {} }
}
if (Array.isArray(schema)) {
return { type: 'object', properties: {} }
}
// $refGemini/Antigravity 不支持,转换为 hint
if (typeof schema.$ref === 'string' && schema.$ref) {
return {
type: 'object',
description: appendHint(schema.description || '', getRefHint(schema.$ref)),
properties: {}
}
}
// anyOf / oneOf选择最可能的 schema保留类型提示
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : null
const oneOf = Array.isArray(schema.oneOf) ? schema.oneOf : null
const alts = anyOf && anyOf.length ? anyOf : oneOf && oneOf.length ? oneOf : null
if (alts) {
const { best, types } = pickBestFromAlternatives(alts)
const cleaned = cleanJsonSchemaForGemini(best)
const mergedDescription = appendHint(cleaned.description || '', schema.description || '')
const typeHint = types.length > 1 ? `Accepts: ${types.join(' || ')}` : ''
return {
...cleaned,
description: appendHint(mergedDescription, typeHint)
}
}
// allOf合并 properties/required
if (Array.isArray(schema.allOf) && schema.allOf.length) {
const merged = {}
let mergedDesc = schema.description || ''
const mergedReq = new Set()
const mergedProps = {}
for (const item of schema.allOf) {
const cleaned = cleanJsonSchemaForGemini(item)
if (cleaned.description) {
mergedDesc = appendHint(mergedDesc, cleaned.description)
}
if (Array.isArray(cleaned.required)) {
for (const r of cleaned.required) {
if (typeof r === 'string' && r) {
mergedReq.add(r)
}
}
}
if (cleaned.properties && typeof cleaned.properties === 'object') {
Object.assign(mergedProps, cleaned.properties)
}
if (cleaned.type && !merged.type) {
merged.type = cleaned.type
}
if (cleaned.items && !merged.items) {
merged.items = cleaned.items
}
if (Array.isArray(cleaned.enum) && !merged.enum) {
merged.enum = cleaned.enum
}
}
if (Object.keys(mergedProps).length) {
merged.type = merged.type || 'object'
merged.properties = mergedProps
const req = Array.from(mergedReq).filter((r) => mergedProps[r])
if (req.length) {
merged.required = req
}
}
if (mergedDesc) {
merged.description = mergedDesc
}
return cleanJsonSchemaForGemini(merged)
}
const result = {}
const constraintHints = []
// description
if (typeof schema.description === 'string') {
result.description = schema.description
}
for (const key of CONSTRAINT_KEYS) {
const value = schema[key]
if (value === undefined || value === null || typeof value === 'object') {
continue
}
constraintHints.push(`${key}: ${value}`)
}
// const -> enum
if (schema.const !== undefined && !Array.isArray(schema.enum)) {
result.enum = [schema.const]
}
// enum
if (Array.isArray(schema.enum)) {
const en = schema.enum.filter(
(v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
)
if (en.length) {
result.enum = en
}
}
// typeflatten 数组 type
const { type: normalizedType, hint: typeHint } = normalizeType(schema.type)
if (normalizedType) {
result.type = normalizedType
}
if (typeHint) {
result.description = appendHint(result.description || '', typeHint)
}
if (result.enum && result.enum.length > 1 && result.enum.length <= 10) {
const list = result.enum.map((item) => String(item)).join(', ')
result.description = appendHint(result.description || '', `Allowed: ${list}`)
}
if (constraintHints.length) {
result.description = appendHint(result.description || '', constraintHints.join(', '))
}
// additionalPropertiesGemini/Antigravity 不接受布尔值,直接删除并用 hint 记录
if (schema.additionalProperties === false) {
result.description = appendHint(result.description || '', 'No extra properties allowed')
}
// properties
if (
schema.properties &&
typeof schema.properties === 'object' &&
!Array.isArray(schema.properties)
) {
const props = {}
for (const [name, propSchema] of Object.entries(schema.properties)) {
props[name] = cleanJsonSchemaForGemini(propSchema)
}
result.type = result.type || 'object'
result.properties = props
}
// items
if (schema.items !== undefined) {
result.type = result.type || 'array'
result.items = cleanJsonSchemaForGemini(schema.items)
}
// required最后再清理无效字段
if (Array.isArray(schema.required) && result.properties) {
const req = schema.required.filter(
(r) =>
typeof r === 'string' && r && Object.prototype.hasOwnProperty.call(result.properties, r)
)
if (req.length) {
result.required = req
}
}
// 只保留 Gemini 兼容字段:其他($schema/$id/$defs/definitions/format/constraints/pattern...)一律丢弃
if (!result.type) {
result.type = result.properties ? 'object' : result.items ? 'array' : 'object'
}
if (result.type === 'object' && !result.properties) {
result.properties = {}
}
return result
}
module.exports = {
cleanJsonSchemaForGemini
}

View File

@@ -52,50 +52,38 @@ function filterForOpenAI(headers) {
/**
* 为 Claude/Anthropic API 过滤 headers
* 在原有逻辑基础上添加 CDN headers 到敏感列表
* 使用白名单模式,只允许指定的 headers 通过
*/
function filterForClaude(headers) {
const sensitiveHeaders = [
'content-type',
'user-agent',
'x-api-key',
'authorization',
'x-authorization',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
...cdnHeaders // 添加 CDN headers
]
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
// 白名单模式:只允许以下 headers
const allowedHeaders = [
'accept',
'cache-control',
'pragma',
'anthropic-dangerous-direct-browser-access'
'x-stainless-retry-count',
'x-stainless-timeout',
'x-stainless-lang',
'x-stainless-package-version',
'x-stainless-os',
'x-stainless-arch',
'x-stainless-runtime',
'x-stainless-runtime-version',
'x-stainless-helper-method',
'anthropic-dangerous-direct-browser-access',
'anthropic-version',
'x-app',
'anthropic-beta',
'accept-language',
'sec-fetch-mode',
'accept-encoding',
'user-agent',
'content-type',
'connection'
]
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
const filtered = {}
Object.keys(headers || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (allowedHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
}
})

View File

@@ -137,6 +137,7 @@ const createLogFormat = (colorize = false) => {
const logFormat = createLogFormat(false)
const consoleFormat = createLogFormat(true)
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID
// 📁 确保日志目录存在并设置权限
if (!fs.existsSync(config.logging.dirname)) {
@@ -159,18 +160,20 @@ const createRotateTransport = (filename, level = null) => {
transport.level = level
}
// 监听轮转事件
transport.on('rotate', (oldFilename, newFilename) => {
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
})
// 监听轮转事件(测试环境关闭以避免 Jest 退出后输出)
if (!isTestEnv) {
transport.on('rotate', (oldFilename, newFilename) => {
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
})
transport.on('new', (newFilename) => {
console.log(`📄 New log file created: ${newFilename}`)
})
transport.on('new', (newFilename) => {
console.log(`📄 New log file created: ${newFilename}`)
})
transport.on('archive', (zipFilename) => {
console.log(`🗜️ Log archived: ${zipFilename}`)
})
transport.on('archive', (zipFilename) => {
console.log(`🗜️ Log archived: ${zipFilename}`)
})
}
return transport
}

View File

@@ -5,6 +5,10 @@
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
*/
// 仅保留原仓库既有的模型前缀CCR 路由
// Gemini/Antigravity 采用“路径分流”,避免在 model 字段里混入 vendor 前缀造成混乱
const SUPPORTED_VENDOR_PREFIXES = ['ccr']
/**
* Parse vendor-prefixed model string
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
@@ -19,16 +23,21 @@ function parseVendorPrefixedModel(modelStr) {
const trimmed = modelStr.trim()
const lowerTrimmed = trimmed.toLowerCase()
// Check for ccr prefix (case insensitive)
if (lowerTrimmed.startsWith('ccr,')) {
for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) {
if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) {
continue
}
const parts = trimmed.split(',')
if (parts.length >= 2) {
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
const baseModel = parts.slice(1).join(',').trim()
return {
vendor: 'ccr',
baseModel
}
if (parts.length < 2) {
break
}
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
const baseModel = parts.slice(1).join(',').trim()
return {
vendor: vendorPrefix,
baseModel
}
}
@@ -70,9 +79,119 @@ function getVendorType(modelStr) {
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 = {
parseVendorPrefixedModel,
hasVendorPrefix,
getEffectiveModel,
getVendorType
getVendorType,
isOpus45OrNewer
}

10
src/utils/projectPaths.js Normal file
View File

@@ -0,0 +1,10 @@
const path = require('path')
// 该文件位于 src/utils 下,向上两级即项目根目录。
function getProjectRoot() {
return path.resolve(__dirname, '..', '..')
}
module.exports = {
getProjectRoot
}

105
src/utils/statsHelper.js Normal file
View File

@@ -0,0 +1,105 @@
/**
* 统计计算工具函数
* 提供百分位数计算、等待时间统计等通用统计功能
*/
/**
* 计算百分位数(使用 nearest-rank 方法)
* @param {number[]} sortedArray - 已排序的数组(升序)
* @param {number} percentile - 百分位数 (0-100)
* @returns {number} 百分位值
*
* 边界情况说明:
* - percentile=0: 返回最小值 (index=0)
* - percentile=100: 返回最大值 (index=len-1)
* - percentile=50 且 len=2: 返回第一个元素nearest-rank 向下取)
*
* 算法说明nearest-rank 方法):
* - index = ceil(percentile / 100 * len) - 1
* - 示例len=100, P50 → ceil(50) - 1 = 49第50个元素0-indexed
* - 示例len=100, P99 → ceil(99) - 1 = 98第99个元素
*/
function getPercentile(sortedArray, percentile) {
const len = sortedArray.length
if (len === 0) {
return 0
}
if (len === 1) {
return sortedArray[0]
}
// 边界处理percentile <= 0 返回最小值
if (percentile <= 0) {
return sortedArray[0]
}
// 边界处理percentile >= 100 返回最大值
if (percentile >= 100) {
return sortedArray[len - 1]
}
const index = Math.ceil((percentile / 100) * len) - 1
return sortedArray[index]
}
/**
* 计算等待时间分布统计
* @param {number[]} waitTimes - 等待时间数组(无需预先排序)
* @returns {Object|null} 统计对象,空数组返回 null
*
* 返回对象包含:
* - sampleCount: 样本数量(始终包含,便于调用方判断可靠性)
* - count: 样本数量(向后兼容)
* - min: 最小值
* - max: 最大值
* - avg: 平均值(四舍五入)
* - p50: 50百分位数中位数
* - p90: 90百分位数
* - p99: 99百分位数
* - sampleSizeWarning: 样本量不足时的警告信息(样本 < 10
* - p90Unreliable: P90 统计不可靠标记(样本 < 10
* - p99Unreliable: P99 统计不可靠标记(样本 < 100
*
* 可靠性标记说明(详见 design.md Decision 6
* - 样本 < 10: P90 和 P99 都不可靠
* - 样本 < 100: P99 不可靠P90 需要 10 个样本P99 需要 100 个样本)
* - 即使标记为不可靠,仍返回计算值供参考
*/
function calculateWaitTimeStats(waitTimes) {
if (!waitTimes || waitTimes.length === 0) {
return null
}
const sorted = [...waitTimes].sort((a, b) => a - b)
const sum = sorted.reduce((a, b) => a + b, 0)
const len = sorted.length
const stats = {
sampleCount: len, // 新增:始终包含样本数
count: len, // 向后兼容
min: sorted[0],
max: sorted[len - 1],
avg: Math.round(sum / len),
p50: getPercentile(sorted, 50),
p90: getPercentile(sorted, 90),
p99: getPercentile(sorted, 99)
}
// 渐进式可靠性标记(详见 design.md Decision 6
// 样本 < 10: P90 不可靠P90 至少需要 ceil(100/10) = 10 个样本)
if (len < 10) {
stats.sampleSizeWarning = 'Results may be inaccurate due to small sample size'
stats.p90Unreliable = true
}
// 样本 < 100: P99 不可靠P99 至少需要 ceil(100/1) = 100 个样本)
if (len < 100) {
stats.p99Unreliable = true
}
return stats
}
module.exports = {
getPercentile,
calculateWaitTimeStats
}

36
src/utils/streamHelper.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* Stream Helper Utilities
* 流处理辅助工具函数
*/
/**
* 检查响应流是否仍然可写(客户端连接是否有效)
* @param {import('http').ServerResponse} stream - HTTP响应流
* @returns {boolean} 如果流可写返回true否则返回false
*/
function isStreamWritable(stream) {
if (!stream) {
return false
}
// 检查流是否已销毁
if (stream.destroyed) {
return false
}
// 检查底层socket是否已销毁
if (stream.socket?.destroyed) {
return false
}
// 检查流是否已结束写入
if (stream.writableEnded) {
return false
}
return true
}
module.exports = {
isStreamWritable
}

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

@@ -0,0 +1,202 @@
'use strict'
const { v4: uuidv4 } = require('uuid')
/**
* 预热请求拦截器
* 检测并拦截低价值请求标题生成、Warmup等直接返回模拟响应
*/
/**
* 检测是否为预热请求
* @param {Object} body - 请求体
* @returns {boolean}
*/
function isWarmupRequest(body) {
if (!body) {
return false
}
// 检查 messages
if (body.messages && Array.isArray(body.messages)) {
for (const msg of body.messages) {
// 处理 content 为数组的情况
if (Array.isArray(msg.content)) {
for (const content of msg.content) {
if (content.type === 'text' && typeof content.text === 'string') {
if (isTitleOrWarmupText(content.text)) {
return true
}
}
}
}
// 处理 content 为字符串的情况
if (typeof msg.content === 'string') {
if (isTitleOrWarmupText(msg.content)) {
return true
}
}
}
}
// 检查 system prompt
if (body.system) {
const systemText = extractSystemText(body.system)
if (isTitleExtractionSystemPrompt(systemText)) {
return true
}
}
return false
}
/**
* 检查文本是否为标题生成或Warmup请求
*/
function isTitleOrWarmupText(text) {
if (!text) {
return false
}
return (
text.includes('Please write a 5-10 word title for the following conversation:') ||
text === 'Warmup'
)
}
/**
* 检查system prompt是否为标题提取类型
*/
function isTitleExtractionSystemPrompt(systemText) {
if (!systemText) {
return false
}
return systemText.includes(
'nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title'
)
}
/**
* 从system字段提取文本
*/
function extractSystemText(system) {
if (typeof system === 'string') {
return system
}
if (Array.isArray(system)) {
return system.map((s) => (typeof s === 'object' ? s.text || '' : String(s))).join('')
}
return ''
}
/**
* 生成模拟的非流式响应
* @param {string} model - 模型名称
* @returns {Object}
*/
function buildMockWarmupResponse(model) {
return {
id: `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'New Conversation' }],
model: model || 'claude-3-5-sonnet-20241022',
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 2
}
}
}
/**
* 发送模拟的流式响应
* @param {Object} res - Express response对象
* @param {string} model - 模型名称
*/
function sendMockWarmupStream(res, model) {
const effectiveModel = model || 'claude-3-5-sonnet-20241022'
const messageId = `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`
const events = [
{
event: 'message_start',
data: {
message: {
content: [],
id: messageId,
model: effectiveModel,
role: 'assistant',
stop_reason: null,
stop_sequence: null,
type: 'message',
usage: { input_tokens: 10, output_tokens: 0 }
},
type: 'message_start'
}
},
{
event: 'content_block_start',
data: {
content_block: { text: '', type: 'text' },
index: 0,
type: 'content_block_start'
}
},
{
event: 'content_block_delta',
data: {
delta: { text: 'New', type: 'text_delta' },
index: 0,
type: 'content_block_delta'
}
},
{
event: 'content_block_delta',
data: {
delta: { text: ' Conversation', type: 'text_delta' },
index: 0,
type: 'content_block_delta'
}
},
{
event: 'content_block_stop',
data: { index: 0, type: 'content_block_stop' }
},
{
event: 'message_delta',
data: {
delta: { stop_reason: 'end_turn', stop_sequence: null },
type: 'message_delta',
usage: { input_tokens: 10, output_tokens: 2 }
}
},
{
event: 'message_stop',
data: { type: 'message_stop' }
}
]
let index = 0
const sendNext = () => {
if (index >= events.length) {
res.end()
return
}
const { event, data } = events[index]
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
index++
// 模拟网络延迟
setTimeout(sendNext, 20)
}
sendNext()
}
module.exports = {
isWarmupRequest,
buildMockWarmupResponse,
sendMockWarmupStream
}

View File

@@ -62,10 +62,16 @@ class ClaudeCodeValidator {
for (const entry of systemEntries) {
const rawText = typeof entry?.text === 'string' ? entry.text : ''
const { bestScore } = bestSimilarityByTemplates(rawText)
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)
if (bestScore < threshold) {
logger.error(
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
)
const preview = typeof maskedRaw === 'string' ? maskedRaw.slice(0, 200) : ''
logger.warn(
`Claude system prompt detail: templateId=${templateId || 'unknown'}, preview=${preview}${
maskedRaw && maskedRaw.length > 200 ? '…' : ''
}`
)
return false
}

View File

@@ -125,8 +125,12 @@ class CodexCliValidator {
const part1 = parts1[i] || 0
const part2 = parts2[i] || 0
if (part1 < part2) return -1
if (part1 > part2) return 1
if (part1 < part2) {
return -1
}
if (part1 > part2) {
return 1
}
}
return 0

View File

@@ -53,7 +53,7 @@ class GeminiCliValidator {
// 2. 对于 /gemini 路径,检查是否包含 generateContent
if (path.includes('generateContent')) {
// 包含 generateContent 的路径需要验证 User-Agent
const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i
if (!geminiCliPattern.test(userAgent)) {
logger.debug(
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
@@ -84,8 +84,12 @@ class GeminiCliValidator {
const part1 = parts1[i] || 0
const part2 = parts2[i] || 0
if (part1 < part2) return -1
if (part1 > part2) return 1
if (part1 < part2) {
return -1
}
if (part1 > part2) {
return 1
}
}
return 0

View File

@@ -0,0 +1,218 @@
// Mock logger避免测试输出污染控制台
jest.mock('../src/utils/logger', () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}))
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
const { AccountBalanceService } = accountBalanceServiceModule
describe('AccountBalanceService', () => {
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
afterEach(() => {
if (originalBalanceScriptEnabled === undefined) {
delete process.env.BALANCE_SCRIPT_ENABLED
} else {
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
}
})
const mockLogger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}
const buildMockRedis = () => ({
getLocalBalance: jest.fn().mockResolvedValue(null),
setLocalBalance: jest.fn().mockResolvedValue(undefined),
getAccountBalance: jest.fn().mockResolvedValue(null),
setAccountBalance: jest.fn().mockResolvedValue(undefined),
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
getAccountUsageStats: jest.fn().mockResolvedValue({
total: { requests: 10 },
daily: { requests: 2, cost: 20 },
monthly: { requests: 5 }
}),
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
})
it('should normalize platform aliases', () => {
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
expect(service.normalizePlatform('claude-official')).toBe('claude')
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
})
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
queryApi: false,
useCache: true
})
expect(result.success).toBe(true)
expect(result.data.source).toBe('local')
expect(result.data.balance.amount).toBeCloseTo(80, 6)
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
})
it('should use cached balance when account has no dailyQuota', async () => {
const mockRedis = buildMockRedis()
mockRedis.getAccountBalance.mockResolvedValue({
status: 'success',
balance: 12.34,
currency: 'USD',
quota: null,
errorMessage: '',
lastRefreshAt: '2025-01-01T00:00:00Z',
ttlSeconds: 120
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const account = { id: 'acct-2', name: 'B' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: false,
useCache: true
})
expect(result.data.source).toBe('cache')
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
})
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
service.registerProvider('openai', {
queryBalance: () => {
throw new Error('boom')
}
})
const account = { id: 'acct-3', name: 'C' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
expect(result.data.source).toBe('local')
expect(result.data.status).toBe('error')
expect(result.data.error).toBe('boom')
})
it('should ignore script config when balance script is disabled', async () => {
process.env.BALANCE_SCRIPT_ENABLED = 'false'
const mockRedis = buildMockRedis()
mockRedis.getBalanceScriptConfig.mockResolvedValue({
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
service.registerProvider('openai', provider)
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
const account = { id: 'acct-script-off', name: 'S' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(provider.queryBalance).toHaveBeenCalled()
expect(scriptSpy).not.toHaveBeenCalled()
expect(result.data.source).toBe('api')
})
it('should prefer script when configured and enabled', async () => {
process.env.BALANCE_SCRIPT_ENABLED = 'true'
const mockRedis = buildMockRedis()
mockRedis.getBalanceScriptConfig.mockResolvedValue({
scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })'
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
service.registerProvider('openai', provider)
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
status: 'success',
balance: 3,
currency: 'USD',
quota: null,
queryMethod: 'script',
rawData: { ok: true },
lastRefreshAt: '2025-01-01T00:00:00Z',
errorMessage: ''
})
const account = { id: 'acct-script-on', name: 'T' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(provider.queryBalance).not.toHaveBeenCalled()
expect(result.data.source).toBe('api')
expect(result.data.balance.amount).toBeCloseTo(3, 6)
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
})
it('should count low balance once per account in summary', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service.getSupportedPlatforms = () => ['claude-console']
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
service._getAccountBalanceForAccount = async () => ({
success: true,
data: {
accountId: 'acct-4',
platform: 'claude-console',
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
quota: { percentage: 95 },
statistics: { totalCost: 1 },
source: 'local',
lastRefreshAt: '2025-01-01T00:00:00Z',
cacheExpiresAt: null,
status: 'success',
error: null
}
})
const summary = await service.getBalanceSummary()
expect(summary.lowBalanceCount).toBe(1)
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
})
})

View File

@@ -0,0 +1,860 @@
/**
* 并发请求排队功能集成测试
*
* 测试分为三个层次:
* 1. Mock 测试 - 测试核心逻辑,不需要真实 Redis
* 2. Redis 方法测试 - 测试 Redis 操作的原子性和正确性
* 3. 端到端场景测试 - 测试完整的排队流程
*
* 运行方式:
* - npm test -- concurrencyQueue.integration # 运行所有测试Mock 部分)
* - REDIS_TEST=1 npm test -- concurrencyQueue.integration # 包含真实 Redis 测试
*/
// Mock logger to avoid console output during tests
jest.mock('../src/utils/logger', () => ({
api: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
database: jest.fn(),
security: jest.fn()
}))
const redis = require('../src/models/redis')
const claudeRelayConfigService = require('../src/services/claudeRelayConfigService')
// Helper: sleep function
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Helper: 创建模拟的 req/res 对象
function createMockReqRes() {
const listeners = {}
const req = {
destroyed: false,
once: jest.fn((event, handler) => {
listeners[`req:${event}`] = handler
}),
removeListener: jest.fn((event) => {
delete listeners[`req:${event}`]
}),
// 触发事件的辅助方法
emit: (event) => {
const handler = listeners[`req:${event}`]
if (handler) {
handler()
}
}
}
const res = {
once: jest.fn((event, handler) => {
listeners[`res:${event}`] = handler
}),
removeListener: jest.fn((event) => {
delete listeners[`res:${event}`]
}),
emit: (event) => {
const handler = listeners[`res:${event}`]
if (handler) {
handler()
}
}
}
return { req, res, listeners }
}
// ============================================
// 第一部分Mock 测试 - waitForConcurrencySlot 核心逻辑
// ============================================
describe('ConcurrencyQueue Integration Tests', () => {
describe('Part 1: waitForConcurrencySlot Logic (Mocked)', () => {
// 导入 auth 模块中的 waitForConcurrencySlot
// 由于它是内部函数,我们需要通过测试其行为来验证
// 这里我们模拟整个流程
let mockRedis
beforeEach(() => {
jest.clearAllMocks()
// 创建 Redis mock
mockRedis = {
concurrencyCount: {},
queueCount: {},
stats: {},
waitTimes: {},
globalWaitTimes: []
}
// Mock Redis 并发方法
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (keyId, requestId, _lease) => {
if (!mockRedis.concurrencyCount[keyId]) {
mockRedis.concurrencyCount[keyId] = new Set()
}
mockRedis.concurrencyCount[keyId].add(requestId)
return mockRedis.concurrencyCount[keyId].size
})
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (keyId, requestId) => {
if (mockRedis.concurrencyCount[keyId]) {
mockRedis.concurrencyCount[keyId].delete(requestId)
return mockRedis.concurrencyCount[keyId].size
}
return 0
})
// Mock 排队计数方法
jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async (keyId) => {
mockRedis.queueCount[keyId] = (mockRedis.queueCount[keyId] || 0) + 1
return mockRedis.queueCount[keyId]
})
jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async (keyId) => {
mockRedis.queueCount[keyId] = Math.max(0, (mockRedis.queueCount[keyId] || 0) - 1)
return mockRedis.queueCount[keyId]
})
jest
.spyOn(redis, 'getConcurrencyQueueCount')
.mockImplementation(async (keyId) => mockRedis.queueCount[keyId] || 0)
// Mock 统计方法
jest.spyOn(redis, 'incrConcurrencyQueueStats').mockImplementation(async (keyId, field) => {
if (!mockRedis.stats[keyId]) {
mockRedis.stats[keyId] = {}
}
mockRedis.stats[keyId][field] = (mockRedis.stats[keyId][field] || 0) + 1
return mockRedis.stats[keyId][field]
})
jest.spyOn(redis, 'recordQueueWaitTime').mockResolvedValue(undefined)
jest.spyOn(redis, 'recordGlobalQueueWaitTime').mockResolvedValue(undefined)
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('Slot Acquisition Flow', () => {
it('should acquire slot immediately when under concurrency limit', async () => {
// 模拟 waitForConcurrencySlot 的行为
const keyId = 'test-key-1'
const requestId = 'req-1'
const concurrencyLimit = 5
// 直接测试 incrConcurrency 的行为
const count = await redis.incrConcurrency(keyId, requestId, 300)
expect(count).toBe(1)
expect(count).toBeLessThanOrEqual(concurrencyLimit)
})
it('should track multiple concurrent requests correctly', async () => {
const keyId = 'test-key-2'
const concurrencyLimit = 3
// 模拟多个并发请求
const results = []
for (let i = 1; i <= 5; i++) {
const count = await redis.incrConcurrency(keyId, `req-${i}`, 300)
results.push({ requestId: `req-${i}`, count, exceeds: count > concurrencyLimit })
}
// 前3个应该在限制内
expect(results[0].exceeds).toBe(false)
expect(results[1].exceeds).toBe(false)
expect(results[2].exceeds).toBe(false)
// 后2个超过限制
expect(results[3].exceeds).toBe(true)
expect(results[4].exceeds).toBe(true)
})
it('should release slot and allow next request', async () => {
const keyId = 'test-key-3'
const concurrencyLimit = 1
// 第一个请求获取槽位
const count1 = await redis.incrConcurrency(keyId, 'req-1', 300)
expect(count1).toBe(1)
// 第二个请求超限
const count2 = await redis.incrConcurrency(keyId, 'req-2', 300)
expect(count2).toBe(2)
expect(count2).toBeGreaterThan(concurrencyLimit)
// 释放第二个请求(因为超限)
await redis.decrConcurrency(keyId, 'req-2')
// 释放第一个请求
await redis.decrConcurrency(keyId, 'req-1')
// 现在第三个请求应该能获取
const count3 = await redis.incrConcurrency(keyId, 'req-3', 300)
expect(count3).toBe(1)
})
})
describe('Queue Count Management', () => {
it('should increment and decrement queue count atomically', async () => {
const keyId = 'test-key-4'
// 增加排队计数
const count1 = await redis.incrConcurrencyQueue(keyId, 60000)
expect(count1).toBe(1)
const count2 = await redis.incrConcurrencyQueue(keyId, 60000)
expect(count2).toBe(2)
// 减少排队计数
const count3 = await redis.decrConcurrencyQueue(keyId)
expect(count3).toBe(1)
const count4 = await redis.decrConcurrencyQueue(keyId)
expect(count4).toBe(0)
})
it('should not go below zero on decrement', async () => {
const keyId = 'test-key-5'
// 直接减少(没有先增加)
const count = await redis.decrConcurrencyQueue(keyId)
expect(count).toBe(0)
})
it('should handle concurrent queue operations', async () => {
const keyId = 'test-key-6'
// 并发增加
const increments = await Promise.all([
redis.incrConcurrencyQueue(keyId, 60000),
redis.incrConcurrencyQueue(keyId, 60000),
redis.incrConcurrencyQueue(keyId, 60000)
])
// 所有增量应该是连续的
const sortedIncrements = [...increments].sort((a, b) => a - b)
expect(sortedIncrements).toEqual([1, 2, 3])
})
})
describe('Statistics Tracking', () => {
it('should track entered/success/timeout/cancelled stats', async () => {
const keyId = 'test-key-7'
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'success')
await redis.incrConcurrencyQueueStats(keyId, 'timeout')
await redis.incrConcurrencyQueueStats(keyId, 'cancelled')
expect(mockRedis.stats[keyId]).toEqual({
entered: 2,
success: 1,
timeout: 1,
cancelled: 1
})
})
})
describe('Client Disconnection Handling', () => {
it('should detect client disconnection via close event', async () => {
const { req } = createMockReqRes()
let clientDisconnected = false
// 设置监听器
req.once('close', () => {
clientDisconnected = true
})
// 模拟客户端断开
req.emit('close')
expect(clientDisconnected).toBe(true)
})
it('should detect pre-destroyed request', () => {
const { req } = createMockReqRes()
req.destroyed = true
expect(req.destroyed).toBe(true)
})
})
describe('Exponential Backoff Simulation', () => {
it('should increase poll interval with backoff', () => {
const config = {
pollIntervalMs: 200,
maxPollIntervalMs: 2000,
backoffFactor: 1.5,
jitterRatio: 0 // 禁用抖动以便测试
}
let interval = config.pollIntervalMs
const intervals = [interval]
for (let i = 0; i < 5; i++) {
interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs)
intervals.push(interval)
}
// 验证指数增长
expect(intervals[1]).toBe(300) // 200 * 1.5
expect(intervals[2]).toBe(450) // 300 * 1.5
expect(intervals[3]).toBe(675) // 450 * 1.5
expect(intervals[4]).toBe(1012.5) // 675 * 1.5
expect(intervals[5]).toBe(1518.75) // 1012.5 * 1.5
})
it('should cap interval at maximum', () => {
const config = {
pollIntervalMs: 1000,
maxPollIntervalMs: 2000,
backoffFactor: 1.5
}
let interval = config.pollIntervalMs
for (let i = 0; i < 10; i++) {
interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs)
}
expect(interval).toBe(2000)
})
it('should apply jitter within expected range', () => {
const baseInterval = 1000
const jitterRatio = 0.2 // ±20%
const results = []
for (let i = 0; i < 100; i++) {
const randomValue = Math.random()
const jitter = baseInterval * jitterRatio * (randomValue * 2 - 1)
const finalInterval = baseInterval + jitter
results.push(finalInterval)
}
const min = Math.min(...results)
const max = Math.max(...results)
// 所有结果应该在 [800, 1200] 范围内
expect(min).toBeGreaterThanOrEqual(800)
expect(max).toBeLessThanOrEqual(1200)
})
})
})
// ============================================
// 第二部分:并发竞争场景测试
// ============================================
describe('Part 2: Concurrent Race Condition Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('Race Condition: Multiple Requests Competing for Same Slot', () => {
it('should handle race condition when multiple requests try to acquire last slot', async () => {
const keyId = 'race-test-1'
const concurrencyLimit = 1
const concurrencyState = { count: 0, holders: new Set() }
// 模拟原子的 incrConcurrency
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (key, reqId) => {
// 模拟原子操作
concurrencyState.count++
concurrencyState.holders.add(reqId)
return concurrencyState.count
})
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (key, reqId) => {
if (concurrencyState.holders.has(reqId)) {
concurrencyState.count--
concurrencyState.holders.delete(reqId)
}
return concurrencyState.count
})
// 5个请求同时竞争1个槽位
const requests = Array.from({ length: 5 }, (_, i) => `req-${i + 1}`)
const acquireResults = await Promise.all(
requests.map(async (reqId) => {
const count = await redis.incrConcurrency(keyId, reqId, 300)
const acquired = count <= concurrencyLimit
if (!acquired) {
// 超限,释放
await redis.decrConcurrency(keyId, reqId)
}
return { reqId, count, acquired }
})
)
// 只有一个请求应该成功获取槽位
const successfulAcquires = acquireResults.filter((r) => r.acquired)
expect(successfulAcquires.length).toBe(1)
// 最终并发计数应该是1
expect(concurrencyState.count).toBe(1)
})
it('should maintain consistency under high contention', async () => {
const keyId = 'race-test-2'
const concurrencyLimit = 3
const requestCount = 20
const concurrencyState = { count: 0, maxSeen: 0 }
jest.spyOn(redis, 'incrConcurrency').mockImplementation(async () => {
concurrencyState.count++
concurrencyState.maxSeen = Math.max(concurrencyState.maxSeen, concurrencyState.count)
return concurrencyState.count
})
jest.spyOn(redis, 'decrConcurrency').mockImplementation(async () => {
concurrencyState.count = Math.max(0, concurrencyState.count - 1)
return concurrencyState.count
})
// 模拟多轮请求
const activeRequests = []
for (let i = 0; i < requestCount; i++) {
const count = await redis.incrConcurrency(keyId, `req-${i}`, 300)
if (count <= concurrencyLimit) {
activeRequests.push(`req-${i}`)
// 模拟处理时间后释放
setTimeout(async () => {
await redis.decrConcurrency(keyId, `req-${i}`)
}, Math.random() * 50)
} else {
await redis.decrConcurrency(keyId, `req-${i}`)
}
// 随机延迟
await sleep(Math.random() * 10)
}
// 等待所有请求完成
await sleep(100)
// 最大并发不应超过限制
expect(concurrencyState.maxSeen).toBeLessThanOrEqual(concurrencyLimit + requestCount) // 允许短暂超限
})
})
describe('Queue Overflow Protection', () => {
it('should reject requests when queue is full', async () => {
const keyId = 'overflow-test-1'
const maxQueueSize = 5
const queueState = { count: 0 }
jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async () => {
queueState.count++
return queueState.count
})
jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async () => {
queueState.count = Math.max(0, queueState.count - 1)
return queueState.count
})
const results = []
// 尝试10个请求进入队列
for (let i = 0; i < 10; i++) {
const queueCount = await redis.incrConcurrencyQueue(keyId, 60000)
if (queueCount > maxQueueSize) {
// 队列满,释放并拒绝
await redis.decrConcurrencyQueue(keyId)
results.push({ index: i, accepted: false })
} else {
results.push({ index: i, accepted: true, position: queueCount })
}
}
const accepted = results.filter((r) => r.accepted)
const rejected = results.filter((r) => !r.accepted)
expect(accepted.length).toBe(5)
expect(rejected.length).toBe(5)
})
})
})
// ============================================
// 第三部分:真实 Redis 集成测试(可选)
// ============================================
describe('Part 3: Real Redis Integration Tests', () => {
const skipRealRedis = !process.env.REDIS_TEST
// 辅助函数:检查 Redis 连接
async function checkRedisConnection() {
try {
const client = redis.getClient()
if (!client) {
return false
}
await client.ping()
return true
} catch {
return false
}
}
beforeAll(async () => {
if (skipRealRedis) {
console.log('⏭️ Skipping real Redis tests (set REDIS_TEST=1 to enable)')
return
}
const connected = await checkRedisConnection()
if (!connected) {
console.log('⚠️ Redis not connected, skipping real Redis tests')
}
})
// 清理测试数据
afterEach(async () => {
if (skipRealRedis) {
return
}
try {
const client = redis.getClient()
if (!client) {
return
}
// 清理测试键
const testKeys = await client.keys('concurrency:queue:test-*')
if (testKeys.length > 0) {
await client.del(...testKeys)
}
} catch {
// 忽略清理错误
}
})
describe('Redis Queue Operations', () => {
const testOrSkip = skipRealRedis ? it.skip : it
testOrSkip('should atomically increment queue count with TTL', async () => {
const keyId = 'test-redis-queue-1'
const timeoutMs = 5000
const count1 = await redis.incrConcurrencyQueue(keyId, timeoutMs)
expect(count1).toBe(1)
const count2 = await redis.incrConcurrencyQueue(keyId, timeoutMs)
expect(count2).toBe(2)
// 验证 TTL 被设置
const client = redis.getClient()
const ttl = await client.ttl(`concurrency:queue:${keyId}`)
expect(ttl).toBeGreaterThan(0)
expect(ttl).toBeLessThanOrEqual(Math.ceil(timeoutMs / 1000) + 30)
})
testOrSkip('should atomically decrement and delete when zero', async () => {
const keyId = 'test-redis-queue-2'
await redis.incrConcurrencyQueue(keyId, 60000)
const count = await redis.decrConcurrencyQueue(keyId)
expect(count).toBe(0)
// 验证键已删除
const client = redis.getClient()
const exists = await client.exists(`concurrency:queue:${keyId}`)
expect(exists).toBe(0)
})
testOrSkip('should handle concurrent increments correctly', async () => {
const keyId = 'test-redis-queue-3'
const numRequests = 10
// 并发增加
const results = await Promise.all(
Array.from({ length: numRequests }, () => redis.incrConcurrencyQueue(keyId, 60000))
)
// 所有结果应该是 1 到 numRequests
const sorted = [...results].sort((a, b) => a - b)
expect(sorted).toEqual(Array.from({ length: numRequests }, (_, i) => i + 1))
})
})
describe('Redis Stats Operations', () => {
const testOrSkip = skipRealRedis ? it.skip : it
testOrSkip('should track queue statistics correctly', async () => {
const keyId = 'test-redis-stats-1'
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'entered')
await redis.incrConcurrencyQueueStats(keyId, 'success')
await redis.incrConcurrencyQueueStats(keyId, 'timeout')
const stats = await redis.getConcurrencyQueueStats(keyId)
expect(stats.entered).toBe(2)
expect(stats.success).toBe(1)
expect(stats.timeout).toBe(1)
expect(stats.cancelled).toBe(0)
})
testOrSkip('should record and retrieve wait times', async () => {
const keyId = 'test-redis-wait-1'
const waitTimes = [100, 200, 150, 300, 250]
for (const wt of waitTimes) {
await redis.recordQueueWaitTime(keyId, wt)
}
const recorded = await redis.getQueueWaitTimes(keyId)
// 应该按 LIFO 顺序存储
expect(recorded.length).toBe(5)
expect(recorded[0]).toBe(250) // 最后插入的在前面
})
testOrSkip('should record global wait times', async () => {
const waitTimes = [500, 600, 700]
for (const wt of waitTimes) {
await redis.recordGlobalQueueWaitTime(wt)
}
const recorded = await redis.getGlobalQueueWaitTimes()
expect(recorded.length).toBeGreaterThanOrEqual(3)
})
})
describe('Redis Cleanup Operations', () => {
const testOrSkip = skipRealRedis ? it.skip : it
testOrSkip('should clear specific queue', async () => {
const keyId = 'test-redis-clear-1'
await redis.incrConcurrencyQueue(keyId, 60000)
await redis.incrConcurrencyQueue(keyId, 60000)
const cleared = await redis.clearConcurrencyQueue(keyId)
expect(cleared).toBe(true)
const count = await redis.getConcurrencyQueueCount(keyId)
expect(count).toBe(0)
})
testOrSkip('should clear all queues but preserve stats', async () => {
const keyId1 = 'test-redis-clearall-1'
const keyId2 = 'test-redis-clearall-2'
// 创建队列和统计
await redis.incrConcurrencyQueue(keyId1, 60000)
await redis.incrConcurrencyQueue(keyId2, 60000)
await redis.incrConcurrencyQueueStats(keyId1, 'entered')
// 清理所有队列
const cleared = await redis.clearAllConcurrencyQueues()
expect(cleared).toBeGreaterThanOrEqual(2)
// 验证队列已清理
const count1 = await redis.getConcurrencyQueueCount(keyId1)
const count2 = await redis.getConcurrencyQueueCount(keyId2)
expect(count1).toBe(0)
expect(count2).toBe(0)
// 统计应该保留
const stats = await redis.getConcurrencyQueueStats(keyId1)
expect(stats.entered).toBe(1)
})
})
})
// ============================================
// 第四部分:配置服务集成测试
// ============================================
describe('Part 4: Configuration Service Integration', () => {
beforeEach(() => {
// 清除配置缓存
claudeRelayConfigService.clearCache()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('Queue Configuration', () => {
it('should return default queue configuration', async () => {
jest.spyOn(redis, 'getClient').mockReturnValue(null)
const config = await claudeRelayConfigService.getConfig()
expect(config.concurrentRequestQueueEnabled).toBe(false)
expect(config.concurrentRequestQueueMaxSize).toBe(3)
expect(config.concurrentRequestQueueMaxSizeMultiplier).toBe(0)
expect(config.concurrentRequestQueueTimeoutMs).toBe(10000)
})
it('should calculate max queue size correctly', async () => {
const testCases = [
{ concurrencyLimit: 5, multiplier: 2, fixedMin: 3, expected: 10 }, // 5*2=10 > 3
{ concurrencyLimit: 1, multiplier: 1, fixedMin: 5, expected: 5 }, // 1*1=1 < 5
{ concurrencyLimit: 10, multiplier: 0.5, fixedMin: 3, expected: 5 }, // 10*0.5=5 > 3
{ concurrencyLimit: 2, multiplier: 1, fixedMin: 10, expected: 10 } // 2*1=2 < 10
]
for (const tc of testCases) {
const maxQueueSize = Math.max(tc.concurrencyLimit * tc.multiplier, tc.fixedMin)
expect(maxQueueSize).toBe(tc.expected)
}
})
})
})
// ============================================
// 第五部分:端到端场景测试
// ============================================
describe('Part 5: End-to-End Scenario Tests', () => {
describe('Scenario: Claude Code Agent Parallel Tool Calls', () => {
it('should handle burst of parallel tool results', async () => {
// 模拟 Claude Code Agent 发送多个并行工具结果的场景
const concurrencyLimit = 2
const maxQueueSize = 5
const state = {
concurrency: 0,
queue: 0,
completed: 0,
rejected: 0
}
// 模拟 8 个并行工具结果请求
const requests = Array.from({ length: 8 }, (_, i) => ({
id: `tool-result-${i + 1}`,
startTime: Date.now()
}))
// 模拟处理逻辑
async function processRequest(req) {
// 尝试获取并发槽位
state.concurrency++
if (state.concurrency > concurrencyLimit) {
// 超限,进入队列
state.concurrency--
state.queue++
if (state.queue > maxQueueSize) {
// 队列满,拒绝
state.queue--
state.rejected++
return { ...req, status: 'rejected', reason: 'queue_full' }
}
// 等待槽位(模拟)
await sleep(Math.random() * 100)
state.queue--
state.concurrency++
}
// 处理请求
await sleep(50) // 模拟处理时间
state.concurrency--
state.completed++
return { ...req, status: 'completed', duration: Date.now() - req.startTime }
}
const results = await Promise.all(requests.map(processRequest))
const completed = results.filter((r) => r.status === 'completed')
const rejected = results.filter((r) => r.status === 'rejected')
// 大部分请求应该完成
expect(completed.length).toBeGreaterThan(0)
// 可能有一些被拒绝
expect(state.rejected).toBe(rejected.length)
console.log(
` ✓ Completed: ${completed.length}, Rejected: ${rejected.length}, Max concurrent: ${concurrencyLimit}`
)
})
})
describe('Scenario: Graceful Degradation', () => {
it('should fallback when Redis fails', async () => {
jest
.spyOn(redis, 'incrConcurrencyQueue')
.mockRejectedValue(new Error('Redis connection lost'))
// 模拟降级行为Redis 失败时直接拒绝而不是崩溃
let result
try {
await redis.incrConcurrencyQueue('fallback-test', 60000)
result = { success: true }
} catch (error) {
// 优雅降级:返回 429 而不是 500
result = { success: false, fallback: true, error: error.message }
}
expect(result.fallback).toBe(true)
expect(result.error).toContain('Redis')
})
})
describe('Scenario: Timeout Behavior', () => {
it('should respect queue timeout', async () => {
const timeoutMs = 100
const startTime = Date.now()
// 模拟等待超时
await new Promise((resolve) => setTimeout(resolve, timeoutMs))
const elapsed = Date.now() - startTime
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10) // 允许 10ms 误差
})
it('should track timeout statistics', async () => {
const stats = { entered: 0, success: 0, timeout: 0, cancelled: 0 }
// 模拟多个请求,部分超时
const requests = [
{ id: 'req-1', willTimeout: false },
{ id: 'req-2', willTimeout: true },
{ id: 'req-3', willTimeout: false },
{ id: 'req-4', willTimeout: true }
]
for (const req of requests) {
stats.entered++
if (req.willTimeout) {
stats.timeout++
} else {
stats.success++
}
}
expect(stats.entered).toBe(4)
expect(stats.success).toBe(2)
expect(stats.timeout).toBe(2)
// 成功率应该是 50%
const successRate = (stats.success / stats.entered) * 100
expect(successRate).toBe(50)
})
})
})
})

View File

@@ -0,0 +1,278 @@
/**
* 并发请求排队功能测试
* 测试排队逻辑中的核心算法:百分位数计算、等待时间统计、指数退避等
*
* 注意Redis 方法的测试需要集成测试环境,这里主要测试纯算法逻辑
*/
// Mock logger to avoid console output during tests
jest.mock('../src/utils/logger', () => ({
api: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
database: jest.fn(),
security: jest.fn()
}))
// 使用共享的统计工具函数(与生产代码一致)
const { getPercentile, calculateWaitTimeStats } = require('../src/utils/statsHelper')
describe('ConcurrencyQueue', () => {
describe('Percentile Calculation (nearest-rank method)', () => {
// 直接测试共享工具函数,确保与生产代码行为一致
it('should return 0 for empty array', () => {
expect(getPercentile([], 50)).toBe(0)
})
it('should return single element for single-element array', () => {
expect(getPercentile([100], 50)).toBe(100)
expect(getPercentile([100], 99)).toBe(100)
})
it('should return min for percentile 0', () => {
expect(getPercentile([10, 20, 30, 40, 50], 0)).toBe(10)
})
it('should return max for percentile 100', () => {
expect(getPercentile([10, 20, 30, 40, 50], 100)).toBe(50)
})
it('should calculate P50 correctly for len=10', () => {
// For [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] (len=10)
// P50: ceil(50/100 * 10) - 1 = ceil(5) - 1 = 4 → value at index 4 = 50
const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
expect(getPercentile(arr, 50)).toBe(50)
})
it('should calculate P90 correctly for len=10', () => {
// For len=10, P90: ceil(90/100 * 10) - 1 = ceil(9) - 1 = 8 → value at index 8 = 90
const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
expect(getPercentile(arr, 90)).toBe(90)
})
it('should calculate P99 correctly for len=100', () => {
// For len=100, P99: ceil(99/100 * 100) - 1 = ceil(99) - 1 = 98
const arr = Array.from({ length: 100 }, (_, i) => i + 1)
expect(getPercentile(arr, 99)).toBe(99)
})
it('should handle two-element array correctly', () => {
// For [10, 20] (len=2)
// P50: ceil(50/100 * 2) - 1 = ceil(1) - 1 = 0 → value = 10
expect(getPercentile([10, 20], 50)).toBe(10)
})
it('should handle negative percentile as 0', () => {
expect(getPercentile([10, 20, 30], -10)).toBe(10)
})
it('should handle percentile > 100 as 100', () => {
expect(getPercentile([10, 20, 30], 150)).toBe(30)
})
})
describe('Wait Time Stats Calculation', () => {
// 直接测试共享工具函数
it('should return null for empty array', () => {
expect(calculateWaitTimeStats([])).toBeNull()
})
it('should return null for null input', () => {
expect(calculateWaitTimeStats(null)).toBeNull()
})
it('should return null for undefined input', () => {
expect(calculateWaitTimeStats(undefined)).toBeNull()
})
it('should calculate stats correctly for typical data', () => {
const waitTimes = [100, 200, 150, 300, 250, 180, 220, 280, 190, 210]
const stats = calculateWaitTimeStats(waitTimes)
expect(stats.count).toBe(10)
expect(stats.min).toBe(100)
expect(stats.max).toBe(300)
// Sum: 100+150+180+190+200+210+220+250+280+300 = 2080
expect(stats.avg).toBe(208)
expect(stats.sampleSizeWarning).toBeUndefined()
})
it('should add warning for small sample size (< 10)', () => {
const waitTimes = [100, 200, 300]
const stats = calculateWaitTimeStats(waitTimes)
expect(stats.count).toBe(3)
expect(stats.sampleSizeWarning).toBe('Results may be inaccurate due to small sample size')
})
it('should handle single value', () => {
const stats = calculateWaitTimeStats([500])
expect(stats.count).toBe(1)
expect(stats.min).toBe(500)
expect(stats.max).toBe(500)
expect(stats.avg).toBe(500)
expect(stats.p50).toBe(500)
expect(stats.p90).toBe(500)
expect(stats.p99).toBe(500)
})
it('should sort input array before calculating', () => {
const waitTimes = [500, 100, 300, 200, 400]
const stats = calculateWaitTimeStats(waitTimes)
expect(stats.min).toBe(100)
expect(stats.max).toBe(500)
})
it('should not modify original array', () => {
const waitTimes = [500, 100, 300]
calculateWaitTimeStats(waitTimes)
expect(waitTimes).toEqual([500, 100, 300])
})
})
describe('Exponential Backoff with Jitter', () => {
/**
* 指数退避计算函数(与 auth.js 中的实现一致)
* @param {number} currentInterval - 当前轮询间隔
* @param {number} backoffFactor - 退避系数
* @param {number} jitterRatio - 抖动比例
* @param {number} maxInterval - 最大间隔
* @param {number} randomValue - 随机值 [0, 1),用于确定性测试
*/
function calculateNextInterval(
currentInterval,
backoffFactor,
jitterRatio,
maxInterval,
randomValue
) {
let nextInterval = currentInterval * backoffFactor
// 抖动范围:[-jitterRatio, +jitterRatio]
const jitter = nextInterval * jitterRatio * (randomValue * 2 - 1)
nextInterval = nextInterval + jitter
return Math.max(1, Math.min(nextInterval, maxInterval))
}
it('should apply exponential backoff without jitter (randomValue=0.5)', () => {
// randomValue = 0.5 gives jitter = 0
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.5)
expect(next).toBe(150) // 100 * 1.5 = 150
})
it('should apply maximum positive jitter (randomValue=1.0)', () => {
// randomValue = 1.0 gives maximum positive jitter (+20%)
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 1.0)
// 100 * 1.5 = 150, jitter = 150 * 0.2 * 1 = 30
expect(next).toBe(180) // 150 + 30
})
it('should apply maximum negative jitter (randomValue=0.0)', () => {
// randomValue = 0.0 gives maximum negative jitter (-20%)
const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.0)
// 100 * 1.5 = 150, jitter = 150 * 0.2 * -1 = -30
expect(next).toBe(120) // 150 - 30
})
it('should respect maximum interval', () => {
const next = calculateNextInterval(800, 1.5, 0.2, 1000, 1.0)
// 800 * 1.5 = 1200, with +20% jitter = 1440, capped at 1000
expect(next).toBe(1000)
})
it('should never go below 1ms even with extreme negative jitter', () => {
const next = calculateNextInterval(1, 1.0, 0.9, 1000, 0.0)
// 1 * 1.0 = 1, jitter = 1 * 0.9 * -1 = -0.9
// 1 - 0.9 = 0.1, but Math.max(1, ...) ensures minimum is 1
expect(next).toBe(1)
})
it('should handle zero jitter ratio', () => {
const next = calculateNextInterval(100, 2.0, 0, 1000, 0.0)
expect(next).toBe(200) // Pure exponential, no jitter
})
it('should handle large backoff factor', () => {
const next = calculateNextInterval(100, 3.0, 0.1, 1000, 0.5)
expect(next).toBe(300) // 100 * 3.0 = 300
})
describe('jitter distribution', () => {
it('should produce values in expected range', () => {
const results = []
// Test with various random values
for (let r = 0; r <= 1; r += 0.1) {
results.push(calculateNextInterval(100, 1.5, 0.2, 1000, r))
}
// All values should be between 120 (150 - 30) and 180 (150 + 30)
expect(Math.min(...results)).toBeGreaterThanOrEqual(120)
expect(Math.max(...results)).toBeLessThanOrEqual(180)
})
})
})
describe('Queue Size Calculation', () => {
/**
* 最大排队数计算(与 auth.js 中的实现一致)
*/
function calculateMaxQueueSize(concurrencyLimit, multiplier, fixedMin) {
return Math.max(concurrencyLimit * multiplier, fixedMin)
}
it('should use multiplier when result is larger', () => {
// concurrencyLimit=10, multiplier=2, fixedMin=5
// max(10*2, 5) = max(20, 5) = 20
expect(calculateMaxQueueSize(10, 2, 5)).toBe(20)
})
it('should use fixed minimum when multiplier result is smaller', () => {
// concurrencyLimit=2, multiplier=1, fixedMin=5
// max(2*1, 5) = max(2, 5) = 5
expect(calculateMaxQueueSize(2, 1, 5)).toBe(5)
})
it('should handle zero multiplier', () => {
// concurrencyLimit=10, multiplier=0, fixedMin=3
// max(10*0, 3) = max(0, 3) = 3
expect(calculateMaxQueueSize(10, 0, 3)).toBe(3)
})
it('should handle fractional multiplier', () => {
// concurrencyLimit=10, multiplier=1.5, fixedMin=5
// max(10*1.5, 5) = max(15, 5) = 15
expect(calculateMaxQueueSize(10, 1.5, 5)).toBe(15)
})
})
describe('TTL Calculation', () => {
/**
* 排队计数器 TTL 计算(与 redis.js 中的实现一致)
*/
function calculateQueueTtl(timeoutMs, bufferSeconds = 30) {
return Math.ceil(timeoutMs / 1000) + bufferSeconds
}
it('should calculate TTL with default buffer', () => {
// 60000ms = 60s + 30s buffer = 90s
expect(calculateQueueTtl(60000)).toBe(90)
})
it('should round up milliseconds to seconds', () => {
// 61500ms = ceil(61.5) = 62s + 30s = 92s
expect(calculateQueueTtl(61500)).toBe(92)
})
it('should handle custom buffer', () => {
// 30000ms = 30s + 60s buffer = 90s
expect(calculateQueueTtl(30000, 60)).toBe(90)
})
it('should handle very short timeout', () => {
// 1000ms = 1s + 30s = 31s
expect(calculateQueueTtl(1000)).toBe(31)
})
})
})

View File

@@ -0,0 +1,434 @@
/**
* 用户消息队列服务测试
* 测试消息类型检测、队列串行行为、延迟间隔、超时处理和功能开关
*/
const redis = require('../src/models/redis')
const userMessageQueueService = require('../src/services/userMessageQueueService')
describe('UserMessageQueueService', () => {
describe('isUserMessageRequest', () => {
it('should return true when last message role is user', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' }
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should return false when last message role is assistant', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' }
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when last message contains tool_result', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Let me check that' },
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'test-id',
content: 'Tool result'
}
]
}
]
}
// tool_result 消息虽然 role 是 user但不是真正的用户消息
// 应该返回 false不进入用户消息队列
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when last message contains multiple tool_results', () => {
const requestBody = {
messages: [
{ role: 'user', content: 'Run multiple tools' },
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'Result 1'
},
{
type: 'tool_result',
tool_use_id: 'tool-2',
content: 'Result 2'
}
]
}
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return true when user message has array content with text type', () => {
const requestBody = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Hello, this is a user message'
}
]
}
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should return true when user message has mixed text and image content', () => {
const requestBody = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'What is in this image?'
},
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: '...' }
}
]
}
]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should return false when messages is empty', () => {
const requestBody = { messages: [] }
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when messages is not an array', () => {
const requestBody = { messages: 'not an array' }
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when messages is undefined', () => {
const requestBody = {}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should return false when requestBody is null', () => {
expect(userMessageQueueService.isUserMessageRequest(null)).toBe(false)
})
it('should return false when requestBody is undefined', () => {
expect(userMessageQueueService.isUserMessageRequest(undefined)).toBe(false)
})
it('should return false when last message has no role', () => {
const requestBody = {
messages: [{ content: 'Hello' }]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
it('should handle single user message', () => {
const requestBody = {
messages: [{ role: 'user', content: 'Hello' }]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
})
it('should handle single assistant message', () => {
const requestBody = {
messages: [{ role: 'assistant', content: 'Hello' }]
}
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
})
})
describe('getConfig', () => {
it('should return config with expected properties', async () => {
const config = await userMessageQueueService.getConfig()
expect(config).toHaveProperty('enabled')
expect(config).toHaveProperty('delayMs')
expect(config).toHaveProperty('timeoutMs')
expect(config).toHaveProperty('lockTtlMs')
expect(typeof config.enabled).toBe('boolean')
expect(typeof config.delayMs).toBe('number')
expect(typeof config.timeoutMs).toBe('number')
expect(typeof config.lockTtlMs).toBe('number')
})
})
describe('isEnabled', () => {
it('should return boolean', async () => {
const enabled = await userMessageQueueService.isEnabled()
expect(typeof enabled).toBe('boolean')
})
})
describe('acquireQueueLock', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('should acquire lock immediately when no lock exists', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: true,
waitMs: 0
})
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result.acquired).toBe(true)
expect(result.requestId).toBe('req-1')
expect(result.error).toBeUndefined()
})
it('should skip lock acquisition when queue disabled', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: false,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
const acquireSpy = jest.spyOn(redis, 'acquireUserMessageLock')
const result = await userMessageQueueService.acquireQueueLock('acct-1')
expect(result.acquired).toBe(true)
expect(result.skipped).toBe(true)
expect(acquireSpy).not.toHaveBeenCalled()
})
it('should generate requestId when not provided', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: true,
waitMs: 0
})
const result = await userMessageQueueService.acquireQueueLock('acct-1')
expect(result.acquired).toBe(true)
expect(result.requestId).toBeDefined()
expect(result.requestId.length).toBeGreaterThan(0)
})
it('should wait and retry when lock is held by another request', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 1000,
lockTtlMs: 120000
})
let callCount = 0
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
callCount++
if (callCount < 3) {
return { acquired: false, waitMs: -1 } // lock held
}
return { acquired: true, waitMs: 0 }
})
// Mock sleep to speed up test
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result.acquired).toBe(true)
expect(callCount).toBe(3)
})
it('should respect delay when previous request just completed', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 1000,
lockTtlMs: 120000
})
let callCount = 0
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
callCount++
if (callCount === 1) {
return { acquired: false, waitMs: 150 } // need to wait 150ms for delay
}
return { acquired: true, waitMs: 0 }
})
const sleepSpy = jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result.acquired).toBe(true)
expect(sleepSpy).toHaveBeenCalledWith(150) // Should wait for delay
})
it('should timeout and return error when wait exceeds timeout', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 100, // very short timeout
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: false,
waitMs: -1 // always held
})
// Use real timers for timeout test but mock sleep to be instant
jest.spyOn(userMessageQueueService, '_sleep').mockImplementation(async () => {
// Simulate time passing
await new Promise((resolve) => setTimeout(resolve, 60))
})
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1', 100)
expect(result.acquired).toBe(false)
expect(result.error).toBe('queue_timeout')
})
})
describe('releaseQueueLock', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('should release lock successfully when holding the lock', async () => {
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(true)
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
expect(result).toBe(true)
expect(redis.releaseUserMessageLock).toHaveBeenCalledWith('acct-1', 'req-1')
})
it('should return false when not holding the lock', async () => {
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(false)
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
expect(result).toBe(false)
})
it('should return false when accountId is missing', async () => {
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
const result = await userMessageQueueService.releaseQueueLock(null, 'req-1')
expect(result).toBe(false)
expect(releaseSpy).not.toHaveBeenCalled()
})
it('should return false when requestId is missing', async () => {
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
const result = await userMessageQueueService.releaseQueueLock('acct-1', null)
expect(result).toBe(false)
expect(releaseSpy).not.toHaveBeenCalled()
})
})
describe('queue serialization behavior', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('should allow different accounts to acquire locks simultaneously', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 200,
timeoutMs: 30000,
lockTtlMs: 120000
})
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
acquired: true,
waitMs: 0
})
const [result1, result2] = await Promise.all([
userMessageQueueService.acquireQueueLock('acct-1', 'req-1'),
userMessageQueueService.acquireQueueLock('acct-2', 'req-2')
])
expect(result1.acquired).toBe(true)
expect(result2.acquired).toBe(true)
})
it('should serialize requests for same account', async () => {
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
enabled: true,
delayMs: 50,
timeoutMs: 5000,
lockTtlMs: 120000
})
const lockState = { held: false, holderId: null }
jest
.spyOn(redis, 'acquireUserMessageLock')
.mockImplementation(async (accountId, requestId) => {
if (!lockState.held) {
lockState.held = true
lockState.holderId = requestId
return { acquired: true, waitMs: 0 }
}
return { acquired: false, waitMs: -1 }
})
jest
.spyOn(redis, 'releaseUserMessageLock')
.mockImplementation(async (accountId, requestId) => {
if (lockState.holderId === requestId) {
lockState.held = false
lockState.holderId = null
return true
}
return false
})
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
// First request acquires lock
const result1 = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
expect(result1.acquired).toBe(true)
// Second request should fail to acquire (lock held)
const acquirePromise = userMessageQueueService.acquireQueueLock('acct-1', 'req-2', 200)
// Release first lock
await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
// Now second request should acquire
const result2 = await acquirePromise
expect(result2.acquired).toBe(true)
})
})
})

View File

@@ -1157,7 +1157,6 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1352,7 +1351,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1589,7 +1587,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -3063,15 +3060,13 @@
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -3623,7 +3618,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3770,7 +3764,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -4035,7 +4028,6 @@
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4533,7 +4525,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4924,7 +4915,6 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -5125,7 +5115,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",

View File

@@ -0,0 +1,302 @@
<template>
<el-dialog
:append-to-body="true"
class="balance-script-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="show"
:title="`配置余额脚本 - ${account?.name || ''}`"
top="5vh"
width="720px"
@close="emitClose"
>
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>请求地址baseUrl</label
>
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token可选</label>
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>额外参数 (extra / userId)</label
>
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间()</label>
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>自动查询间隔(分钟)</label
>
<input
v-model.number="form.autoIntervalMinutes"
class="input-text"
min="0"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
可用变量{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}apiKey{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}accountId{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}extra{{ '}'
}}{{ '}' }}
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
<button
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
@click="applyPreset"
>
使用示例
</button>
</div>
<textarea
v-model="form.scriptBody"
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
spellcheck="false"
></textarea>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
extractor 可返回isValidinvalidMessageremainingunitplanNametotalusedextra
</div>
</div>
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
<div class="flex items-center justify-between">
<span class="font-semibold">测试结果</span>
<span
:class="[
'rounded px-2 py-0.5 text-xs',
testResult.mapped?.status === 'success'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
]"
>
{{ testResult.mapped?.status || 'unknown' }}
</span>
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
错误: {{ testResult.mapped.errorMessage }}
</div>
</div>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看 extractor 输出</summary>
<pre class="mt-1 whitespace-pre-wrap break-all">{{
formatJson(testResult.extracted)
}}</pre>
</details>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看原始响应</summary>
<pre class="mt-1 whitespace-pre-wrap break-all">{{
formatJson(testResult.response)
}}</pre>
</details>
</div>
</div>
<template #footer>
<div class="flex items-center gap-2">
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
<el-button @click="emitClose">取消</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: { type: Boolean, default: false },
account: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'saved'])
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const presetScript = `({
request: {
url: "{{baseUrl}}/api/user/self",
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer {{apiKey}}",
"New-Api-User": "{{extra}}"
}
},
extractor: function (response) {
if (response && response.success && response.data) {
const quota = response.data.quota || 0;
const used = response.data.used_quota || 0;
return {
planName: response.data.group || "默认套餐",
remaining: quota / 500000,
used: used / 500000,
total: (quota + used) / 500000,
unit: "USD"
};
}
return {
isValid: false,
invalidMessage: (response && response.message) || "查询失败"
};
}
})`
const form = reactive({
baseUrl: '',
apiKey: '',
token: '',
extra: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
scriptBody: ''
})
const buildDefaultForm = () => ({
baseUrl: '',
apiKey: '',
token: '',
extra: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
scriptBody: presetScript
})
const emitClose = () => emit('close')
const resetForm = () => {
Object.assign(form, buildDefaultForm())
testResult.value = null
saving.value = false
testing.value = false
}
const loadConfig = async () => {
if (!props.account?.id || !props.account?.platform) return
try {
const res = await apiClient.get(
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
)
if (res?.success && res.data) {
Object.assign(form, res.data)
}
} catch (error) {
showToast('加载脚本配置失败', 'error')
}
}
const saveConfig = async () => {
if (!props.account?.id || !props.account?.platform) return
saving.value = true
try {
await apiClient.put(
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
{ ...form }
)
showToast('已保存', 'success')
emit('saved')
} catch (error) {
showToast(error.message || '保存失败', 'error')
} finally {
saving.value = false
}
}
const testScript = async () => {
if (!props.account?.id || !props.account?.platform) return
testing.value = true
testResult.value = null
try {
const res = await apiClient.post(
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
{ ...form }
)
if (res?.success) {
testResult.value = res.data
showToast('测试完成', 'success')
} else {
showToast(res?.error || '测试失败', 'error')
}
} catch (error) {
showToast(error.message || '测试失败', 'error')
} finally {
testing.value = false
}
}
const applyPreset = () => {
form.scriptBody = presetScript
}
const displayAmount = (val) => {
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
return Number(val).toFixed(2)
}
const formatJson = (data) => {
try {
return JSON.stringify(data, null, 2)
} catch (error) {
return String(data)
}
}
watch(
() => props.show,
(val) => {
if (val) {
resetForm()
loadConfig()
}
}
)
</script>
<style scoped>
:deep(.balance-script-dialog) {
max-height: 90vh;
display: flex;
flex-direction: column;
}
:deep(.balance-script-dialog .el-dialog__body) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
:deep(.balance-script-dialog .el-dialog__footer) {
border-top: 1px solid rgba(229, 231, 235, 0.7);
}
.input-text {
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
}
</style>

View File

@@ -477,6 +477,36 @@
<i class="fas fa-check text-xs text-white"></i>
</div>
</label>
<label
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
:class="[
form.platform === 'gemini-antigravity'
? 'border-purple-500 bg-purple-50 dark:border-purple-400 dark:bg-purple-900/30'
: 'border-gray-300 bg-white hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-purple-500 dark:hover:bg-purple-900/20'
]"
>
<input
v-model="form.platform"
class="sr-only"
type="radio"
value="gemini-antigravity"
/>
<div class="flex items-center gap-2">
<i class="fas fa-rocket text-sm text-purple-600 dark:text-purple-400"></i>
<div>
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
>Antigravity</span
>
<span class="text-xs text-gray-500 dark:text-gray-400">OAuth</span>
</div>
</div>
<div
v-if="form.platform === 'gemini-antigravity'"
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500"
>
<i class="fas fa-check text-xs text-white"></i>
</div>
</label>
<label
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
@@ -772,7 +802,7 @@
</div>
<!-- Gemini 项目 ID 字段 -->
<div v-if="form.platform === 'gemini'">
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>项目 ID (可选)</label
>
@@ -1320,10 +1350,10 @@
class="rounded-lg bg-blue-100 px-3 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
type="button"
@click="
addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514')
addPresetMapping('claude-opus-4-5-20251101', 'claude-opus-4-5-20251101')
"
>
+ Sonnet 4
+ Opus 4.5
</button>
<button
class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
@@ -1334,24 +1364,6 @@
>
+ Sonnet 4.5
</button>
<button
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
type="button"
@click="
addPresetMapping('claude-opus-4-1-20250805', 'claude-opus-4-1-20250805')
"
>
+ Opus 4.1
</button>
<button
class="rounded-lg bg-green-100 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
type="button"
@click="
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')
"
>
+ Haiku 3.5
</button>
<button
class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50"
type="button"
@@ -1451,6 +1463,26 @@
</p>
</div>
</div>
<!-- 上游错误处理 -->
<div v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>上游错误处理</label
>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段 -->
@@ -1630,6 +1662,47 @@
</label>
</div>
<!-- Claude 账户级串行队列开关 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- 拦截预热请求开关Claude 和 Claude Console -->
<div
v-if="form.platform === 'claude' || form.platform === 'claude-console'"
class="mt-4"
>
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
@@ -1781,7 +1854,7 @@
Token建议也一并填写以支持自动刷新。
</p>
<p
v-else-if="form.platform === 'gemini'"
v-else-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'"
class="mb-2 text-sm text-blue-800 dark:text-blue-300"
>
请输入有效的 Gemini Access Token。如果您有 Refresh
@@ -1818,12 +1891,14 @@
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
</p>
<p
v-else-if="form.platform === 'gemini'"
v-else-if="
form.platform === 'gemini' || form.platform === 'gemini-antigravity'
"
class="text-xs text-blue-800 dark:text-blue-300"
>
请从已登录 Gemini CLI 的机器上获取
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/50"
>~/.config/gemini/credentials.json</code
>~/.config/.gemini/oauth_creds.json</code
>
文件中的凭证。
</p>
@@ -1924,6 +1999,22 @@
rows="4"
/>
</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>
<!-- API Key 模式输入 -->
@@ -1969,6 +2060,22 @@
</p>
</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
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"
>
@@ -2516,7 +2623,7 @@
</div>
<!-- Gemini 项目 ID 字段 -->
<div v-if="form.platform === 'gemini'">
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>项目 ID (可选)</label
>
@@ -2581,6 +2688,44 @@
</label>
</div>
<!-- Claude 账户级串行队列开关(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- 拦截预热请求开关Claude 和 Claude Console 编辑模式) -->
<div v-if="form.platform === 'claude' || form.platform === 'claude-console'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
@@ -3070,6 +3215,26 @@
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
</div>
</div>
<!-- 上游错误处理(编辑模式)-->
<div v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
上游错误处理
</label>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段(编辑模式)-->
@@ -3599,6 +3764,22 @@
</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" />
@@ -3731,7 +3912,7 @@ const determinePlatformGroup = (platform) => {
return 'claude'
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
return 'openai'
} else if (['gemini', 'gemini-api'].includes(platform)) {
} else if (['gemini', 'gemini-antigravity', 'gemini-api'].includes(platform)) {
return 'gemini'
} else if (platform === 'droid') {
return 'droid'
@@ -3866,7 +4047,8 @@ const form = ref({
platform: props.account?.platform || 'claude',
addType: (() => {
const platform = props.account?.platform || 'claude'
if (platform === 'gemini' || platform === 'openai') return 'oauth'
if (platform === 'gemini' || platform === 'gemini-antigravity' || platform === 'openai')
return 'oauth'
if (platform === 'claude') return 'oauth'
return 'manual'
})(),
@@ -3879,6 +4061,9 @@ const form = ref({
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
interceptWarmup:
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
groupId: '',
groupIds: [],
projectId: props.account?.projectId || '',
@@ -3912,6 +4097,7 @@ const form = ref({
})(),
userAgent: props.account?.userAgent || '',
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
disableAutoProtection: props.account?.disableAutoProtection === true,
// 额度管理字段
dailyQuota: props.account?.dailyQuota || 0,
dailyUsage: props.account?.dailyUsage || 0,
@@ -4204,7 +4390,7 @@ const selectPlatformGroup = (group) => {
} else if (group === 'openai') {
form.value.platform = 'openai'
} else if (group === 'gemini') {
form.value.platform = 'gemini'
form.value.platform = 'gemini' // Default to Gemini CLI, user can select Antigravity
} else if (group === 'droid') {
form.value.platform = 'droid'
}
@@ -4241,7 +4427,11 @@ const nextStep = async () => {
}
// 对于Gemini账户检查项目 ID
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
if (
(form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') &&
oauthStep.value === 1 &&
form.value.addType === 'oauth'
) {
if (!form.value.projectId || form.value.projectId.trim() === '') {
// 使用自定义确认弹窗
const confirmed = await showConfirm(
@@ -4464,9 +4654,11 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false,
interceptWarmup: form.value.interceptWarmup || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId,
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max',
@@ -4604,6 +4796,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -4611,9 +4804,14 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true // 标记为手动设置
}
} else if (currentPlatform === 'gemini') {
// Gemini使用geminiOauth字段
} else if (currentPlatform === 'gemini' || currentPlatform === 'gemini-antigravity') {
// Gemini/Antigravity使用geminiOauth字段
data.geminiOauth = tokenInfo.tokens || tokenInfo
// 根据 platform 设置 oauthProvider
data.oauthProvider =
currentPlatform === 'gemini-antigravity'
? 'antigravity'
: tokenInfo.oauthProvider || 'gemini-cli'
if (form.value.projectId) {
data.projectId = form.value.projectId
}
@@ -4927,6 +5125,7 @@ const createAccount = async () => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -5015,6 +5214,11 @@ const createAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
data.interceptWarmup = !!form.value.interceptWarmup
}
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -5029,6 +5233,10 @@ const createAccount = async () => {
data.rateLimitDuration = 60 // 默认值60不从用户输入获取
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
} else if (form.value.platform === 'gemini-antigravity') {
// Antigravity OAuth - set oauthProvider, submission happens below
data.oauthProvider = 'antigravity'
data.priority = form.value.priority || 50
} else if (form.value.platform === 'gemini-api') {
// Gemini API 账户特定数据
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
@@ -5080,7 +5288,7 @@ const createAccount = async () => {
result = await accountsStore.createOpenAIAccount(data)
} else if (form.value.platform === 'azure_openai') {
result = await accountsStore.createAzureOpenAIAccount(data)
} else if (form.value.platform === 'gemini') {
} else if (form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') {
result = await accountsStore.createGeminiAccount(data)
} else if (form.value.platform === 'gemini-api') {
result = await accountsStore.createGeminiApiAccount(data)
@@ -5310,9 +5518,11 @@ const updateAccount = async () => {
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
data.interceptWarmup = form.value.interceptWarmup || false
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 更新订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -5343,6 +5553,10 @@ const updateAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection
// 拦截预热请求
data.interceptWarmup = !!form.value.interceptWarmup
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -5911,9 +6125,12 @@ watch(
accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false,
interceptWarmup:
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
useUnifiedClientId: newAccount.useUnifiedClientId || false,
unifiedClientId: newAccount.unifiedClientId || '',
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
groupId: groupId,
groupIds: [],
projectId: newAccount.projectId || '',
@@ -5964,7 +6181,9 @@ watch(
dailyUsage: newAccount.dailyUsage || 0,
quotaResetTime: newAccount.quotaResetTime || '00:00',
// 并发控制字段
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
// 上游错误处理
disableAutoProtection: newAccount.disableAutoProtection === true
}
// 如果是Claude Console账户加载实时使用情况

View File

@@ -0,0 +1,402 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-lg"
>
<i class="fas fa-clock" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">定时测试配置</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ account?.name || '未知账户' }}
</p>
</div>
</div>
<button
class="flex h-9 w-9 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"
:disabled="saving"
@click="handleClose"
>
<i class="fas fa-times text-sm" />
</button>
</div>
<!-- 内容区域 -->
<div class="px-5 py-4">
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
<span class="text-gray-500 dark:text-gray-400">加载配置中...</span>
</div>
<template v-else>
<!-- 启用开关 -->
<div class="mb-5 flex items-center justify-between">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">启用定时测试</p>
<p class="text-xs text-gray-500 dark:text-gray-400">按计划自动测试账户连通性</p>
</div>
<button
:class="[
'relative h-6 w-11 rounded-full transition-colors duration-200',
config.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
]"
@click="config.enabled = !config.enabled"
>
<span
:class="[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
config.enabled ? 'left-5' : 'left-0.5'
]"
/>
</button>
</div>
<!-- Cron 表达式配置 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Cron 表达式
</label>
<input
v-model="config.cronExpression"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="0 8 * * *"
type="text"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
格式: (: "0 8 * * *" = 每天8:00)
</p>
</div>
<!-- 快捷选项 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
快捷设置
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in cronPresets"
:key="preset.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.cronExpression === preset.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.cronExpression = preset.value"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- 测试模型选择 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
测试模型
</label>
<input
v-model="config.model"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="claude-sonnet-4-5-20250929"
type="text"
/>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="modelOption in modelOptions"
:key="modelOption.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.model === modelOption.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.model = modelOption.value"
>
{{ modelOption.label }}
</button>
</div>
</div>
<!-- 测试历史 -->
<div v-if="testHistory.length > 0" class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
最近测试记录
</label>
<div
class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
>
<div
v-for="(record, index) in testHistory"
:key="index"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<i
:class="[
'fas',
record.success
? 'fa-check-circle text-green-500'
: 'fa-times-circle text-red-500'
]"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ formatTimestamp(record.timestamp) }}
</span>
</div>
<span v-if="record.latencyMs" class="text-gray-500 dark:text-gray-500">
{{ record.latencyMs }}ms
</span>
<span
v-else-if="record.error"
class="max-w-[150px] truncate text-red-500"
:title="record.error"
>
{{ record.error }}
</span>
</div>
</div>
</div>
<!-- 无历史记录 -->
<div
v-else
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
>
<i class="fas fa-history mb-2 text-2xl text-gray-300 dark:text-gray-600" />
<p>暂无测试记录</p>
</div>
</template>
</div>
<!-- 底部操作栏 -->
<div
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
>
<button
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="saving"
@click="handleClose"
>
取消
</button>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
saving
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
]"
:disabled="saving || loading"
@click="saveConfig"
>
<i :class="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save']" />
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import { API_PREFIX } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'saved'])
// 状态
const loading = ref(false)
const saving = ref(false)
const config = ref({
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
})
const testHistory = ref([])
// Cron 预设选项
const cronPresets = [
{ label: '每天 8:00', value: '0 8 * * *' },
{ label: '每天 12:00', value: '0 12 * * *' },
{ label: '每天 18:00', value: '0 18 * * *' },
{ label: '每6小时', value: '0 */6 * * *' },
{ label: '每12小时', value: '0 */12 * * *' },
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
]
// 模型选项
const modelOptions = [
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
]
// 格式化时间戳
function formatTimestamp(timestamp) {
if (!timestamp) return '未知'
const date = new Date(timestamp)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 加载配置
async function loadConfig() {
if (!props.account) return
loading.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
// 根据平台获取配置端点
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
// 其他平台暂不支持
loading.value = false
return
}
// 获取配置
const configRes = await fetch(endpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (configRes.ok) {
const data = await configRes.json()
if (data.success && data.data?.config) {
config.value = {
enabled: data.data.config.enabled || false,
cronExpression: data.data.config.cronExpression || '0 8 * * *',
model: data.data.config.model || 'claude-sonnet-4-5-20250929'
}
}
}
// 获取测试历史
const historyEndpoint = endpoint.replace('/test-config', '/test-history')
const historyRes = await fetch(historyEndpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (historyRes.ok) {
const historyData = await historyRes.json()
if (historyData.success && historyData.data?.history) {
testHistory.value = historyData.data.history
}
}
} catch (err) {
showToast('加载配置失败: ' + err.message, 'error')
} finally {
loading.value = false
}
}
// 保存配置
async function saveConfig() {
if (!props.account) return
saving.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
saving.value = false
return
}
const res = await fetch(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({
enabled: config.value.enabled,
cronExpression: config.value.cronExpression,
model: config.value.model
})
})
if (res.ok) {
showToast('配置已保存', 'success')
emit('saved')
handleClose()
} else {
const errorData = await res.json().catch(() => ({}))
showToast(errorData.message || '保存失败', 'error')
}
} catch (err) {
showToast('保存失败: ' + err.message, 'error')
} finally {
saving.value = false
}
}
// 关闭模态框
function handleClose() {
if (saving.value) return
emit('close')
}
// 监听 show 变化,加载配置
watch(
() => props.show,
(newVal) => {
if (newVal) {
config.value = {
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
}
testHistory.value = []
loadConfig()
}
}
)
</script>

View File

@@ -44,12 +44,20 @@
</p>
</div>
</div>
<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"
@click="handleClose"
>
<i class="fas fa-times" />
</button>
<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
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"
>
<i class="fas fa-times" />
</button>
</div>
</div>
<!-- 内容区域 -->
@@ -325,6 +333,7 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import Chart from 'chart.js/auto'
import { useThemeStore } from '@/stores/theme'
@@ -343,6 +352,7 @@ const emit = defineEmits(['close'])
const themeStore = useThemeStore()
const { isDarkMode } = storeToRefs(themeStore)
const router = useRouter()
const chartCanvas = ref(null)
let chartInstance = null
@@ -579,6 +589,14 @@ const handleClose = () => {
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(
() => props.show,
(visible) => {

View File

@@ -0,0 +1,281 @@
<template>
<div class="min-w-[200px] space-y-1">
<div v-if="loading" class="flex items-center gap-2">
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
</div>
<div v-else-if="requestError" class="flex items-center gap-2">
<i class="fas fa-exclamation-circle text-red-500"></i>
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
<button
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
:disabled="refreshing"
@click="reload"
>
重试
</button>
</div>
<div v-else-if="balanceData" class="space-y-1">
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
{{ balanceData.error }}
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i
class="fas"
:class="
balanceData.balance
? 'fa-wallet text-green-600 dark:text-green-400'
: 'fa-chart-line text-gray-500 dark:text-gray-400'
"
></i>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ primaryText }}
</span>
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
{{ sourceLabel }}
</span>
</div>
<button
v-if="!hideRefresh"
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
:disabled="refreshing || !canRefresh"
:title="refreshTitle"
@click="refresh"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
</button>
</div>
<!-- 配额如适用 -->
<div v-if="quotaInfo" class="space-y-1">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-1.5 rounded-full transition-all"
:class="quotaBarClass"
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
></div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
</span>
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
重置: {{ formatResetTime(quotaInfo.resetAt) }}
</span>
</div>
</div>
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
</div>
<div
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
class="text-xs text-gray-400 dark:text-gray-500"
>
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
</div>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { apiClient } from '@/config/api'
const props = defineProps({
accountId: { type: String, required: true },
platform: { type: String, required: true },
initialBalance: { type: Object, default: null },
hideRefresh: { type: Boolean, default: false },
autoLoad: { type: Boolean, default: true }
})
const emit = defineEmits(['refreshed', 'error'])
const balanceData = ref(props.initialBalance)
const loading = ref(false)
const refreshing = ref(false)
const requestError = ref(null)
const sourceClass = computed(() => {
const source = balanceData.value?.source
return {
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
}
})
const sourceLabel = computed(() => {
const source = balanceData.value?.source
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
})
const quotaInfo = computed(() => {
const quota = balanceData.value?.quota
if (!quota || quota.unlimited) return null
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
return {
used: quota.used ?? 0,
remaining: quota.remaining ?? 0,
percentage: quota.percentage,
resetAt: quota.resetAt || null
}
})
const quotaBarClass = computed(() => {
const percentage = quotaInfo.value?.percentage || 0
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
return 'bg-green-500 dark:bg-green-600'
})
const canRefresh = computed(() => {
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
const data = balanceData.value
if (!data) return false
if (data.scriptEnabled === false) return false
return !!data.scriptConfigured
})
const refreshTitle = computed(() => {
if (refreshing.value) return '刷新中...'
if (!canRefresh.value) {
if (balanceData.value?.scriptEnabled === false) {
return '余额脚本功能已禁用'
}
return '请先配置余额脚本'
}
return '刷新余额(调用脚本配置的余额 API'
})
const primaryText = computed(() => {
if (balanceData.value?.balance?.formattedAmount) {
return balanceData.value.balance.formattedAmount
}
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
return `今日成本 ${formatCurrency(dailyCost)}`
})
const load = async () => {
if (!props.autoLoad) return
if (!props.accountId || !props.platform) return
loading.value = true
requestError.value = null
try {
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
params: { platform: props.platform, queryApi: false }
})
if (response?.success) {
balanceData.value = response.data
} else {
requestError.value = response?.error || '加载失败'
}
} catch (error) {
requestError.value = error.message || '网络错误'
emit('error', error)
} finally {
loading.value = false
}
}
const refresh = async () => {
if (!props.accountId || !props.platform) return
if (refreshing.value) return
if (!canRefresh.value) return
refreshing.value = true
requestError.value = null
try {
const response = await apiClient.post(`/admin/accounts/${props.accountId}/balance/refresh`, {
platform: props.platform
})
if (response?.success) {
balanceData.value = response.data
emit('refreshed', response.data)
} else {
requestError.value = response?.error || '刷新失败'
}
} catch (error) {
requestError.value = error.message || '网络错误'
emit('error', error)
} finally {
refreshing.value = false
}
}
const reload = async () => {
await load()
}
const formatNumber = (num) => {
if (num === Infinity) return '∞'
const value = Number(num)
if (!Number.isFinite(value)) return 'N/A'
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
const formatCurrency = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
if (value >= 0.01) return `$${value.toFixed(3)}`
return `$${value.toFixed(6)}`
}
const formatResetTime = (isoString) => {
const date = new Date(isoString)
const now = new Date()
const diff = date.getTime() - now.getTime()
if (!Number.isFinite(diff)) return '未知'
if (diff < 0) return '已过期'
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(minutes / 60)
const remainMinutes = minutes % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
return `${days}天后`
}
return `${hours}小时${remainMinutes}分钟`
}
const formatCacheExpiry = (isoString) => {
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
watch(
() => props.initialBalance,
(newVal) => {
if (newVal) {
balanceData.value = newVal
}
}
)
onMounted(() => {
if (!props.initialBalance) {
load()
}
})
defineExpose({ refresh, reload })
</script>

View File

@@ -303,6 +303,16 @@
请按照以下步骤完成 Gemini 账户的授权
</p>
<!-- 授权来源显示由平台类型决定 -->
<div class="mb-4">
<p class="text-sm text-green-800 dark:text-green-300">
<i class="fas fa-info-circle mr-1"></i>
授权类型<span class="font-semibold">{{
platform === 'gemini-antigravity' ? 'Antigravity OAuth' : 'Gemini CLI OAuth'
}}</span>
</p>
</div>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div
@@ -818,6 +828,13 @@ const exchanging = ref(false)
const authUrl = ref('')
const authCode = ref('')
const copied = ref(false)
// oauthProvider is now derived from platform prop
const geminiOauthProvider = computed(() => {
if (props.platform === 'gemini-antigravity') {
return 'antigravity'
}
return 'gemini-cli'
})
const sessionId = ref('') // 保存sessionId用于后续交换
const userCode = ref('')
const verificationUri = ref('')
@@ -921,7 +938,11 @@ watch(authCode, (newValue) => {
console.error('Failed to parse URL:', error)
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
}
} else if (props.platform === 'gemini' || props.platform === 'openai') {
} else if (
props.platform === 'gemini' ||
props.platform === 'gemini-antigravity' ||
props.platform === 'openai'
) {
// Gemini 和 OpenAI 平台可能使用不同的回调URL
// 尝试从任何URL中提取code参数
try {
@@ -972,8 +993,11 @@ const generateAuthUrl = async () => {
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
authUrl.value = result.authUrl
sessionId.value = result.sessionId
} else if (props.platform === 'gemini') {
const result = await accountsStore.generateGeminiAuthUrl(proxyConfig)
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
const result = await accountsStore.generateGeminiAuthUrl({
...proxyConfig,
oauthProvider: geminiOauthProvider.value
})
authUrl.value = result.authUrl
sessionId.value = result.sessionId
} else if (props.platform === 'openai') {
@@ -996,6 +1020,8 @@ const generateAuthUrl = async () => {
}
}
// onGeminiOauthProviderChange removed - oauthProvider is now computed from platform
// 重新生成授权URL
const regenerateAuthUrl = () => {
stopCountdown()
@@ -1079,11 +1105,12 @@ const exchangeCode = async () => {
sessionId: sessionId.value,
callbackUrl: authCode.value.trim()
}
} else if (props.platform === 'gemini') {
// Gemini使用code和sessionId
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
// Gemini/Antigravity使用code和sessionId
data = {
code: authCode.value.trim(),
sessionId: sessionId.value
sessionId: sessionId.value,
oauthProvider: geminiOauthProvider.value
}
} else if (props.platform === 'openai') {
// OpenAI使用code和sessionId
@@ -1111,8 +1138,12 @@ const exchangeCode = async () => {
let tokenInfo
if (props.platform === 'claude') {
tokenInfo = await accountsStore.exchangeClaudeCode(data)
} else if (props.platform === 'gemini') {
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
tokenInfo = await accountsStore.exchangeGeminiCode(data)
// 附加 oauthProvider 信息到 tokenInfo
if (tokenInfo) {
tokenInfo.oauthProvider = geminiOauthProvider.value
}
} else if (props.platform === 'openai') {
tokenInfo = await accountsStore.exchangeOpenAICode(data)
} else if (props.platform === 'droid') {

View File

@@ -579,55 +579,46 @@
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="claude"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="gemini"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="openai"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="droid"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Droid</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务
不选择任何服务表示允许访问全部服务
</p>
</div>
@@ -662,7 +653,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -676,7 +667,7 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
@@ -690,7 +681,7 @@
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
@@ -704,7 +695,7 @@
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
@@ -718,7 +709,7 @@
v-model="form.droidAccountId"
:accounts="localAccounts.droid"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
@@ -966,7 +957,7 @@ const form = reactive({
expirationMode: 'fixed', // 过期模式fixed(固定) 或 activation(激活)
activationDays: 30, // 激活后有效天数
activationUnit: 'days', // 激活时间单位hours 或 days
permissions: 'all',
permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',

View File

@@ -412,55 +412,46 @@
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="claude"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="gemini"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="openai"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="droid"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Droid</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务
不选择任何服务表示允许访问全部服务
</p>
</div>
@@ -495,7 +486,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -509,7 +500,7 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
@@ -523,7 +514,7 @@
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
@@ -537,7 +528,7 @@
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
@@ -551,7 +542,7 @@
v-model="form.droidAccountId"
:accounts="localAccounts.droid"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
@@ -800,7 +791,7 @@ const form = reactive({
dailyCostLimit: '',
totalCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
@@ -1241,7 +1232,17 @@ onMounted(async () => {
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.totalCostLimit = props.apiKey.totalCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
const perms = props.apiKey.permissions
if (Array.isArray(perms)) {
form.permissions = perms
} else if (perms === 'all' || !perms) {
form.permissions = []
} else if (typeof perms === 'string') {
form.permissions = [perms]
} else {
form.permissions = []
}
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`

Some files were not shown because too many files have changed in this diff Show More