Compare commits

..

162 Commits

Author SHA1 Message Date
github-actions[bot]
3000632d4e chore: sync VERSION file with release v1.1.258 [skip ci] 2026-01-15 01:25:03 +00:00
Wesley Liddick
9e3a4cf45a Merge pull request #899 from UncleJ-h/fix/remove-unused-heapdump
fix: remove unused heapdump dependency
2026-01-15 09:24:51 +08:00
UncleJ-h
eb992697b6 fix: remove unused heapdump dependency
The heapdump package was added in v1.1.257 but is not actually used anywhere in the codebase.

This causes build failures on platforms without Python (e.g., Zeabur) because heapdump requires node-gyp compilation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:43:45 +08:00
github-actions[bot]
35ab34d687 chore: sync VERSION file with release v1.1.257 [skip ci] 2026-01-14 07:41:16 +00:00
Wesley Liddick
bc4b050c69 Merge pull request #895 from wayfind/fix/memory-simple
fix(memory): reduce memory retention in request handling
2026-01-14 15:40:59 +08:00
root
189d53d793 style: fix ESLint prefer-const and formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:46:08 +00:00
root
b148537428 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:42:39 +00:00
root
9d1a451027 fix(memory): comprehensive req closure capture fixes
Additional fixes for memory leaks:
- Bedrock stream: extract _apiKeyIdBedrock, _rateLimitInfoBedrock, _requestBodyBedrock
- Non-stream requests: extract variables at block start
- Non-stream service calls: use extracted variables
- Non-stream usage recording: use extracted variables

All async callbacks now use local variables instead of req.* references,
preventing the entire request object (including large req.body with images)
from being retained by closures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:29:29 +00:00
root
ba815de08f fix(memory): extract req properties to avoid closure capturing entire request object
Problem:
- usageCallback closures referenced req.apiKey.id and req.rateLimitInfo
- This caused entire req object (including req.body with images) to be retained
- Base64 images in messages accumulated in memory (290 images = 26MB)

Solution:
- Extract needed properties before callback: _apiKeyId, _rateLimitInfo, etc.
- Closures now capture small local variables instead of entire req object
- Enables proper GC of request bodies after stream completion

Results verified via heapdump analysis:
- String memory: 144MB -> 24MB (-83%)
- Base64 images: 290 -> 0 (-100%)
- Heapdump size: 57MB -> 28MB (-51%)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:53:20 +00:00
root
b26027731e fix(memory): clear bodyString after req.write() to prevent closure capture
Additional memory optimizations:
- Set bodyString = null after req.write() in both stream and non-stream requests
- Use let instead of const for bodyString to allow nullifying
- Store non-stream originalBodyString in bodyStore to avoid closure capture
- Clean up bodyStore in finally block for non-stream requests

This prevents V8 closures (res.on handlers) from retaining large request
body strings until stream completion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:57:54 +00:00
root
f535b35a1c fix(memory): use bodyStore to avoid closure capturing request body
Problem:
- Stream response handlers (res.on) captured requestOptions in closures
- requestOptions contained originalBodyString (~800KB per request)
- These strings couldn't be GC'd until stream completed
- With concurrent requests, memory accumulated rapidly

Solution:
- Store request body strings in this.bodyStore Map with unique ID
- Pass only bodyStoreId in requestOptions (not the 800KB string)
- Closures capture small ID, not large string
- Clean up bodyStore on request completion (success/error/timeout)
- Extract needed values before closures to avoid capturing body object
2026-01-12 08:31:47 +00:00
github-actions[bot]
962e01b080 chore: sync VERSION file with release v1.1.256 [skip ci] 2026-01-10 08:56:29 +00:00
Wesley Liddick
fcc6ac4e22 Merge pull request #885 from junejuneli/feat/add_claude_aws_type
为 AWS Bedrock 账户添加 Bearer Token 认证支持
2026-01-10 16:56:13 +08:00
Wesley Liddick
3a03147ac9 Merge pull request #884 from moonsphere/fix_claude_auth [skip ci]
fix: claude subscription detection
2026-01-10 16:55:59 +08:00
juenjunli
94f239b56a feat: 添加 Claude 4.5 系列模型支持到 AWS Bedrock 映射表
为 AWS Bedrock 服务添加 Claude 4.5 系列模型的映射支持,包括:
- Claude 4.5 Opus (claude-opus-4-5)
- Claude 4.5 Sonnet (claude-sonnet-4-5)
- Claude 4.5 Haiku (claude-haiku-4-5)

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

Co-Authored-By: Claude Haiku 3.5 <noreply@anthropic.com>
2026-01-10 16:04:58 +08:00
juenjunli
b07873772c fix 2026-01-10 14:19:59 +08:00
juenjunli
549c95eb80 feat: 为 AWS Bedrock 账户添加 Bearer Token 认证支持
- 新增 credentialType 字段支持 access_key 和 bearer_token 两种认证方式
- 实现 Bedrock 账户的 testAccountConnection 方法,支持 SSE 流式测试
- 前端账户表单增加认证类型选择器,自动切换输入字段
- 前端测试模态框根据账户类型自动选择测试模型(Bearer Token 使用 Sonnet 4.5,Access Key 使用 Haiku)
- 改进测试接口错误处理,避免响应流重复关闭

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 14:13:36 +08:00
jett
b397954ea4 fix lint 2026-01-10 11:41:35 +08:00
jett
ed835d0c28 using TTL-based random suffix 2026-01-10 00:56:21 +08:00
jett
28b27e6a7b fix: claude subscription detection 2026-01-10 00:31:17 +08:00
github-actions[bot]
810fe9fe90 chore: sync VERSION file with release v1.1.255 [skip ci] 2026-01-09 07:55:15 +00:00
Wesley Liddick
141b07db78 Merge pull request #880 from DaydreamCoding/feature/fix-claude-openai-endpoint
fix: 修正Claude通过openaiClaudeRoutes访问失败问题
2026-01-09 15:55:01 +08:00
QTom
1dad810d15 refactor: 统一权限检查逻辑,使用 apiKeyService.hasPermission
将散布在各处的权限检查逻辑(permissions || 'all')统一为
apiKeyService.hasPermission() 方法调用,确保:

- 权限检查的唯一真实来源
- 避免默认值不一致导致的安全问题
- 便于后续权限模型的扩展和维护

影响文件:
- geminiHandlers.js: key-info 端点
- apiStats.js: user-stats 统计端点
- openaiClaudeRoutes.js: 权限校验辅助函数
- openaiRoutes.js: key-info 端点
2026-01-09 14:36:31 +08:00
QTom
4723328be4 fix: 添加 OpenAI 兼容路由对 Claude Console 账户的支持 2026-01-09 14:31:06 +08:00
github-actions[bot]
114e9facee chore: sync VERSION file with release v1.1.254 [skip ci] 2026-01-08 04:08:28 +00:00
shaw
e20ce86ad4 feat: Antigravity 账号注入 systemInstruction 和 requestType header
- 在 antigravityClient.js 的请求 header 中添加 requestType: agent
- 在 anthropicGeminiBridgeService.js 中为 antigravity 账号前置注入系统提示词
2026-01-08 12:07:50 +08:00
shaw
6caabb5444 update readme 2026-01-08 08:58:16 +08:00
shaw
b924c3c559 update readme 2026-01-08 08:33:09 +08:00
QTom
6682e0a982 fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
防止非等待等待重置的账号刷新,导致大量错误消息通知问题
2026-01-08 00:05:47 +08:00
github-actions[bot]
b9c088ce58 chore: sync VERSION file with release v1.1.253 [skip ci] 2026-01-07 14:12:03 +00:00
shaw
2ff74c21d2 Merge branch 'antigravity' 2026-01-07 21:55:15 +08:00
shaw
8a4dadbbc0 fix(security): 修复余额脚本功能的RCE和SSRF漏洞
- 将 BALANCE_SCRIPT_ENABLED 默认值改为 false,需显式启用
- 添加 isUrlSafe() SSRF防护,禁止访问:
  - localhost/127.x
  - 私有IP (10.x, 172.16-31.x, 192.168.x)
  - AWS metadata (169.254.x)
  - 非HTTP(S)协议
2026-01-07 21:55:08 +08:00
shaw
adf2890f65 fix: 去除context_management会导致压缩失败还原逻辑 [skip ci] 2026-01-07 21:24:39 +08:00
Wesley Liddick
7d892a69f1 Merge pull request #873 from DaydreamCoding/patch-5 [skip ci]
fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
2026-01-07 08:09:15 -05:00
QTom
a749ddfede fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
2026-01-07 20:57:49 +08:00
Wesley Liddick
dbd4fb19cf Merge branch 'main' into antigravity 2026-01-07 03:49:14 -05:00
github-actions[bot]
39ba345a43 chore: sync VERSION file with release v1.1.252 [skip ci] 2026-01-07 08:22:01 +00:00
shaw
2693fd77b7 fix: 移除context_management字段,避免报错 2026-01-07 16:21:41 +08:00
52227
3cc3219a90 docs: 更新中英文 README,完善模型配额查询与 Claude Code 适配说明
- 新增 Antigravity 账户额度与模型列表查询指南
- 完善 Claude Code 兼容性特性说明 (Thinking Signature, Zombie Stream Watchdog)
- 移除无关广告信息,优化文档结构
- 明确二开维护者信息
2026-01-05 23:07:04 +08:00
52227
1b834ffcdb feat: 增强稳定性与Antigravity适配 (僵尸流看门狗/自动重试/签名缓存)
主要变更:
1. **僵尸流看门狗 (Zombie Stream Watchdog)**:
   - 新增 resetActivityTimeout 机制,45秒无数据强制断开连接,防止服务假死。

2. **智能重试机制**:
   - 针对 Antigravity 429 (Resource Exhausted) 错误,自动清理会话并切换账号重试。
   - 涵盖流式 (Stream) 和非流式 (Non-stream) 请求。

3. **Thought Signature 增强**:
   - 新增签名缓存与恢复机制 (signatureCache)。
   - 增加 skip_thought_signature_validator 兜底签名策略。
   - 强制补充 thought: true 标记以满足上游校验。

4. **系统稳定性与调试**:
   - 使用 util.inspect 替代 JSON.stringify 打印错误日志,彻底修复循环引用导致的服务崩溃。
   - 新增针对 Antigravity 参数错误 (400) 的详细请求结构分析日志。
   - 优化日志写入为轮转模式 (safeRotatingAppend)。

5. **其他优化**:
   - antigravityClient 数据处理安全增强 (safeDataToString)。
2026-01-05 09:37:39 +08:00
52227
41999f56b4 feat: 适配 Antigravity 账户余额查询与流式响应优化
1. Antigravity 账户适配:
   - 新增 GeminiBalanceProvider,支持 Antigravity 账户的额度查询(API 模式)
   - AccountBalanceService 增加 queryMode 逻辑与安全限制
   - 前端 BalanceDisplay 适配 Antigravity 配额显示

2. 流式响应增强:
   - 优化 thoughtSignature 捕获与回填,支持思维链透传
   - 修复工具调用签名校验

3. 其他:
   - 请求体大小限制提升至 100MB
   - .gitignore 更新
2026-01-03 10:15:13 +08:00
52227
b81c2b946f feat: 增强 Gemini 桥接处理并添加 Antigravity 响应转储工具 2026-01-01 15:24:12 +08:00
github-actions[bot]
0a59a0f9d4 chore: sync VERSION file with release v1.1.251 [skip ci] 2026-01-01 05:57:53 +00:00
Chapoly1305
c4448db6ab fix: 防止客户端断开连接时服务崩溃
当客户端在流式响应过程中断开连接时,catch 块尝试发送 JSON 错误响应
会触发 ERR_HTTP_HEADERS_SENT 错误,导致 unhandledRejection 使服务崩溃。

修复文件:
- src/routes/openaiClaudeRoutes.js
- src/routes/openaiGeminiRoutes.js

修复内容:
- 添加 res.headersSent 检查,避免在响应已发送后再次尝试发送
- 客户端断开连接使用 INFO 级别日志(不是 ERROR)
- 客户端断开使用 499 状态码 (Client Closed Request)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 01:18:06 -05:00
52227
c67d2bce9d feat: 完善 Antigravity OAuth 功能与权限校验
新增功能:
- 实现 Antigravity OAuth 账户支持与路径分流
- 支持 /antigravity/api 路径自动分流到 Antigravity OAuth 账户
- 支持 gemini-antigravity 平台类型的账户创建和管理

修复问题:
- 修复 OAuthFlow 组件中 gemini-antigravity 平台授权页面空白的问题
- 修复 EditApiKeyModal 中 Redis 返回字符串格式 permissions 导致的 400 错误
- 统一使用 hasPermission 函数进行权限校验,支持数组格式

优化改进:
- 添加 Antigravity 调试环境变量说明
2025-12-29 14:23:43 +08:00
github-actions[bot]
a345812cd7 chore: sync VERSION file with release v1.1.250 [skip ci] 2025-12-29 05:46:39 +00:00
shaw
a0cbafd759 Merge branch 'fix-authenticateUserOrAdmin-bypass' 2025-12-29 13:45:44 +08:00
Wesley Liddick
3c64038fa7 Create SECURITY.md for security policy [skip ci]
Add a security policy document outlining supported versions and vulnerability reporting.
2025-12-29 13:37:15 +08:00
Junming Chen
45b81bd478 fix: 修复 authenticateUserOrAdmin 认证绕过漏洞
- 添加 username 和 loginTime 字段验证(与 authenticateAdmin 保持一致)
- 无效/伪造会话自动删除并记录安全日志
- 删除未使用的 id 字段(死代码清理)

漏洞详情:
- 位置:src/middleware/auth.js:1569-1581
- 原因:只检查 Object.keys(session).length > 0,未验证必须字段
- 影响:攻击者可通过注入最小会话 {foo:'bar'} 绕过认证

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 23:56:05 -05:00
github-actions[bot]
fc57133230 chore: sync VERSION file with release v1.1.249 [skip ci] 2025-12-26 11:26:14 +00:00
shaw
1f06af4a56 chore: trigger release [force release] 2025-12-26 19:25:53 +08:00
shaw
6165fad090 docs: 添加安全漏洞警告 2025-12-26 19:22:08 +08:00
shaw
d53a399d41 revert: 回退到安全漏洞修复版本 2025-12-26 19:15:50 +08: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
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
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
108 changed files with 21563 additions and 1157 deletions

View File

@@ -33,6 +33,59 @@ 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
# ============================================================================
# 🐛 调试 Dump 配置(可选)
# ============================================================================
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
# ⚠️ 生产环境建议关闭,避免磁盘占用。
#
# 📄 输出文件列表:
# - anthropic-requests-dump.jsonl (客户端请求)
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
# - anthropic-tools-dump.jsonl (工具定义快照)
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
#
# 📌 开关配置:
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
#
# 📏 单条记录大小上限(字节),默认 2MB
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
#
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB
# DUMP_MAX_FILE_SIZE_BYTES=10485760
#
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
# (仅 /antigravity/api 分流生效)
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
# 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
@@ -61,6 +114,9 @@ PROXY_USE_IPV4=true
# ⏱️ 请求超时配置
REQUEST_TIMEOUT=600000 # 请求超时设置毫秒默认10分钟
# 🔧 请求体大小配置
REQUEST_MAX_SIZE_MB=60
# 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000
@@ -75,6 +131,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

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.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
>
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[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,6 +394,9 @@ docker-compose.yml 已包含:
**Claude Code 设置环境变量:**
**使用标准 Claude 账号池**
默认使用标准 Claude 账号池:
```bash
@@ -396,6 +404,24 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
```
**使用 Antigravity 账户池**
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
```bash
# 1. 设置 Base URL 为 Antigravity 专用路径
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
# 2. 设置 API Key在后台创建权限需包含 'all' 或 'gemini'
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
# 3. 指定模型名称(直接使用短名,无需前缀!)
export ANTHROPIC_MODEL="claude-opus-4-5"
# 4. 启动
claude
```
**VSCode Claude 插件配置:**
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
@@ -408,6 +434,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 方式访问**
@@ -597,8 +625,9 @@ gpt-5 # Codex使用固定模型ID
- 所有账号类型都使用相同的API密钥在后台统一创建
- 根据不同的路由前缀自动识别账号类型
- `/claude/` - 使用Claude账号池
- `/antigravity/api/` - 使用Antigravity账号池推荐用于Claude Code
- `/droid/claude/` - 使用Droid类型Claude账号池只建议api调用或Droid Cli中使用
- `/gemini/` - 使用Gemini账号池
- `/gemini/` - 使用Gemini账号池
- `/openai/` - 使用Codex账号只支持Openai-Response格式
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池只建议api调用或Droid Cli中使用
- 支持所有标准API端点messages、models等

View File

@@ -1,5 +1,10 @@
# Claude Relay Service
> [!CAUTION]
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
>
> **Please update to v1.1.249+ 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)

21
SECURITY.md Normal file
View File

@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.

View File

@@ -1 +1 @@
1.1.224
1.1.258

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字符的加密密钥

38
package-lock.json generated
View File

@@ -20,12 +20,14 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"google-auth-library": "^10.1.0",
"heapdump": "^0.3.15",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6",
"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",
@@ -891,7 +893,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -3000,7 +3001,6 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3082,7 +3082,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3538,7 +3537,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4426,7 +4424,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4483,7 +4480,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -5403,6 +5399,19 @@
"node": ">= 0.4"
}
},
"node_modules/heapdump": {
"version": "0.3.15",
"resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz",
"integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"nan": "^2.13.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/helmet": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
@@ -7018,6 +7027,12 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC"
},
"node_modules/nan": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
"integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -7034,6 +7049,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",
@@ -7582,7 +7606,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9101,7 +9124,6 @@
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",

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

71
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.10.1
node-cron:
specifier: ^4.2.1
version: 4.2.1
nodemailer:
specifier: ^7.0.6
version: 7.0.11
@@ -108,6 +111,9 @@ importers:
prettier:
specifier: ^3.6.2
version: 3.7.4
prettier-plugin-tailwindcss:
specifier: ^0.7.2
version: 0.7.2(prettier@3.7.4)
supertest:
specifier: ^6.3.3
version: 6.3.4
@@ -2144,6 +2150,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -2302,6 +2312,61 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier-plugin-tailwindcss@0.7.2:
resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
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
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
@@ -5692,6 +5757,8 @@ snapshots:
negotiator@0.6.4: {}
node-cron@4.2.1: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -5840,6 +5907,10 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
dependencies:
prettier: 3.7.4
prettier@3.7.4: {}
pretty-format@29.7.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')
@@ -165,7 +179,7 @@ class Application {
// 🔧 基础中间件
this.app.use(
express.json({
limit: '10mb',
limit: '100mb',
verify: (req, res, buf, encoding) => {
// 验证JSON格式
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
@@ -174,7 +188,7 @@ class Application {
}
})
)
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
this.app.use(securityMiddleware)
// 🎯 信任代理
@@ -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)
}
/**
@@ -508,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) {
@@ -754,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({
@@ -837,7 +862,7 @@ async function handleKeyInfo(req, res) {
res.json({
id: keyData.id,
name: keyData.name,
permissions: keyData.permissions || 'all',
permissions: keyData.permissions,
token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens,
tokens_remaining:
@@ -927,7 +952,8 @@ function handleSimpleEndpoint(apiMethod) {
const client = await geminiAccountService.getOauthClient(
accessToken,
refreshToken,
proxyConfig
proxyConfig,
account.oauthProvider
)
// 直接转发请求体,不做特殊处理
@@ -1006,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
@@ -1104,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
@@ -1256,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)
}
@@ -1366,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)
@@ -1388,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({
@@ -1410,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) {
@@ -1578,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)
@@ -1600,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({
@@ -1622,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')
@@ -1978,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)
@@ -2024,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
)
}
}
// 记录使用统计
@@ -2263,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)
@@ -2306,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)
@@ -700,7 +1434,6 @@ const authenticateAdmin = async (req, res, next) => {
// 设置管理员信息(只包含必要信息)
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: token,
loginTime: adminSession.loginTime
@@ -833,17 +1566,25 @@ const authenticateUserOrAdmin = async (req, res, next) => {
try {
const adminSession = await redis.getSession(adminToken)
if (adminSession && Object.keys(adminSession).length > 0) {
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: adminToken,
loginTime: adminSession.loginTime
}
req.userType = 'admin'
// 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致)
if (!adminSession.username || !adminSession.loginTime) {
logger.security(
`🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
)
await redis.deleteSession(adminToken) // 清理无效/伪造的会话
// 不返回 401继续尝试用户认证
} else {
req.admin = {
username: adminSession.username,
sessionId: adminToken,
loginTime: adminSession.loginTime
}
req.userType = 'admin'
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
const authDuration = Date.now() - startTime
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next()
}
}
} catch (error) {
logger.debug('Admin authentication failed, trying user authentication:', error.message)
@@ -1022,9 +1763,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 +1801,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 +2050,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 || '100', 10)
const maxSize = MAX_SIZE_MB * 1024 * 1024
const contentLength = parseInt(req.headers['content-length'] || '0')
if (contentLength > maxSize) {
@@ -1306,7 +2059,7 @@ const requestSizeLimit = (req, res, next) => {
return res.status(413).json({
error: 'Payload Too Large',
message: 'Request body size exceeds limit',
limit: '10MB'
limit: `${MAX_SIZE_MB}MB`
})
}

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分配
@@ -919,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,
@@ -928,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
}
}
@@ -943,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]
@@ -975,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
}
@@ -1029,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,
@@ -1339,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({
@@ -1438,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
@@ -1549,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(
@@ -1830,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

@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
description,
region,
awsCredentials,
bearerToken,
defaultModel,
priority,
accountType,
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
}
// 验证credentialType的有效性
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
})
}
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
description: description || '',
region: region || 'us-east-1',
awsCredentials,
bearerToken,
defaultModel,
priority: priority || 50,
accountType: accountType || 'shared',
credentialType: credentialType || 'default'
credentialType: credentialType || 'access_key'
})
if (!result.success) {
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
// 验证credentialType的有效性
if (
mappedUpdates.credentialType &&
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
) {
return res.status(400).json({
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
})
}
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
}
})
// 测试Bedrock账户连接
// 测试Bedrock账户连接SSE 流式)
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await bedrockAccountService.testAccount(accountId)
if (!result.success) {
return res.status(500).json({ error: 'Account test failed', message: result.error })
}
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
return res.json({ success: true, data: result.data })
await bedrockAccountService.testAccountConnection(accountId, res)
} catch (error) {
logger.error('❌ Failed to test Bedrock account:', error)
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
// 错误已在服务层处理,这里仅做日志记录
}
})

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

@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
dailyQuota,
quotaResetTime,
maxConcurrentTasks,
disableAutoProtection
disableAutoProtection,
interceptWarmup
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks)
: 0,
disableAutoProtection: normalizedDisableAutoProtection
disableAutoProtection: normalizedDisableAutoProtection,
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
})
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)

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

@@ -8,6 +8,7 @@ const geminiApiAccountService = require('../../services/geminiApiAccountService'
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const droidAccountService = require('../../services/droidAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
@@ -25,6 +26,7 @@ const accountTypeNames = {
gemini: 'Gemini',
'gemini-api': 'Gemini API',
droid: 'Droid',
bedrock: 'AWS Bedrock',
unknown: '未知渠道'
}
@@ -37,7 +39,8 @@ const resolveAccountByPlatform = async (accountId, platform) => {
openai: openaiAccountService,
'openai-responses': openaiResponsesAccountService,
droid: droidAccountService,
ccr: ccrAccountService
ccr: ccrAccountService,
bedrock: bedrockAccountService
}
if (platform && serviceMap[platform]) {
@@ -161,7 +164,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
'openai-responses',
'gemini',
'gemini-api',
'droid'
'droid',
'bedrock'
]
if (!allowedPlatforms.includes(platform)) {
return res.status(400).json({
@@ -174,7 +178,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
openai: 'openai',
'openai-responses': 'openai-responses',
'gemini-api': 'gemini-api',
droid: 'droid'
droid: 'droid',
bedrock: 'bedrock'
}
const fallbackModelMap = {
@@ -184,7 +189,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
'openai-responses': 'gpt-4o-mini-2024-07-18',
gemini: 'gemini-1.5-flash',
'gemini-api': 'gemini-2.0-flash',
droid: 'unknown'
droid: 'unknown',
bedrock: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'
}
// 获取账户信息以获取创建时间
@@ -215,6 +221,11 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
case 'droid':
accountData = await droidAccountService.getAccount(accountId)
break
case 'bedrock': {
const result = await bedrockAccountService.getAccount(accountId)
accountData = result?.success ? result.data : null
break
}
}
if (accountData && accountData.createdAt) {
@@ -882,7 +893,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
try {
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
const allowedGroups = ['claude', 'openai', 'gemini', 'droid']
const allowedGroups = ['claude', 'openai', 'gemini', 'droid', 'bedrock']
if (!allowedGroups.includes(group)) {
return res.status(400).json({
success: false,
@@ -894,7 +905,8 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
claude: 'Claude账户',
openai: 'OpenAI账户',
gemini: 'Gemini账户',
droid: 'Droid账户'
droid: 'Droid账户',
bedrock: 'Bedrock账户'
}
// 拉取各平台账号列表
@@ -988,6 +1000,18 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
platform: 'droid'
}
})
} else if (group === 'bedrock') {
const result = await bedrockAccountService.getAllAccounts()
const bedrockAccounts = result?.success ? result.data : []
accounts = bedrockAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || `Bedrock账号 ${shortId}`,
platform: 'bedrock'
}
})
}
if (!accounts || accounts.length === 0) {

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,21 +50,90 @@ 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'
) {
const forcedVendor = req._anthropicVendor || null
const requiredService =
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
message:
requiredService === 'gemini'
? '此 API Key 无权访问 Gemini 服务'
: '此 API Key 无权访问 Claude 服务'
}
})
}
@@ -100,6 +182,25 @@ async function handleMessagesRequest(req, res) {
}
}
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 baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
}
// 检查是否为流式请求
const isStream = req.body.stream === true
@@ -122,12 +223,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 +272,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 +330,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,14 +362,72 @@ 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账号使用原有的转发服务会自己选择账号
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
const _apiKeyId = req.apiKey.id
const _rateLimitInfo = req.rateLimitInfo
const _requestBody = req.body // 传递后清除引用
const _apiKey = req.apiKey
const _headers = req.headers
await claudeRelayService.relayStreamRequestWithUsageCapture(
req.body,
req.apiKey,
_requestBody,
_apiKey,
res,
req.headers,
_headers,
(usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info(
@@ -227,13 +477,13 @@ async function handleMessagesRequest(req, res) {
}
apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude')
.catch((error) => {
logger.error('❌ Failed to record stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfo,
{
inputTokens,
outputTokens,
@@ -258,11 +508,18 @@ async function handleMessagesRequest(req, res) {
)
} else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdConsole = req.apiKey.id
const _rateLimitInfoConsole = req.rateLimitInfo
const _requestBodyConsole = req.body
const _apiKeyConsole = req.apiKey
const _headersConsole = req.headers
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
req.body,
req.apiKey,
_requestBodyConsole,
_apiKeyConsole,
res,
req.headers,
_headersConsole,
(usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info(
@@ -313,7 +570,7 @@ async function handleMessagesRequest(req, res) {
apiKeyService
.recordUsageWithDetails(
req.apiKey.id,
_apiKeyIdConsole,
usageObject,
model,
usageAccountId,
@@ -324,7 +581,7 @@ async function handleMessagesRequest(req, res) {
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoConsole,
{
inputTokens,
outputTokens,
@@ -350,6 +607,11 @@ async function handleMessagesRequest(req, res) {
)
} else if (accountType === 'bedrock') {
// Bedrock账号使用Bedrock转发服务
// 🧹 内存优化:提取需要的值
const _apiKeyIdBedrock = req.apiKey.id
const _rateLimitInfoBedrock = req.rateLimitInfo
const _requestBodyBedrock = req.body
try {
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
if (!bedrockAccountResult.success) {
@@ -357,7 +619,7 @@ async function handleMessagesRequest(req, res) {
}
const result = await bedrockRelayService.handleStreamRequest(
req.body,
_requestBodyBedrock,
bedrockAccountResult.data,
res
)
@@ -368,13 +630,21 @@ async function handleMessagesRequest(req, res) {
const outputTokens = result.usage.output_tokens || 0
apiKeyService
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
.recordUsage(
_apiKeyIdBedrock,
inputTokens,
outputTokens,
0,
0,
result.model,
accountId
)
.catch((error) => {
logger.error('❌ Failed to record Bedrock stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoBedrock,
{
inputTokens,
outputTokens,
@@ -399,11 +669,18 @@ async function handleMessagesRequest(req, res) {
}
} else if (accountType === 'ccr') {
// CCR账号使用CCR转发服务需要传递accountId
// 🧹 内存优化:提取需要的值
const _apiKeyIdCcr = req.apiKey.id
const _rateLimitInfoCcr = req.rateLimitInfo
const _requestBodyCcr = req.body
const _apiKeyCcr = req.apiKey
const _headersCcr = req.headers
await ccrRelayService.relayStreamRequestWithUsageCapture(
req.body,
req.apiKey,
_requestBodyCcr,
_apiKeyCcr,
res,
req.headers,
_headersCcr,
(usageData) => {
// 回调函数当检测到完整usage数据时记录真实token使用量
logger.info(
@@ -453,13 +730,13 @@ async function handleMessagesRequest(req, res) {
}
apiKeyService
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
.catch((error) => {
logger.error('❌ Failed to record CCR stream usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoCcr,
{
inputTokens,
outputTokens,
@@ -494,15 +771,121 @@ async function handleMessagesRequest(req, res) {
}
}, 1000) // 1秒后检查
} else {
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
const _apiKeyIdNonStream = req.apiKey.id
const _apiKeyNameNonStream = req.apiKey.name
const _rateLimitInfoNonStream = req.rateLimitInfo
const _requestBodyNonStream = req.body
const _apiKeyNonStream = req.apiKey
const _headersNonStream = req.headers
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
logger.warn(
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
)
return undefined
}
// 非流式响应 - 只使用官方真实usage数据
logger.info('📄 Starting non-streaming request', {
apiKeyId: req.apiKey.id,
apiKeyName: req.apiKey.name
apiKeyId: _apiKeyIdNonStream,
apiKeyName: _apiKeyNameNonStream
})
// 📊 监听 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 +894,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 +920,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(_requestBodyNonStream)) {
logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
)
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
}
}
// 根据账号类型选择对应的转发服务
let response
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
@@ -536,11 +978,11 @@ async function handleMessagesRequest(req, res) {
if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务
response = await claudeRelayService.relayRequest(
req.body,
req.apiKey,
req,
_requestBodyNonStream,
_apiKeyNonStream,
req, // clientRequest 用于断开检测,保留但服务层已优化
res,
req.headers
_headersNonStream
)
} else if (accountType === 'claude-console') {
// Claude Console账号使用Console转发服务
@@ -548,11 +990,11 @@ async function handleMessagesRequest(req, res) {
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
)
response = await claudeConsoleRelayService.relayRequest(
req.body,
req.apiKey,
req,
_requestBodyNonStream,
_apiKeyNonStream,
req, // clientRequest 保留用于断开检测
res,
req.headers,
_headersNonStream,
accountId
)
} else if (accountType === 'bedrock') {
@@ -564,9 +1006,9 @@ async function handleMessagesRequest(req, res) {
}
const result = await bedrockRelayService.handleNonStreamRequest(
req.body,
_requestBodyNonStream,
bedrockAccountResult.data,
req.headers
_headersNonStream
)
// 构建标准响应格式
@@ -596,11 +1038,11 @@ async function handleMessagesRequest(req, res) {
// CCR账号使用CCR转发服务
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
response = await ccrRelayService.relayRequest(
req.body,
req.apiKey,
req,
_requestBodyNonStream,
_apiKeyNonStream,
req, // clientRequest 保留用于断开检测
res,
req.headers,
_headersNonStream,
accountId
)
}
@@ -611,6 +1053,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 冲突
@@ -640,14 +1091,14 @@ async function handleMessagesRequest(req, res) {
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
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 rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
const model = usageBaseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response
await apiKeyService.recordUsage(
req.apiKey.id,
_apiKeyIdNonStream,
inputTokens,
outputTokens,
cacheCreateTokens,
@@ -657,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
)
await queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoNonStream,
{
inputTokens,
outputTokens,
@@ -676,10 +1127,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 +1269,65 @@ 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') {
if (!apiKeyService.hasPermission(req.apiKey?.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 +1336,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,20 +1464,62 @@ 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) => {
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null
const requiredService =
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Claude'
message:
requiredService === 'gemini'
? 'This API key does not have permission to access Gemini'
: 'This API key does not have permission to access Claude'
}
})
}
if (requiredService === 'gemini') {
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 🔗 会话绑定验证(与 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 +1527,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 +1722,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

@@ -155,7 +155,7 @@ router.post('/api/user-stats', async (req, res) => {
restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients,
permissions: keyData.permissions || 'all',
permissions: keyData.permissions,
// 添加激活相关字段
expirationMode: keyData.expirationMode || 'fixed',
isActivated: keyData.isActivated === 'true',
@@ -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

@@ -8,6 +8,7 @@ const router = express.Router()
const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth')
const claudeRelayService = require('../services/claudeRelayService')
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
const openaiToClaude = require('../services/openaiToClaude')
const apiKeyService = require('../services/apiKeyService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
@@ -19,8 +20,7 @@ const { getEffectiveModel } = require('../utils/modelHelper')
// 🔧 辅助函数:检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
const permissions = apiKeyData.permissions || 'all'
return permissions === 'all' || permissions === requiredPermission
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
@@ -235,7 +235,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
}
throw error
}
const { accountId } = accountSelection
const { accountId, accountType } = accountSelection
// 获取该账号存储的 Claude Code headers
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
@@ -265,72 +265,105 @@ async function handleChatCompletion(req, res, apiKeyData) {
}
})
// 使用转换后的响应流 (使用 OAuth-only beta header添加 Claude Code 必需的 headers)
await claudeRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
(usage) => {
// 记录使用统计
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
const model = usage.model || claudeRequest.model
const cacheCreateTokens =
(usage.cache_creation && typeof usage.cache_creation === 'object'
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
: usage.cache_creation_input_tokens || 0) || 0
const cacheReadTokens = usage.cache_read_input_tokens || 0
// 使用转换后的响应流 (根据账户类型选择转发服务)
// 创建 usage 回调函数
const usageCallback = (usage) => {
// 记录使用统计
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
const model = usage.model || claudeRequest.model
const cacheCreateTokens =
(usage.cache_creation && typeof usage.cache_creation === 'object'
? (usage.cache_creation.ephemeral_5m_input_tokens || 0) +
(usage.cache_creation.ephemeral_1h_input_tokens || 0)
: usage.cache_creation_input_tokens || 0) || 0
const cacheReadTokens = usage.cache_read_input_tokens || 0
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
apiKeyService
.recordUsageWithDetails(
apiKeyData.id,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
model,
accountId
)
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreateTokens,
cacheReadTokens
},
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
apiKeyService
.recordUsageWithDetails(
apiKeyData.id,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
model,
'openai-claude-stream'
accountId,
accountType
)
}
},
// 流转换器
(() => {
// 为每个请求创建独立的会话ID
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
})(),
{
betaHeader:
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
})
queueRateLimitUpdate(
req.rateLimitInfo,
{
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreateTokens,
cacheReadTokens
},
model,
`openai-${accountType}-stream`
)
}
)
}
// 创建流转换器
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
const streamTransformer = (chunk) =>
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
// 根据账户类型选择转发服务
if (accountType === 'claude-console') {
// Claude Console 账户使用 Console 转发服务
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
usageCallback,
accountId,
streamTransformer
)
} else {
// Claude Official 账户使用标准转发服务
await claudeRelayService.relayStreamRequestWithUsageCapture(
claudeRequest,
apiKeyData,
res,
claudeCodeHeaders,
usageCallback,
streamTransformer,
{
betaHeader:
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
}
)
}
} else {
// 非流式请求
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
// 发送请求到 Claude (使用 OAuth-only beta header添加 Claude Code 必需的 headers)
const claudeResponse = await claudeRelayService.relayRequest(
claudeRequest,
apiKeyData,
req,
res,
claudeCodeHeaders,
{ betaHeader: 'oauth-2025-04-20' }
)
// 根据账户类型选择转发服务
let claudeResponse
if (accountType === 'claude-console') {
// Claude Console 账户使用 Console 转发服务
claudeResponse = await claudeConsoleRelayService.relayRequest(
claudeRequest,
apiKeyData,
req,
res,
claudeCodeHeaders,
accountId
)
} else {
// Claude Official 账户使用标准转发服务
claudeResponse = await claudeRelayService.relayRequest(
claudeRequest,
apiKeyData,
req,
res,
claudeCodeHeaders,
{ betaHeader: 'oauth-2025-04-20' }
)
}
// 解析 Claude 响应
let claudeData
@@ -376,7 +409,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
apiKeyData.id,
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
claudeRequest.model,
accountId
accountId,
accountType
)
.catch((error) => {
logger.error('❌ Failed to record usage:', error)
@@ -391,7 +425,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
cacheReadTokens
},
claudeRequest.model,
'openai-claude-non-stream'
`openai-${accountType}-non-stream`
)
}
@@ -402,16 +436,29 @@ async function handleChatCompletion(req, res, apiKeyData) {
const duration = Date.now() - startTime
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
} catch (error) {
logger.error('❌ OpenAI-Claude request error:', error)
// 客户端主动断开连接是正常情况,使用 INFO 级别
if (error.message === 'Client disconnected') {
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
} else {
logger.error('❌ OpenAI-Claude request error:', error)
}
const status = error.status || 500
res.status(status).json({
error: {
message: error.message || 'Internal server error',
type: 'server_error',
code: 'internal_error'
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
if (!res.headersSent) {
// 客户端断开使用 499 状态码 (Client Closed Request)
if (error.message === 'Client disconnected') {
res.status(499).end()
} else {
const status = error.status || 500
res.status(status).json({
error: {
message: error.message || 'Internal server error',
type: 'server_error',
code: 'internal_error'
}
})
}
})
}
} finally {
// 清理资源
if (abortController) {

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) {
@@ -613,17 +673,24 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
}
}
// 返回 OpenAI 格式的错误响应
const status = error.status || 500
const errorResponse = {
error: error.error || {
message: error.message || 'Internal server error',
type: 'server_error',
code: 'internal_error'
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
if (!res.headersSent) {
// 客户端断开使用 499 状态码 (Client Closed Request)
if (error.message === 'Client disconnected') {
res.status(499).end()
} else {
// 返回 OpenAI 格式的错误响应
const status = error.status || 500
const errorResponse = {
error: error.error || {
message: error.message || 'Internal server error',
type: 'server_error',
code: 'internal_error'
}
}
res.status(status).json(errorResponse)
}
}
res.status(status).json(errorResponse)
} finally {
// 清理资源
if (abortController) {
@@ -633,8 +700,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
return undefined
})
// OpenAI 兼容的模型列表端点
router.get('/v1/models', authenticateApiKey, async (req, res) => {
// 获取可用模型列表的共享处理器
async function handleGetModels(req, res) {
try {
const apiKeyData = req.apiKey
@@ -665,8 +732,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 +759,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))
@@ -698,8 +789,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
}
})
}
return undefined
})
}
// OpenAI 兼容的模型列表端点 (带 v1 版)
router.get('/v1/models', authenticateApiKey, handleGetModels)
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
router.get('/models', authenticateApiKey, handleGetModels)
// OpenAI 兼容的模型详情端点
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {

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 = {}) {
@@ -905,7 +904,7 @@ router.get('/key-info', authenticateApiKey, async (req, res) => {
id: keyData.id,
name: keyData.name,
description: keyData.description,
permissions: keyData.permissions || 'all',
permissions: keyData.permissions,
token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens,
tokens_remaining:

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()
@@ -45,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
// 检查权限
const permissions = req.apiKey.permissions || 'all'
const { permissions } = req.apiKey
if (backend === 'claude') {
// Claude 后端:通过 OpenAI 兼容层
if (permissions !== 'all' && permissions !== 'claude') {
if (!apiKeyService.hasPermission(permissions, 'claude')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access Claude',
@@ -61,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
await handleChatCompletion(req, res, req.apiKey)
} else if (backend === 'openai') {
// OpenAI 后端
if (permissions !== 'all' && permissions !== 'openai') {
if (!apiKeyService.hasPermission(permissions, 'openai')) {
return res.status(403).json({
error: {
message: 'This API key does not have permission to access OpenAI',
@@ -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,789 @@
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
}
const result = await service.getAccount(accountId)
// 处理不同服务返回格式的差异
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.success ? result.data : null
}
return result
}
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 queryMode = this._parseQueryMode(options.queryApi)
const useCache = options.useCache !== false
const accountId = account?.id
if (!accountId) {
// 如果账户缺少 id返回空响应而不是抛出错误避免接口报错和UI错误
this.logger.warn('账户缺少 id返回空余额数据', { account, platform })
return this._buildResponse(
{
status: 'error',
errorMessage: '账户数据异常',
balance: null,
currency: 'USD',
quota: null,
statistics: {},
lastRefreshAt: new Date().toISOString()
},
'unknown',
platform,
'local',
null,
{ scriptEnabled: false, scriptConfigured: false }
)
}
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
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)
// 安全限制queryApi=auto 仅用于 Antigravitygemini + oauthProvider=antigravity账户
const effectiveQueryMode =
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
? 'local'
: queryMode
// local: 仅本地统计/缓存auto: 优先缓存,无缓存则尝试远程 Provider并缓存结果
if (effectiveQueryMode !== 'api') {
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
)
}
}
if (effectiveQueryMode === 'local') {
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
}
_parseQueryMode(value) {
if (value === 'auto') {
return 'auto'
}
const parsed = this._parseBoolean(value)
return parsed ? 'api' : 'local'
}
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,595 @@
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',
requestType: 'agent'
}
}
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) => {
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
return true
}
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
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
const safeDataToString = (value) => {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
// 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 msg = safeDataToString(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,250 @@
const BaseBalanceProvider = require('./baseBalanceProvider')
const antigravityClient = require('../antigravityClient')
const geminiAccountService = require('../geminiAccountService')
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
function clamp01(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
if (value < 0) {
return 0
}
if (value > 1) {
return 1
}
return value
}
function round2(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
return Math.round(value * 100) / 100
}
function normalizeQuotaCategory(displayName, modelId) {
const name = String(displayName || '')
const id = String(modelId || '')
if (name.includes('Gemini') && name.includes('Pro')) {
return 'Gemini Pro'
}
if (name.includes('Gemini') && name.includes('Flash')) {
return 'Gemini Flash'
}
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
return 'Gemini Image'
}
if (name.includes('Claude') || name.includes('GPT-OSS')) {
return 'Claude'
}
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
return 'Gemini Pro'
}
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
return 'Gemini Flash'
}
if (id.includes('image')) {
return 'Gemini Image'
}
if (id.includes('claude') || id.includes('gpt-oss')) {
return 'Claude'
}
return name || id || 'Unknown'
}
function buildAntigravityQuota(modelsResponse) {
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
if (!models || typeof models !== 'object') {
return null
}
const parseRemainingFraction = (quotaInfo) => {
if (!quotaInfo || typeof quotaInfo !== 'object') {
return null
}
const raw =
quotaInfo.remainingFraction ??
quotaInfo.remaining_fraction ??
quotaInfo.remaining ??
undefined
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
if (!Number.isFinite(num)) {
return null
}
return clamp01(num)
}
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
const categoryMap = new Map()
for (const [modelId, modelDataRaw] of Object.entries(models)) {
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
continue
}
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
const remainingFraction = parseRemainingFraction(quotaInfo)
if (remainingFraction === null) {
continue
}
const remainingPercent = round2(remainingFraction * 100)
const usedPercent = round2(100 - remainingPercent)
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
const category = normalizeQuotaCategory(displayName, modelId)
if (!allowedCategories.has(category)) {
continue
}
const entry = {
category,
modelId,
displayName: String(displayName || modelId || category),
remainingPercent,
usedPercent,
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
}
const existing = categoryMap.get(category)
if (!existing || entry.remainingPercent < existing.remainingPercent) {
categoryMap.set(category, entry)
}
}
const buckets = fixedOrder.map((category) => {
const existing = categoryMap.get(category) || null
if (existing) {
return existing
}
return {
category,
modelId: '',
displayName: category,
remainingPercent: null,
usedPercent: null,
resetAt: null
}
})
if (buckets.length === 0) {
return null
}
const critical = buckets
.filter((item) => item.remainingPercent !== null)
.reduce((min, item) => {
if (!min) {
return item
}
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
}, null)
if (!critical) {
return null
}
return {
balance: null,
currency: 'USD',
quota: {
type: 'antigravity',
total: 100,
used: critical.usedPercent,
remaining: critical.remainingPercent,
percentage: critical.usedPercent,
resetAt: critical.resetAt,
buckets: buckets.map((item) => ({
category: item.category,
remaining: item.remainingPercent,
used: item.usedPercent,
percentage: item.usedPercent,
resetAt: item.resetAt
}))
},
queryMethod: 'api',
rawData: {
modelsCount: Object.keys(models).length,
bucketCount: buckets.length
}
}
}
class GeminiBalanceProvider extends BaseBalanceProvider {
constructor() {
super('gemini')
}
async queryBalance(account) {
const oauthProvider = account?.oauthProvider
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
return this.readQuotaFromFields(account)
}
return { balance: null, currency: 'USD', queryMethod: 'local' }
}
const accessToken = String(account?.accessToken || '').trim()
const refreshToken = String(account?.refreshToken || '').trim()
const proxyConfig = account?.proxyConfig || account?.proxy || null
if (!accessToken) {
throw new Error('Antigravity 账户缺少 accessToken')
}
const fetch = async (token) =>
await antigravityClient.fetchAvailableModels({
accessToken: token,
proxyConfig
})
let data
try {
data = await fetch(accessToken)
} catch (error) {
const status = error?.response?.status
if ((status === 401 || status === 403) && refreshToken) {
const refreshed = await geminiAccountService.refreshAccessToken(
refreshToken,
proxyConfig,
OAUTH_PROVIDER_ANTIGRAVITY
)
const nextToken = String(refreshed?.access_token || '').trim()
if (!nextToken) {
throw error
}
data = await fetch(nextToken)
} else {
throw error
}
}
const mapped = buildAntigravityQuota(data)
if (!mapped) {
return {
balance: null,
currency: 'USD',
quota: null,
queryMethod: 'api',
rawData: data || null
}
}
return mapped
}
}
module.exports = GeminiBalanceProvider

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,25 @@
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
const GenericBalanceProvider = require('./genericBalanceProvider')
const GeminiBalanceProvider = require('./geminiBalanceProvider')
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 GeminiBalanceProvider())
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,210 @@
const vm = require('vm')
const axios = require('axios')
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
/**
* SSRF防护检查URL是否访问内网或敏感地址
* @param {string} url - 要检查的URL
* @returns {boolean} - true表示URL安全
*/
function isUrlSafe(url) {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
// 禁止的协议
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false
}
// 禁止访问localhost和私有IP
const privatePatterns = [
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^169\.254\./, // AWS metadata
/^0\./, // 0.0.0.0
/^::1$/,
/^fc00:/i,
/^fe80:/i,
/\.local$/i,
/\.internal$/i,
/\.localhost$/i
]
for (const pattern of privatePatterns) {
if (pattern.test(hostname)) {
return false
}
}
return true
} catch {
return false
}
}
/**
* 可配置脚本余额查询执行器
* - 脚本格式:({ 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 不能为空')
}
// SSRF防护验证URL安全性
if (!isUrlSafe(request.url)) {
throw new Error('脚本 request.url 不安全禁止访问内网地址、localhost或使用非HTTP(S)协议')
}
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

@@ -35,12 +35,13 @@ class BedrockAccountService {
description = '',
region = process.env.AWS_REGION || 'us-east-1',
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key
} = options
const accountId = uuidv4()
@@ -71,6 +72,11 @@ class BedrockAccountService {
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
}
// 加密存储 Bearer Token
if (bearerToken) {
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
}
const client = redis.getClientSafe()
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
@@ -106,9 +112,85 @@ class BedrockAccountService {
const account = JSON.parse(accountData)
// 解密AWS凭证用于内部使用
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
// 根据凭证类型解密对应的凭证
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
try {
let accessKeyDecrypted = false
let bearerTokenDecrypted = false
// 第一步:按照 credentialType 尝试解密对应的凭证
if (account.credentialType === 'access_key' && account.awsCredentials) {
// Access Key 模式:解密 AWS 凭证
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.debug(
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
// Bearer Token 模式:解密 Bearer Token
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.debug(
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
)
} else if (!account.credentialType || account.credentialType === 'default') {
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
}
logger.debug(
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
)
}
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.warn(
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
)
if (account.awsCredentials) {
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
accessKeyDecrypted = true
logger.warn(
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
)
}
if (account.bearerToken) {
const decrypted = this._decryptAwsCredentials(account.bearerToken)
account.bearerToken = decrypted.token
bearerTokenDecrypted = true
logger.warn(
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
)
}
}
// 验证至少解密了一种凭证
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
logger.error(
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
)
return {
success: false,
error: 'No valid credentials found in account data'
}
}
} catch (decryptError) {
logger.error(
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
decryptError
)
return {
success: false,
error: `Credentials decryption failed: ${decryptError.message}`
}
}
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
@@ -155,7 +237,11 @@ class BedrockAccountService {
updatedAt: account.updatedAt,
type: 'bedrock',
platform: 'bedrock',
hasCredentials: !!account.awsCredentials
// 根据凭证类型判断是否有凭证
hasCredentials:
account.credentialType === 'bearer_token'
? !!account.bearerToken
: !!account.awsCredentials
})
}
}
@@ -235,6 +321,15 @@ class BedrockAccountService {
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
}
// 更新 Bearer Token
if (updates.bearerToken !== undefined) {
if (updates.bearerToken) {
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
} else {
delete account.bearerToken
}
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
if (updates.subscriptionExpiresAt !== undefined) {
@@ -345,13 +440,45 @@ class BedrockAccountService {
const account = accountResult.data
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
logger.info(
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
)
// 尝试获取模型列表来测试连接
// 验证凭证是否已解密
const hasValidCredentials =
(account.credentialType === 'access_key' && account.awsCredentials) ||
(account.credentialType === 'bearer_token' && account.bearerToken) ||
(!account.credentialType && (account.awsCredentials || account.bearerToken))
if (!hasValidCredentials) {
logger.error(
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
)
return {
success: false,
error: 'No valid credentials found after decryption'
}
}
// 尝试创建 Bedrock 客户端来验证凭证格式
try {
bedrockRelayService._getBedrockClient(account.region, account)
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
} catch (clientError) {
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
return {
success: false,
error: `Failed to create Bedrock client: ${clientError.message}`
}
}
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
const models = await bedrockRelayService.getAvailableModels(account)
if (models && models.length > 0) {
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
logger.info(
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
)
return {
success: true,
data: {
@@ -376,6 +503,135 @@ class BedrockAccountService {
}
}
/**
* 🧪 测试 Bedrock 账户连接SSE 流式返回,供前端测试页面使用)
* @param {string} accountId - 账户ID
* @param {Object} res - Express response 对象
* @param {string} model - 测试使用的模型
*/
async testAccountConnection(accountId, res, model = null) {
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
try {
// 获取账户信息
const accountResult = await this.getAccount(accountId)
if (!accountResult.success) {
throw new Error(accountResult.error || 'Account not found')
}
const account = accountResult.data
// 根据账户类型选择合适的测试模型
if (!model) {
// Access Key 模式使用 Haiku更快更便宜
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
logger.info(
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
)
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.status(200)
// 发送 test_start 事件
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
// 构造测试请求体Bedrock 格式)
const bedrockPayload = {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 256,
messages: [
{
role: 'user',
content:
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
}
]
}
// 获取 Bedrock 客户端
const region = account.region || bedrockRelayService.defaultRegion
const client = bedrockRelayService._getBedrockClient(region, account)
// 创建流式调用命令
const command = new InvokeModelWithResponseStreamCommand({
modelId: model,
body: JSON.stringify(bedrockPayload),
contentType: 'application/json',
accept: 'application/json'
})
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
const startTime = Date.now()
const response = await client.send(command)
// 处理流式响应
// let responseText = ''
for await (const chunk of response.body) {
if (chunk.chunk) {
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
// 提取文本内容
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
const { text } = chunkData.delta
// responseText += text
// 发送 content 事件
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
}
// 检测错误
if (chunkData.type === 'error') {
throw new Error(chunkData.error?.message || 'Bedrock API error')
}
}
}
const duration = Date.now() - startTime
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
// 发送 message_stop 事件(前端兼容)
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
// 发送 test_complete 事件
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
// 结束响应
res.end()
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
} catch (error) {
logger.error(`❌ Test Bedrock account connection failed:`, error)
// 发送错误事件给前端
try {
// 检查响应流是否仍然可写
if (!res.writableEnded && !res.destroyed) {
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.status(200)
}
const errorMsg = error.message || '测试失败'
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
res.end()
}
} catch (writeError) {
logger.error('Failed to write error to response stream:', writeError)
}
// 不再重新抛出错误,避免路由层再次处理
// throw error
}
}
/**
* 检查账户订阅是否过期
* @param {Object} account - 账户对象

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() {
@@ -47,13 +48,17 @@ class BedrockRelayService {
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
sessionToken: bedrockAccount.awsCredentials.sessionToken
}
} else if (bedrockAccount?.bearerToken) {
// Bearer Token 模式AWS SDK >= 3.400.0 会自动检测环境变量
clientConfig.token = { token: bedrockAccount.bearerToken }
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
} else {
// 检查是否有环境变量凭证
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
clientConfig.credentials = fromEnv()
} else {
throw new Error(
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
)
}
}
@@ -69,7 +74,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 +156,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 +189,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 +296,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 +380,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
)
}
}
}
}
@@ -231,6 +435,18 @@ class BedrockRelayService {
_mapToBedrockModel(modelName) {
// 标准Claude模型名到Bedrock模型名的映射表
const modelMapping = {
// Claude 4.5 Opus
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
// Claude 4.5 Sonnet
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
// Claude 4.5 Haiku
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
// Claude Sonnet 4
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',

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

@@ -91,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()
@@ -136,7 +138,11 @@ class ClaudeAccountService {
// 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '',
// 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
}
} else {
// 兼容旧格式
@@ -168,7 +174,11 @@ class ClaudeAccountService {
// 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '',
// 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
}
}
@@ -216,7 +226,8 @@ class ClaudeAccountService {
useUnifiedUserAgent,
useUnifiedClientId,
unifiedClientId,
extInfo: normalizedExtInfo
extInfo: normalizedExtInfo,
interceptWarmup
}
}
@@ -574,7 +585,11 @@ class ClaudeAccountService {
// 添加停止原因
stoppedReason: account.stoppedReason || null,
// 扩展信息
extInfo: parsedExtInfo
extInfo: parsedExtInfo,
// 账户级用户消息串行队列限制
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
// 拦截预热请求
interceptWarmup: account.interceptWarmup === 'true'
}
})
)
@@ -666,7 +681,9 @@ class ClaudeAccountService {
'useUnifiedClientId',
'unifiedClientId',
'subscriptionExpiresAt',
'extInfo'
'extInfo',
'maxConcurrency',
'interceptWarmup'
]
const updatedData = { ...accountData }
let shouldClearAutoStopFields = false
@@ -681,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') {
// 处理订阅信息更新

View File

@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
disableAutoProtection = false, // 是否关闭自动防护429/401/400/529 不自动禁用)
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options
// 验证必填字段
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
}
const client = redis.getClientSafe()
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关
interceptWarmup, // 新增:返回预热请求拦截开关
activeTaskCount: 0 // 新增新建账户当前并发数为0
}
}
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
// 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true'
disableAutoProtection: accountData.disableAutoProtection === 'true',
// 拦截预热请求
interceptWarmup: accountData.interceptWarmup === 'true'
})
}
}
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
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,8 +32,68 @@ 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) {
@@ -203,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)
@@ -366,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
)
}
}
}
}
@@ -384,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) {
@@ -483,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 {
// 🛑 清理租约刷新定时器
@@ -517,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
)
}
}
}
}
@@ -530,7 +727,8 @@ class ClaudeConsoleRelayService {
accountId,
usageCallback,
streamTransformer = null,
requestOptions = {}
requestOptions = {},
onResponseHeaderReceived = null
) {
return new Promise((resolve, reject) => {
let aborted = false
@@ -593,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}`)
// 错误响应处理
@@ -682,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()
}
@@ -690,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()
}
@@ -701,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) {
@@ -714,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'
})
}
@@ -744,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()
@@ -865,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(
@@ -888,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) {
@@ -937,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)
@@ -954,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(
@@ -1002,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(
@@ -1041,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)
}
// 🕐 更新最后使用时间
@@ -1179,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

@@ -72,7 +72,8 @@ class RateLimitCleanupService {
const results = {
openai: { checked: 0, cleared: 0, errors: [] },
claude: { checked: 0, cleared: 0, errors: [] },
claudeConsole: { checked: 0, cleared: 0, errors: [] }
claudeConsole: { checked: 0, cleared: 0, errors: [] },
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
}
// 清理 OpenAI 账号
@@ -84,21 +85,29 @@ class RateLimitCleanupService {
// 清理 Claude Console 账号
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
// 主动刷新等待重置的 Claude 账户 Token防止 5小时/7天 等待期间 Token 过期)
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
const totalChecked =
results.openai.checked + results.claude.checked + results.claudeConsole.checked
const totalCleared =
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
const duration = Date.now() - startTime
if (totalCleared > 0) {
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
logger.info(
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)`
)
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
logger.info(
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
)
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
logger.info(
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
)
}
// 发送 webhook 恢复通知
if (this.clearedAccounts.length > 0) {
@@ -114,7 +123,8 @@ class RateLimitCleanupService {
const allErrors = [
...results.openai.errors,
...results.claude.errors,
...results.claudeConsole.errors
...results.claudeConsole.errors,
...results.tokenRefresh.errors
]
if (allErrors.length > 0) {
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
@@ -348,6 +358,75 @@ class RateLimitCleanupService {
}
}
/**
* 主动刷新 Claude 账户 Token防止等待重置期间 Token 过期)
* 仅对因限流/配额限制而等待重置的账户执行刷新:
* - 429 限流账户rateLimitAutoStopped=true
* - 5小时限制自动停止账户fiveHourAutoStopped=true
* 不处理错误状态账户error/temp_error
*/
async proactiveRefreshClaudeTokens(result) {
try {
const redis = require('../models/redis')
const accounts = await redis.getAllClaudeAccounts()
const now = Date.now()
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
for (const account of accounts) {
// 1. 必须激活
if (account.isActive !== 'true') {
continue
}
// 2. 必须有 refreshToken
if (!account.refreshToken) {
continue
}
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
// 正常调度的账户会在请求时自动刷新,无需主动刷新
// 错误状态账户的 Token 可能已失效,刷新也会失败
const isWaitingForReset =
account.rateLimitAutoStopped === 'true' || // 429 限流
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
if (!isWaitingForReset) {
continue
}
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
if (now - lastRefreshAt < recentRefreshMs) {
continue
}
// 5. 检查 Token 是否即将过期30分钟内
const expiresAt = parseInt(account.expiresAt)
if (expiresAt && now < expiresAt - refreshAheadMs) {
continue
}
// 符合条件,执行刷新
result.checked++
try {
await claudeAccountService.refreshAccountToken(account.id)
result.refreshed++
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
}
}
} catch (error) {
logger.error('Failed to proactively refresh Claude tokens:', error)
result.errors.push({ error: error.message })
}
}
/**
* 手动触发一次清理(供 API 或 CLI 调用)
*/

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

@@ -180,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
@@ -1711,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 path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
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 safeRotatingAppend(filename, line)
} 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 path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
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 safeRotatingAppend(filename, line)
} 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 path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
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 safeRotatingAppend(filename, line)
} 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

@@ -0,0 +1,175 @@
const path = require('path')
const logger = require('./logger')
const { getProjectRoot } = require('./projectPaths')
const { safeRotatingAppend } = require('./safeRotatingAppend')
const UPSTREAM_RESPONSE_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP'
const UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP_MAX_BYTES'
const UPSTREAM_RESPONSE_DUMP_FILENAME = 'antigravity-upstream-responses-dump.jsonl'
function isEnabled() {
const raw = process.env[UPSTREAM_RESPONSE_DUMP_ENV]
if (!raw) {
return false
}
const normalized = String(raw).trim().toLowerCase()
return normalized === '1' || normalized === 'true'
}
function getMaxBytes() {
const raw = process.env[UPSTREAM_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: 'antigravity_upstream_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: 'antigravity_upstream_response_dump_truncated',
maxBytes,
originalBytes: Buffer.byteLength(json, 'utf8'),
partialJson: truncated
})
}
/**
* 记录 Antigravity 上游 API 的响应
* @param {Object} responseInfo - 响应信息
* @param {string} responseInfo.requestId - 请求 ID
* @param {string} responseInfo.model - 模型名称
* @param {number} responseInfo.statusCode - HTTP 状态码
* @param {string} responseInfo.statusText - HTTP 状态文本
* @param {Object} responseInfo.headers - 响应头
* @param {string} responseInfo.responseType - 响应类型 (stream/non-stream/error)
* @param {Object} responseInfo.summary - 响应摘要
* @param {Object} responseInfo.error - 错误信息(如果有)
*/
async function dumpAntigravityUpstreamResponse(responseInfo) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
type: 'antigravity_upstream_response',
requestId: responseInfo?.requestId || null,
model: responseInfo?.model || null,
statusCode: responseInfo?.statusCode || null,
statusText: responseInfo?.statusText || null,
responseType: responseInfo?.responseType || null,
headers: responseInfo?.headers || null,
summary: responseInfo?.summary || null,
error: responseInfo?.error || null,
rawData: responseInfo?.rawData || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await safeRotatingAppend(filename, line)
} catch (e) {
logger.warn('Failed to dump Antigravity upstream response', {
filename,
requestId: responseInfo?.requestId || null,
error: e?.message || String(e)
})
}
}
/**
* 记录 SSE 流中的每个事件(用于详细调试)
*/
async function dumpAntigravityStreamEvent(eventInfo) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
type: 'antigravity_stream_event',
requestId: eventInfo?.requestId || null,
eventIndex: eventInfo?.eventIndex || null,
eventType: eventInfo?.eventType || null,
data: eventInfo?.data || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await safeRotatingAppend(filename, line)
} catch (e) {
// 静默处理,避免日志过多
}
}
/**
* 记录流式响应的最终摘要
*/
async function dumpAntigravityStreamSummary(summaryInfo) {
if (!isEnabled()) {
return
}
const maxBytes = getMaxBytes()
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
const record = {
ts: new Date().toISOString(),
type: 'antigravity_stream_summary',
requestId: summaryInfo?.requestId || null,
model: summaryInfo?.model || null,
totalEvents: summaryInfo?.totalEvents || 0,
finishReason: summaryInfo?.finishReason || null,
hasThinking: summaryInfo?.hasThinking || false,
hasToolCalls: summaryInfo?.hasToolCalls || false,
toolCallNames: summaryInfo?.toolCallNames || [],
usage: summaryInfo?.usage || null,
error: summaryInfo?.error || null,
textPreview: summaryInfo?.textPreview || null
}
const line = `${safeJsonStringify(record, maxBytes)}\n`
try {
await safeRotatingAppend(filename, line)
} catch (e) {
logger.warn('Failed to dump Antigravity stream summary', {
filename,
requestId: summaryInfo?.requestId || null,
error: e?.message || String(e)
})
}
}
module.exports = {
dumpAntigravityUpstreamResponse,
dumpAntigravityStreamEvent,
dumpAntigravityStreamSummary,
UPSTREAM_RESPONSE_DUMP_ENV,
UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV,
UPSTREAM_RESPONSE_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)
}
/**

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

@@ -0,0 +1,46 @@
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'
}
/**
* 是否允许执行"余额脚本"(安全开关)
* ⚠️ 安全警告vm模块非安全沙箱默认禁用。如需启用请显式设置 BALANCE_SCRIPT_ENABLED=true
* 仅在完全信任管理员且了解RCE风险时才启用此功能
*/
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 : false
}
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
}
}

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
}

View File

@@ -0,0 +1,88 @@
/**
* ============================================================================
* 安全 JSONL 追加工具(带文件大小限制与自动轮转)
* ============================================================================
*
* 用于所有调试 Dump 模块,避免日志文件无限增长导致 I/O 拥塞。
*
* 策略:
* - 每次写入前检查目标文件大小
* - 超过阈值时,将现有文件重命名为 .bak覆盖旧 .bak
* - 然后写入新文件
*/
const fs = require('fs/promises')
const logger = require('./logger')
// 默认文件大小上限10MB
const DEFAULT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
const MAX_FILE_SIZE_ENV = 'DUMP_MAX_FILE_SIZE_BYTES'
/**
* 获取文件大小上限(可通过环境变量覆盖)
*/
function getMaxFileSize() {
const raw = process.env[MAX_FILE_SIZE_ENV]
if (raw) {
const parsed = Number.parseInt(raw, 10)
if (Number.isFinite(parsed) && parsed > 0) {
return parsed
}
}
return DEFAULT_MAX_FILE_SIZE_BYTES
}
/**
* 获取文件大小,文件不存在时返回 0
*/
async function getFileSize(filepath) {
try {
const stat = await fs.stat(filepath)
return stat.size
} catch (e) {
// 文件不存在或无法读取
return 0
}
}
/**
* 安全追加写入 JSONL 文件,支持自动轮转
*
* @param {string} filepath - 目标文件绝对路径
* @param {string} line - 要写入的单行(应以 \n 结尾)
* @param {Object} options - 可选配置
* @param {number} options.maxFileSize - 文件大小上限(字节),默认从环境变量或 10MB
*/
async function safeRotatingAppend(filepath, line, options = {}) {
const maxFileSize = options.maxFileSize || getMaxFileSize()
const currentSize = await getFileSize(filepath)
// 如果当前文件已达到或超过阈值,轮转
if (currentSize >= maxFileSize) {
const backupPath = `${filepath}.bak`
try {
// 先删除旧备份(如果存在)
await fs.unlink(backupPath).catch(() => {})
// 重命名当前文件为备份
await fs.rename(filepath, backupPath)
} catch (renameErr) {
// 轮转失败时记录警告日志,继续写入原文件
logger.warn('⚠️ Log rotation failed, continuing to write to original file', {
filepath,
backupPath,
error: renameErr?.message || String(renameErr)
})
}
}
// 追加写入
await fs.appendFile(filepath, line, { encoding: 'utf8' })
}
module.exports = {
safeRotatingAppend,
getMaxFileSize,
MAX_FILE_SIZE_ENV,
DEFAULT_MAX_FILE_SIZE_BYTES
}

183
src/utils/signatureCache.js Normal file
View File

@@ -0,0 +1,183 @@
/**
* Signature Cache - 签名缓存模块
*
* 用于缓存 Antigravity thinking block 的 thoughtSignature。
* Claude Code 客户端可能剥离非标准字段,导致多轮对话时签名丢失。
* 此模块按 sessionId + thinkingText 存储签名,便于后续请求恢复。
*
* 参考实现:
* - CLIProxyAPI: internal/cache/signature_cache.go
* - antigravity-claude-proxy: src/format/signature-cache.js
*/
const crypto = require('crypto')
const logger = require('./logger')
// 配置常量
const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000 // 1 小时(同 CLIProxyAPI
const MAX_ENTRIES_PER_SESSION = 100 // 每会话最大缓存条目
const MIN_SIGNATURE_LENGTH = 50 // 最小有效签名长度
const TEXT_HASH_LENGTH = 16 // 文本哈希长度SHA256 前 16 位)
// 主缓存sessionId -> Map<textHash, { signature, timestamp }>
const signatureCache = new Map()
/**
* 生成文本内容的稳定哈希值
* @param {string} text - 待哈希的文本
* @returns {string} 16 字符的十六进制哈希
*/
function hashText(text) {
if (!text || typeof text !== 'string') {
return ''
}
const hash = crypto.createHash('sha256').update(text).digest('hex')
return hash.slice(0, TEXT_HASH_LENGTH)
}
/**
* 获取或创建会话缓存
* @param {string} sessionId - 会话 ID
* @returns {Map} 会话的签名缓存 Map
*/
function getOrCreateSessionCache(sessionId) {
if (!signatureCache.has(sessionId)) {
signatureCache.set(sessionId, new Map())
}
return signatureCache.get(sessionId)
}
/**
* 检查签名是否有效
* @param {string} signature - 待检查的签名
* @returns {boolean} 签名是否有效
*/
function isValidSignature(signature) {
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH
}
/**
* 缓存 thinking 签名
* @param {string} sessionId - 会话 ID
* @param {string} thinkingText - thinking 内容文本
* @param {string} signature - thoughtSignature
*/
function cacheSignature(sessionId, thinkingText, signature) {
if (!sessionId || !thinkingText || !signature) {
return
}
if (!isValidSignature(signature)) {
return
}
const sessionCache = getOrCreateSessionCache(sessionId)
const textHash = hashText(thinkingText)
if (!textHash) {
return
}
// 淘汰策略:超过限制时删除最老的 1/4 条目
if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) {
const entries = Array.from(sessionCache.entries())
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
const toRemove = Math.max(1, Math.floor(entries.length / 4))
for (let i = 0; i < toRemove; i++) {
sessionCache.delete(entries[i][0])
}
logger.debug(
`[SignatureCache] Evicted ${toRemove} old entries for session ${sessionId.slice(0, 8)}...`
)
}
sessionCache.set(textHash, {
signature,
timestamp: Date.now()
})
logger.debug(
`[SignatureCache] Cached signature for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
)
}
/**
* 获取缓存的签名
* @param {string} sessionId - 会话 ID
* @param {string} thinkingText - thinking 内容文本
* @returns {string|null} 缓存的签名,未找到或过期则返回 null
*/
function getCachedSignature(sessionId, thinkingText) {
if (!sessionId || !thinkingText) {
return null
}
const sessionCache = signatureCache.get(sessionId)
if (!sessionCache) {
return null
}
const textHash = hashText(thinkingText)
if (!textHash) {
return null
}
const entry = sessionCache.get(textHash)
if (!entry) {
return null
}
// 检查是否过期
if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {
sessionCache.delete(textHash)
logger.debug(`[SignatureCache] Entry expired for hash ${textHash}`)
return null
}
logger.debug(
`[SignatureCache] Cache hit for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
)
return entry.signature
}
/**
* 清除会话缓存
* @param {string} sessionId - 要清除的会话 ID为空则清除全部
*/
function clearSignatureCache(sessionId = null) {
if (sessionId) {
signatureCache.delete(sessionId)
logger.debug(`[SignatureCache] Cleared cache for session ${sessionId.slice(0, 8)}...`)
} else {
signatureCache.clear()
logger.debug('[SignatureCache] Cleared all caches')
}
}
/**
* 获取缓存统计信息(调试用)
* @returns {Object} { sessionCount, totalEntries }
*/
function getCacheStats() {
let totalEntries = 0
for (const sessionCache of signatureCache.values()) {
totalEntries += sessionCache.size
}
return {
sessionCount: signatureCache.size,
totalEntries
}
}
module.exports = {
cacheSignature,
getCachedSignature,
clearSignatureCache,
getCacheStats,
isValidSignature,
// 内部函数导出(用于测试或扩展)
hashText,
MIN_SIGNATURE_LENGTH,
MAX_ENTRIES_PER_SESSION,
SIGNATURE_CACHE_TTL_MS
}

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

@@ -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
>
@@ -822,41 +852,194 @@
</div>
<!-- Bedrock 特定字段 -->
<div v-if="form.platform === 'bedrock' && !isEdit" class="space-y-4">
<div v-if="form.platform === 'bedrock'" class="space-y-4">
<!-- 凭证类型选择器 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 访问密钥 ID *</label
>凭证类型 *</label
>
<input
v-model="form.accessKeyId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.accessKeyId }"
placeholder="请输入 AWS Access Key ID"
required
type="text"
/>
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
{{ errors.accessKeyId }}
</p>
<div v-if="!isEdit" class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="access_key"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>AWS Access Key(访问密钥)</span
>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="bearer_token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>Bearer Token长期令牌</span
>
</label>
</div>
<div v-else class="flex gap-4">
<label class="flex items-center opacity-60">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
disabled
type="radio"
value="access_key"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>AWS Access Key访问密钥</span
>
</label>
<label class="flex items-center opacity-60">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
disabled
type="radio"
value="bearer_token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>Bearer Token长期令牌</span
>
</label>
</div>
<div
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
<div class="text-xs text-blue-700 dark:text-blue-300">
<p v-if="form.credentialType === 'access_key'" class="font-medium">
使用 AWS Access Key ID 和 Secret Access Key 进行身份验证(支持临时凭证)
</p>
<p v-else class="font-medium">
使用 AWS Bedrock API Keys 生成的 Bearer Token
进行身份验证,更简单、权限范围更小
</p>
<p v-if="isEdit" class="mt-1 text-xs italic">
💡 编辑模式下凭证类型不可更改,如需切换类型请重新创建账户
</p>
</div>
</div>
</div>
</div>
<div>
<!-- AWS Access Key 字段(仅在 access_key 模式下显示)-->
<div v-if="form.credentialType === 'access_key'">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 访问密钥 ID {{ isEdit ? '' : '*' }}</label
>
<input
v-model="form.accessKeyId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.accessKeyId }"
:placeholder="isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Access Key ID'"
:required="!isEdit"
type="text"
/>
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
{{ errors.accessKeyId }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Access Key ID 不变
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 秘密访问密钥 {{ isEdit ? '' : '*' }}</label
>
<input
v-model="form.secretAccessKey"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.secretAccessKey }"
:placeholder="
isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Secret Access Key'
"
:required="!isEdit"
type="password"
/>
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
{{ errors.secretAccessKey }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Secret Access Key 不变
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>会话令牌 (可选)</label
>
<input
v-model="form.sessionToken"
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="
isEdit
? '留空则保持原有 Session Token 不变'
: '如果使用临时凭证,请输入会话令牌'
"
type="password"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
仅在使用临时 AWS 凭证时需要填写
</p>
</div>
</div>
<!-- Bearer Token 字段(仅在 bearer_token 模式下显示)-->
<div v-if="form.credentialType === 'bearer_token'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 秘密访问密钥 *</label
>Bearer Token {{ isEdit ? '' : '*' }}</label
>
<input
v-model="form.secretAccessKey"
v-model="form.bearerToken"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.secretAccessKey }"
placeholder="请输入 AWS Secret Access Key"
required
:class="{ 'border-red-500': errors.bearerToken }"
:placeholder="
isEdit ? '留空则保持原有 Bearer Token 不变' : '请输入 AWS Bearer Token'
"
:required="!isEdit"
type="password"
/>
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
{{ errors.secretAccessKey }}
<p v-if="errors.bearerToken" class="mt-1 text-xs text-red-500">
{{ errors.bearerToken }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Bearer Token 不变
</p>
<div
class="mt-2 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-key mt-0.5 text-green-600 dark:text-green-400" />
<div class="text-xs text-green-700 dark:text-green-300">
<p class="mb-1 font-medium">Bearer Token 说明:</p>
<ul class="list-inside list-disc space-y-1 text-xs">
<li>输入 AWS Bedrock API Keys 生成的 Bearer Token</li>
<li>Bearer Token 仅限 Bedrock 服务访问,权限范围更小</li>
<li>相比 Access Key 更简单,无需 Secret Key</li>
<li>
参考:<a
class="text-green-600 underline dark:text-green-400"
href="https://aws.amazon.com/cn/blogs/machine-learning/accelerate-ai-development-with-amazon-bedrock-api-keys/"
target="_blank"
>AWS 官方文档</a
>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- AWS 区域(两种凭证类型都需要)-->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 区域 *</label
@@ -872,10 +1055,12 @@
<p v-if="errors.region" class="mt-1 text-xs text-red-500">
{{ errors.region }}
</p>
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-info-circle mt-0.5 text-blue-600" />
<div class="text-xs text-blue-700">
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
<div class="text-xs text-blue-700 dark:text-blue-300">
<p class="mb-1 font-medium">常用 AWS 区域参考:</p>
<div class="grid grid-cols-2 gap-1 text-xs">
<span>• us-east-1 (美国东部)</span>
@@ -885,27 +1070,14 @@
<span>• ap-northeast-1 (东京)</span>
<span>• eu-central-1 (法兰克福)</span>
</div>
<p class="mt-2 text-blue-600">💡 请输入完整的区域代码,如 us-east-1</p>
<p class="mt-2 text-blue-600 dark:text-blue-400">
💡 请输入完整的区域代码,如 us-east-1
</p>
</div>
</div>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>会话令牌 (可选)</label
>
<input
v-model="form.sessionToken"
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="如果使用临时凭证,请输入会话令牌"
type="password"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
仅在使用临时 AWS 凭证时需要填写
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>默认主模型 (可选)</label
@@ -1320,10 +1492,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 +1506,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"
@@ -1650,6 +1804,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">
@@ -1801,7 +1996,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
@@ -1838,12 +2033,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>
@@ -1944,6 +2141,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 模式输入 -->
@@ -1989,6 +2202,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"
>
@@ -2536,7 +2765,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
>
@@ -2601,6 +2830,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">
@@ -3639,6 +3906,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" />
@@ -3771,7 +4054,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'
@@ -3906,7 +4189,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'
})(),
@@ -3919,6 +4203,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 || '',
@@ -3960,10 +4247,12 @@ const form = ref({
// 并发控制字段
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
// Bedrock 特定字段
credentialType: props.account?.credentialType || 'access_key', // 'access_key' 或 'bearer_token'
accessKeyId: props.account?.accessKeyId || '',
secretAccessKey: props.account?.secretAccessKey || '',
region: props.account?.region || '',
sessionToken: props.account?.sessionToken || '',
bearerToken: props.account?.bearerToken || '', // Bearer Token 字段
defaultModel: props.account?.defaultModel || '',
smallFastModel: props.account?.smallFastModel || '',
// Azure OpenAI 特定字段
@@ -4126,6 +4415,7 @@ const errors = ref({
accessKeyId: '',
secretAccessKey: '',
region: '',
bearerToken: '',
azureEndpoint: '',
deploymentName: ''
})
@@ -4245,7 +4535,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'
}
@@ -4282,7 +4572,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(
@@ -4505,9 +4799,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',
@@ -4645,6 +4941,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',
@@ -4652,9 +4949,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
}
@@ -4826,14 +5128,27 @@ const createAccount = async () => {
hasError = true
}
} else if (form.value.platform === 'bedrock') {
// Bedrock 验证
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
hasError = true
}
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') {
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
hasError = true
// Bedrock 验证 - 根据凭证类型进行不同验证
if (form.value.credentialType === 'access_key') {
// Access Key 模式:创建时必填,编辑时可选(留空则保持原有凭证)
if (!isEdit.value) {
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
hasError = true
}
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') {
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
hasError = true
}
}
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:创建时必填,编辑时可选(留空则保持原有凭证)
if (!isEdit.value) {
if (!form.value.bearerToken || form.value.bearerToken.trim() === '') {
errors.value.bearerToken = '请填写 Bearer Token'
hasError = true
}
}
}
if (!form.value.region || form.value.region.trim() === '') {
errors.value.region = '请选择 AWS 区域'
@@ -4968,6 +5283,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',
@@ -5059,6 +5375,7 @@ const createAccount = async () => {
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
data.interceptWarmup = !!form.value.interceptWarmup
}
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
@@ -5074,6 +5391,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'
@@ -5083,12 +5404,21 @@ const createAccount = async () => {
? form.value.supportedModels
: []
} else if (form.value.platform === 'bedrock') {
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
data.awsCredentials = {
accessKeyId: form.value.accessKeyId,
secretAccessKey: form.value.secretAccessKey,
sessionToken: form.value.sessionToken || null
// Bedrock 账户特定数据
data.credentialType = form.value.credentialType || 'access_key'
// 根据凭证类型构造不同的凭证对象
if (form.value.credentialType === 'access_key') {
data.awsCredentials = {
accessKeyId: form.value.accessKeyId,
secretAccessKey: form.value.secretAccessKey,
sessionToken: form.value.sessionToken || null
}
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:必须传递 Bearer Token
data.bearerToken = form.value.bearerToken
}
data.region = form.value.region
data.defaultModel = form.value.defaultModel || null
data.smallFastModel = form.value.smallFastModel || null
@@ -5125,7 +5455,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)
@@ -5355,9 +5685,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',
@@ -5390,6 +5722,8 @@ const updateAccount = async () => {
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'
@@ -5412,19 +5746,33 @@ const updateAccount = async () => {
// Bedrock 特定更新
if (props.account.platform === 'bedrock') {
// 只有当有凭证变更时才构造 awsCredentials 对象
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
data.awsCredentials = {}
if (form.value.accessKeyId) {
data.awsCredentials.accessKeyId = form.value.accessKeyId
// 更新凭证类型
if (form.value.credentialType) {
data.credentialType = form.value.credentialType
}
// 根据凭证类型更新凭证
if (form.value.credentialType === 'access_key') {
// 只有当有凭证变更时才构造 awsCredentials 对象
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
data.awsCredentials = {}
if (form.value.accessKeyId) {
data.awsCredentials.accessKeyId = form.value.accessKeyId
}
if (form.value.secretAccessKey) {
data.awsCredentials.secretAccessKey = form.value.secretAccessKey
}
if (form.value.sessionToken !== undefined) {
data.awsCredentials.sessionToken = form.value.sessionToken || null
}
}
if (form.value.secretAccessKey) {
data.awsCredentials.secretAccessKey = form.value.secretAccessKey
}
if (form.value.sessionToken !== undefined) {
data.awsCredentials.sessionToken = form.value.sessionToken || null
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:更新 Bearer Token编辑时可选留空则保留原有凭证
if (form.value.bearerToken && form.value.bearerToken.trim()) {
data.bearerToken = form.value.bearerToken
}
}
if (form.value.region) {
data.region = form.value.region
}
@@ -5958,9 +6306,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 || '',

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

@@ -68,6 +68,22 @@
{{ platformLabel }}
</span>
</div>
<!-- Bedrock 账号类型 -->
<div
v-if="props.account?.platform === 'bedrock'"
class="flex items-center justify-between text-sm"
>
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
credentialTypeBadgeClass
]"
>
<i :class="credentialTypeIcon" />
{{ credentialTypeLabel }}
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
@@ -209,13 +225,15 @@ const platformLabel = computed(() => {
const platform = props.account.platform
if (platform === 'claude') return 'Claude OAuth'
if (platform === 'claude-console') return 'Claude Console'
if (platform === 'bedrock') return 'AWS Bedrock'
return platform
})
const platformIcon = computed(() => {
if (!props.account) return 'fas fa-question'
const platform = props.account.platform
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain'
if (platform === 'claude' || platform === 'claude-console' || platform === 'bedrock')
return 'fas fa-brain'
return 'fas fa-robot'
})
@@ -228,6 +246,39 @@ const platformBadgeClass = computed(() => {
if (platform === 'claude-console') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
}
if (platform === 'bedrock') {
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
}
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
// Bedrock 账号类型相关
const credentialTypeLabel = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'Access Key'
if (credentialType === 'bearer_token') return 'Bearer Token'
return 'Unknown'
})
const credentialTypeIcon = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'fas fa-key'
if (credentialType === 'bearer_token') return 'fas fa-ticket'
return 'fas fa-question'
})
const credentialTypeBadgeClass = computed(() => {
if (!props.account || props.account.platform !== 'bedrock')
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
const credentialType = props.account.credentialType
if (credentialType === 'access_key') {
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
}
if (credentialType === 'bearer_token') {
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
}
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
@@ -346,6 +397,9 @@ function getTestEndpoint() {
if (platform === 'claude-console') {
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
}
if (platform === 'bedrock') {
return `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`
}
return ''
}
@@ -469,7 +523,7 @@ function handleClose() {
emit('close')
}
// 监听show变化重置状态
// 监听show变化重置状态并设置测试模型
watch(
() => props.show,
(newVal) => {
@@ -478,6 +532,21 @@ watch(
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
// 根据平台和账号类型设置测试模型
if (props.account?.platform === 'bedrock') {
const credentialType = props.account.credentialType
if (credentialType === 'bearer_token') {
// Bearer Token 模式使用 Sonnet 4.5
testModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
} else {
// Access Key 模式使用 Haiku更快更便宜
testModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
} else {
// 其他平台使用默认模型
testModel.value = 'claude-sonnet-4-5-20250929'
}
}
}
)

View File

@@ -364,7 +364,8 @@ const platformLabelMap = {
'openai-responses': 'OpenAI Responses',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
droid: 'Droid'
droid: 'Droid',
bedrock: 'Claude AWS Bedrock'
}
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')

View File

@@ -0,0 +1,381 @@
<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 && isAntigravityQuota" class="space-y-2">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>剩余</span>
<span>{{ formatQuotaNumber(quotaInfo.remaining) }}</span>
</div>
<div class="space-y-1">
<div
v-for="row in antigravityRows"
:key="row.category"
class="flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1.5 dark:bg-gray-700/60"
>
<span class="h-2 w-2 shrink-0 rounded-full" :class="row.dotClass"></span>
<span
class="min-w-0 flex-1 truncate text-xs font-medium text-gray-800 dark:text-gray-100"
:title="row.category"
>
{{ row.category }}
</span>
<div class="flex w-[94px] flex-col gap-0.5">
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-1.5 rounded-full transition-all"
:class="row.barClass"
:style="{ width: `${row.remainingPercent ?? 0}%` }"
></div>
</div>
<div
class="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-300"
>
<span>{{ row.remainingText }}</span>
<span v-if="row.resetAt" class="text-gray-400 dark:text-gray-400">{{
formatResetTime(row.resetAt)
}}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="quotaInfo" class="space-y-1">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>已用: {{ formatQuotaNumber(quotaInfo.used) }}</span>
<span>剩余: {{ formatQuotaNumber(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 },
queryMode: { type: String, default: 'local' } // local | auto | api
})
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 isAntigravityQuota = computed(() => {
return balanceData.value?.quota?.type === 'antigravity'
})
const antigravityRows = computed(() => {
if (!isAntigravityQuota.value) return []
const buckets = balanceData.value?.quota?.buckets
const list = Array.isArray(buckets) ? buckets : []
const map = new Map(list.map((b) => [b?.category, b]))
const order = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
const styles = {
'Gemini Pro': { dotClass: 'bg-blue-500', barClass: 'bg-blue-500 dark:bg-blue-400' },
Claude: { dotClass: 'bg-purple-500', barClass: 'bg-purple-500 dark:bg-purple-400' },
'Gemini Flash': { dotClass: 'bg-cyan-500', barClass: 'bg-cyan-500 dark:bg-cyan-400' },
'Gemini Image': { dotClass: 'bg-emerald-500', barClass: 'bg-emerald-500 dark:bg-emerald-400' }
}
return order.map((category) => {
const raw = map.get(category) || null
const remaining = raw?.remaining
const remainingPercent = Number.isFinite(Number(remaining))
? Math.max(0, Math.min(100, Number(remaining)))
: null
return {
category,
remainingPercent,
remainingText: remainingPercent === null ? '—' : `${Math.round(remainingPercent)}%`,
resetAt: raw?.resetAt || null,
dotClass: styles[category]?.dotClass || 'bg-gray-400',
barClass: styles[category]?.barClass || 'bg-gray-400'
}
})
})
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(() => {
// antigravity 配额:允许直接触发 Provider 刷新(无需脚本)
if (props.queryMode === 'api' || props.queryMode === 'auto') {
return true
}
// 其他平台:仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 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 '请先配置余额脚本'
}
if (isAntigravityQuota.value) {
return '刷新配额(调用 Antigravity API'
}
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: props.queryMode === 'api' ? true : props.queryMode === 'auto' ? 'auto' : 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 formatQuotaNumber = (num) => {
if (num === Infinity) return '∞'
const value = Number(num)
if (!Number.isFinite(value)) return 'N/A'
if (isAntigravityQuota.value) {
return `${Math.round(value)}%`
}
return formatNumber(value)
}
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>

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