Compare commits

...

121 Commits

Author SHA1 Message Date
github-actions[bot]
9ebef1b116 chore: sync VERSION file with release v1.1.262 [skip ci] 2026-01-22 07:18:31 +00:00
Wesley Liddick
35f755246e Merge pull request #914 from sczheng189/main
mod: 修改opus周限额为Claude模型的周限额
2026-01-22 15:18:16 +08:00
github-actions[bot]
338d44faee chore: sync VERSION file with release v1.1.261 [skip ci] 2026-01-22 07:08:02 +00:00
shaw
968398ffa5 fix: API Key permissions multi-select save and display issue
- Fix updateApiKey to use JSON.stringify for permissions field
- Add comma-separated string handling in normalizePermissions
- Add frontend parsing for comma-separated permissions format

Fixes issue where selecting multiple permissions (e.g. Claude + OpenAI)
would be saved as "claude,openai" instead of '["claude","openai"]'
2026-01-22 15:07:19 +08:00
shaw
645ab43675 chore: sync latest Claude Code system prompt definitions
Add claudeOtherSystemPrompt5 for CLI billing header detection
2026-01-22 15:07:10 +08:00
sczheng
1027a2e3e2 mod: 修改opus周限额为Claude模型的周限额 2026-01-22 15:04:34 +08:00
github-actions[bot]
0f5321b0ef chore: sync VERSION file with release v1.1.260 [skip ci] 2026-01-21 02:19:34 +00:00
shaw
c7d7bf47d6 fix: 更新claude账号oauth链接生成规则 2026-01-21 10:06:24 +08:00
Wesley Liddick
ebc30b6026 Merge pull request #906 from 0xRichardH/fix-bedrock-sse-stream-event [skip ci]
Fix bedrock sse stream event
2026-01-21 09:38:19 +08:00
Wesley Liddick
d5a7af2d7d Merge pull request #903 from RedwindA/main [skip ci]
feat(droid): add prompt_cache_retention and safety_identifier to fiel…
2026-01-21 09:37:19 +08:00
Richard Hao
81a3e26e27 fix: correct Bedrock SSE stream event format to match Claude API spec
- message_start: nest fields inside 'message' object with type: 'message'
- content_block_delta: add type field to data
- message_delta: add type field to data
- message_stop: remove usage field, just return type
- Extract usage from message_delta instead of message_stop
2026-01-18 11:38:38 +08:00
Richard Hao
64db4a270d fix: handle bedrock content block start/stop events 2026-01-18 10:58:11 +08:00
RedwindA
ca027ecb90 feat(droid): add prompt_cache_retention and safety_identifier to fieldsToRemove 2026-01-16 04:22:05 +08:00
github-actions[bot]
21e6944abb chore: sync VERSION file with release v1.1.259 [skip ci] 2026-01-15 03:07:53 +00:00
Wesley Liddick
4ea3d4830f Merge pull request #858 from zengqinglei/feature/gemini-retrieve-user-quota
feat: 添加 Gemini retrieveUserQuota 接口支持
2026-01-15 11:07:41 +08:00
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
曾庆雷
944ef096b3 fix: eslint 代码风格优化 2026-01-08 18:26:45 +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
曾庆雷
18a493e805 feat: 添加 Gemini retrieveUserQuota 接口支持
支持 Gemini CLI 0.22.2+ 的配额查询功能
实现与现有 v1internal 接口一致的 projectId 处理逻辑
2025-12-24 22:48:27 +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
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
91 changed files with 14930 additions and 870 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

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.234
1.1.262

View File

@@ -205,6 +205,14 @@ const config = {
hotReload: process.env.HOT_RELOAD === 'true'
},
// 💰 账户余额相关配置
accountBalance: {
// 是否允许执行自定义余额脚本(安全开关)
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
// 默认保持开启如需禁用请显式设置BALANCE_SCRIPT_ENABLED=false
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
},
// 📬 用户消息队列配置
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
userMessageQueue: {

18
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
@@ -891,7 +892,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 +3000,6 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3082,7 +3081,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3538,7 +3536,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4426,7 +4423,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 +4479,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7034,6 +7029,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7582,7 +7586,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9101,7 +9104,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')
@@ -80,6 +94,15 @@ class Application {
)
}
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
try {
logger.info('💰 Backfilling current-week Claude weekly cost...')
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
} catch (error) {
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
}
// 🕐 初始化Claude账户会话窗口
logger.info('🕐 Initializing Claude account session windows...')
const claudeAccountService = require('./services/claudeAccountService')
@@ -165,7 +188,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 +197,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 +287,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 +468,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,10 +671,11 @@ class Application {
const now = Date.now()
let totalCleaned = 0
let legacyCleaned = 0
// 使用 Lua 脚本批量清理所有过期项
for (const key of keys) {
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
@@ -599,11 +690,21 @@ class Application {
}
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)
@@ -622,8 +723,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)
@@ -633,6 +736,9 @@ 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)
}
@@ -661,6 +767,19 @@ class Application {
'🚦 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() {
@@ -715,6 +834,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
@@ -1152,6 +1188,110 @@ async function handleOnboardUser(req, res) {
}
}
/**
* 处理 retrieveUserQuota 请求
* POST /v1internal:retrieveUserQuota
*
* 功能查询用户在各个Gemini模型上的配额使用情况
* 请求体:{ "project": "项目ID" }
* 响应:{ "buckets": [...] }
*/
async function handleRetrieveUserQuota(req, res) {
try {
// 1. 权限检查
if (!ensureGeminiPermission(req, res)) {
return undefined
}
// 2. 会话哈希
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 3. 账户选择
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
const { accountId, accountType } = schedulerResult
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
if (accountType === 'gemini-api') {
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
return res.status(400).json({
error: {
message:
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
type: 'invalid_account_type'
}
})
}
// 5. 获取账户
const account = await geminiAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({
error: {
message: 'Gemini account not found',
type: 'account_not_found'
}
})
}
const { accessToken, refreshToken, projectId } = account
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject"
const requestProject = req.body.project
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.info(`RetrieveUserQuota request (${version})`, {
requestedProject: requestProject || null,
accountProject: projectId || null,
apiKeyId: req.apiKey?.id || 'unknown'
})
// 7. 解析账户的代理配置
const proxyConfig = parseProxyConfig(account)
// 8. 获取OAuth客户端
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
// 9. 智能处理项目ID与其他 v1internal 接口保持一致)
const effectiveProject = projectId || requestProject || null
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
accountProjectId: projectId,
requestProject,
effectiveProject,
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
})
// 10. 构建请求体(注入 effectiveProject
const requestBody = { ...req.body }
if (effectiveProject) {
requestBody.project = effectiveProject
}
// 11. 调用底层服务转发请求
const response = await geminiAccountService.forwardToCodeAssist(
client,
'retrieveUserQuota',
requestBody,
proxyConfig
)
res.json(response)
} catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
error: error.message
})
res.status(500).json({
error: 'Internal server error',
message: error.message
})
}
}
/**
* 处理 countTokens 请求
*/
@@ -1256,7 +1396,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 +1507,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 +1536,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 +1564,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 +1742,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 +1771,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 +1798,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 +2165,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 +2219,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 +2469,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 +2520,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 响应头
@@ -2576,6 +2802,7 @@ module.exports = {
handleSimpleEndpoint,
handleLoadCodeAssist,
handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens,
handleGenerateContent,
handleStreamGenerateContent,

View File

@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { calculateWaitTimeStats } = require('../utils/statsHelper')
const { isClaudeFamilyModel } = require('../utils/modelHelper')
// 工具函数
function sleep(ms) {
@@ -1239,20 +1240,20 @@ const authenticateApiKey = async (req, res, next) => {
)
}
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
// 检查 Claude 周费用限制
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
if (weeklyOpusCostLimit > 0) {
// 从请求中获取模型信息
const requestBody = req.body || {}
const model = requestBody.model || ''
// 判断是否为 Opus 模型
if (model && model.toLowerCase().includes('claude-opus')) {
// 判断是否为 Claude 模型
if (isClaudeFamilyModel(model)) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security(
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
@@ -1266,17 +1267,17 @@ const authenticateApiKey = async (req, res, next) => {
resetDate.setHours(0, 0, 0, 0)
return res.status(429).json({
error: 'Weekly Opus cost limit exceeded',
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
error: 'Weekly Claude cost limit exceeded',
message: `已达到 Claude 模型周费用限制 ($${weeklyOpusCostLimit})`,
currentCost: weeklyOpusCost,
costLimit: weeklyOpusCostLimit,
resetAt: resetDate.toISOString() // 下周一重置
})
}
// 记录当前 Opus 费用使用情况
// 记录当前 Claude 费用使用情况
logger.api(
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
@@ -1389,6 +1390,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)
@@ -1422,7 +1435,6 @@ const authenticateAdmin = async (req, res, next) => {
// 设置管理员信息(只包含必要信息)
req.admin = {
id: adminSession.adminId || 'admin',
username: adminSession.username,
sessionId: token,
loginTime: adminSession.loginTime
@@ -1555,17 +1567,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)
@@ -1744,9 +1764,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', () => {
@@ -1778,7 +1802,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相关日志
@@ -2020,7 +2051,7 @@ const globalRateLimit = async (req, res, next) =>
// 📊 请求大小限制中间件
const requestSizeLimit = (req, res, next) => {
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
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')
@@ -2029,7 +2060,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`
})
}

View File

@@ -96,7 +96,25 @@ class RedisClient {
logger.warn('⚠️ Redis connection closed')
})
await this.client.connect()
// 只有在 lazyConnect 模式下才需要手动调用 connect()
// 如果 Redis 已经连接或正在连接中,则跳过
if (
this.client.status !== 'connecting' &&
this.client.status !== 'connect' &&
this.client.status !== 'ready'
) {
await this.client.connect()
} else {
// 等待 ready 状态
await new Promise((resolve, reject) => {
if (this.client.status === 'ready') {
resolve()
} else {
this.client.once('ready', resolve)
this.client.once('error', reject)
}
})
}
return this.client
} catch (error) {
logger.error('💥 Failed to connect to Redis:', error)
@@ -1063,8 +1081,13 @@ class RedisClient {
// 💰 获取本周 Opus 费用
async getWeeklyOpusCost(keyId) {
const currentWeek = getWeekStringInTimezone()
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const cost = await this.client.get(costKey)
const costKey = `usage:claude:weekly:${keyId}:${currentWeek}`
let cost = await this.client.get(costKey)
// 向后兼容:如果新 key 不存在,则回退读取旧的(仅 Opus 口径)周费用 key。
if (cost === null || cost === undefined) {
const legacyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
cost = await this.client.get(legacyKey)
}
const result = parseFloat(cost || 0)
logger.debug(
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
@@ -1072,11 +1095,12 @@ class RedisClient {
return result
}
// 💰 增加本周 Opus 费用
// 💰 增加本周 Claude 费用
async incrementWeeklyOpusCost(keyId, amount) {
const currentWeek = getWeekStringInTimezone()
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const totalKey = `usage:opus:total:${keyId}`
// 注意:尽管函数名沿用旧的 Opus 命名,但当前实现统计的是 Claude 系列模型的“周费用”。
const weeklyKey = `usage:claude:weekly:${keyId}:${currentWeek}`
const totalKey = `usage:claude:total:${keyId}`
logger.debug(
`💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}`
@@ -1093,6 +1117,16 @@ class RedisClient {
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
}
// 💰 覆盖设置本周 Claude 费用(用于启动回填/迁移)
async setWeeklyClaudeCost(keyId, amount, weekString = null) {
const currentWeek = weekString || getWeekStringInTimezone()
const weeklyKey = `usage:claude:weekly:${keyId}:${currentWeek}`
await this.client.set(weeklyKey, String(amount || 0))
// 保留 2 周,足够覆盖“当前周 + 上周”查看/回填
await this.client.expire(weeklyKey, 14 * 24 * 3600)
}
// 💰 计算账户的每日费用(基于模型使用)
async getAccountDailyCost(accountId) {
const CostCalculator = require('../utils/costCalculator')
@@ -1503,6 +1537,123 @@ class RedisClient {
return await this.client.del(key)
}
// 💰 账户余额缓存API 查询结果)
async setAccountBalance(platform, accountId, balanceData, ttl = 3600) {
const key = `account_balance:${platform}:${accountId}`
const payload = {
balance:
balanceData && balanceData.balance !== null && balanceData.balance !== undefined
? String(balanceData.balance)
: '',
currency: balanceData?.currency || 'USD',
lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(),
queryMethod: balanceData?.queryMethod || 'api',
status: balanceData?.status || 'success',
errorMessage: balanceData?.errorMessage || balanceData?.error || '',
rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '',
quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : ''
}
await this.client.hset(key, payload)
await this.client.expire(key, ttl)
}
async getAccountBalance(platform, accountId) {
const key = `account_balance:${platform}:${accountId}`
const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)])
if (!data || Object.keys(data).length === 0) {
return null
}
let rawData = null
if (data.rawData) {
try {
rawData = JSON.parse(data.rawData)
} catch (error) {
rawData = null
}
}
let quota = null
if (data.quota) {
try {
quota = JSON.parse(data.quota)
} catch (error) {
quota = null
}
}
return {
balance: data.balance ? parseFloat(data.balance) : null,
currency: data.currency || 'USD',
lastRefreshAt: data.lastRefreshAt || null,
queryMethod: data.queryMethod || null,
status: data.status || null,
errorMessage: data.errorMessage || '',
rawData,
quota,
ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null
}
}
// 📊 账户余额缓存(本地统计)
async setLocalBalance(platform, accountId, statisticsData, ttl = 300) {
const key = `account_balance_local:${platform}:${accountId}`
await this.client.hset(key, {
estimatedBalance: JSON.stringify(statisticsData || {}),
lastCalculated: new Date().toISOString()
})
await this.client.expire(key, ttl)
}
async getLocalBalance(platform, accountId) {
const key = `account_balance_local:${platform}:${accountId}`
const data = await this.client.hgetall(key)
if (!data || !data.estimatedBalance) {
return null
}
try {
return JSON.parse(data.estimatedBalance)
} catch (error) {
return null
}
}
async deleteAccountBalance(platform, accountId) {
const key = `account_balance:${platform}:${accountId}`
const localKey = `account_balance_local:${platform}:${accountId}`
await this.client.del(key, localKey)
}
// 🧩 账户余额脚本配置
async setBalanceScriptConfig(platform, accountId, scriptConfig) {
const key = `account_balance_script:${platform}:${accountId}`
await this.client.set(key, JSON.stringify(scriptConfig || {}))
}
async getBalanceScriptConfig(platform, accountId) {
const key = `account_balance_script:${platform}:${accountId}`
const raw = await this.client.get(key)
if (!raw) {
return null
}
try {
return JSON.parse(raw)
} catch (error) {
return null
}
}
async deleteBalanceScriptConfig(platform, accountId) {
const key = `account_balance_script:${platform}:${accountId}`
return await this.client.del(key)
}
// 📈 系统统计
async getSystemStats() {
const keys = await Promise.all([
@@ -2122,6 +2273,27 @@ class RedisClient {
const results = []
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
}
// 检查键类型,只处理 Sorted Set
const keyType = await client.type(key)
if (keyType !== 'zset') {
logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`)
continue
}
// 提取 apiKeyId去掉 concurrency: 前缀)
const apiKeyId = key.replace('concurrency:', '')
@@ -2184,6 +2356,23 @@ class RedisClient {
}
}
// 检查键类型,只处理 Sorted Set
const keyType = await client.type(key)
if (keyType !== 'zset') {
logger.warn(
`⚠️ getConcurrencyStatus: key ${key} has unexpected type: ${keyType}, expected zset`
)
return {
apiKeyId,
key,
activeCount: 0,
expiredCount: 0,
activeRequests: [],
exists: true,
invalidType: keyType
}
}
// 获取所有成员和分数
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
@@ -2233,20 +2422,36 @@ class RedisClient {
const client = this.getClientSafe()
const key = `concurrency:${apiKeyId}`
// 获取清理前的状态
const beforeCount = await client.zcard(key)
// 检查键类型
const keyType = await client.type(key)
// 删除整个 key
let beforeCount = 0
let isLegacy = false
if (keyType === 'zset') {
// 正常的 zset 键,获取条目数
beforeCount = await client.zcard(key)
} else if (keyType !== 'none') {
// 非 zset 且非空的遗留键
isLegacy = true
logger.warn(
`⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted`
)
}
// 删除键(无论什么类型)
await client.del(key)
logger.warn(
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
)
return {
apiKeyId,
key,
clearedCount: beforeCount,
type: keyType,
legacy: isLegacy,
success: true
}
} catch (error) {
@@ -2265,25 +2470,47 @@ class RedisClient {
const keys = await client.keys('concurrency:*')
let totalCleared = 0
let legacyCleared = 0
const clearedKeys = []
for (const key of keys) {
const count = await client.zcard(key)
await client.del(key)
totalCleared += count
clearedKeys.push({
key,
clearedCount: count
})
// 跳过 queue 相关的键(它们有各自的清理逻辑)
if (key.startsWith('concurrency:queue:')) {
continue
}
// 检查键类型
const keyType = await client.type(key)
if (keyType === 'zset') {
const count = await client.zcard(key)
await client.del(key)
totalCleared += count
clearedKeys.push({
key,
clearedCount: count,
type: 'zset'
})
} else {
// 非 zset 类型的遗留键,直接删除
await client.del(key)
legacyCleared++
clearedKeys.push({
key,
clearedCount: 0,
type: keyType,
legacy: true
})
}
}
logger.warn(
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
`🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
)
return {
keysCleared: keys.length,
keysCleared: clearedKeys.length,
totalEntriesCleared: totalCleared,
legacyKeysCleared: legacyCleared,
clearedKeys,
success: true
}
@@ -2311,9 +2538,30 @@ class RedisClient {
}
let totalCleaned = 0
let legacyCleaned = 0
const cleanedKeys = []
for (const key of keys) {
// 跳过 queue 相关的键(它们有各自的清理逻辑)
if (key.startsWith('concurrency:queue:')) {
continue
}
// 检查键类型
const keyType = await client.type(key)
if (keyType !== 'zset') {
// 非 zset 类型的遗留键,直接删除
await client.del(key)
legacyCleaned++
cleanedKeys.push({
key,
cleanedCount: 0,
type: keyType,
legacy: true
})
continue
}
// 只清理过期的条目
const cleaned = await client.zremrangebyscore(key, '-inf', now)
if (cleaned > 0) {
@@ -2332,13 +2580,14 @@ class RedisClient {
}
logger.info(
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys`
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed`
)
return {
keysProcessed: keys.length,
keysCleaned: cleanedKeys.length,
totalEntriesCleaned: totalCleaned,
legacyKeysRemoved: legacyCleaned,
cleanedKeys,
success: true
}
@@ -3157,4 +3406,249 @@ redisClient.scanConcurrencyQueueStatsKeys = async function () {
}
}
// ============================================================================
// 账户测试历史相关操作
// ============================================================================
const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录
const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期
const ACCOUNT_TEST_CONFIG_TTL = 86400 * 365 // 测试配置保留1年用户通常长期使用
/**
* 保存账户测试结果
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型 (claude/gemini/openai等)
* @param {Object} testResult - 测试结果对象
* @param {boolean} testResult.success - 是否成功
* @param {string} testResult.message - 测试消息/响应
* @param {number} testResult.latencyMs - 延迟毫秒数
* @param {string} testResult.error - 错误信息(如有)
* @param {string} testResult.timestamp - 测试时间戳
*/
redisClient.saveAccountTestResult = async function (accountId, platform, testResult) {
const key = `account:test_history:${platform}:${accountId}`
try {
const record = JSON.stringify({
...testResult,
timestamp: testResult.timestamp || new Date().toISOString()
})
// 使用 LPUSH + LTRIM 保持最近5条记录
const client = this.getClientSafe()
await client.lpush(key, record)
await client.ltrim(key, 0, ACCOUNT_TEST_HISTORY_MAX - 1)
await client.expire(key, ACCOUNT_TEST_HISTORY_TTL)
logger.debug(`📝 Saved test result for ${platform} account ${accountId}`)
} catch (error) {
logger.error(`Failed to save test result for ${accountId}:`, error)
}
}
/**
* 获取账户测试历史
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 测试历史记录数组(最新在前)
*/
redisClient.getAccountTestHistory = async function (accountId, platform) {
const key = `account:test_history:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const records = await client.lrange(key, 0, -1)
return records.map((r) => JSON.parse(r))
} catch (error) {
logger.error(`Failed to get test history for ${accountId}:`, error)
return []
}
}
/**
* 获取账户最新测试结果
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>} 最新测试结果
*/
redisClient.getAccountLatestTestResult = async function (accountId, platform) {
const key = `account:test_history:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const record = await client.lindex(key, 0)
return record ? JSON.parse(record) : null
} catch (error) {
logger.error(`Failed to get latest test result for ${accountId}:`, error)
return null
}
}
/**
* 批量获取多个账户的测试历史
* @param {Array<{accountId: string, platform: string}>} accounts - 账户列表
* @returns {Promise<Object>} 以 accountId 为 key 的测试历史映射
*/
redisClient.getAccountsTestHistory = async function (accounts) {
const result = {}
try {
const client = this.getClientSafe()
const pipeline = client.pipeline()
for (const { accountId, platform } of accounts) {
const key = `account:test_history:${platform}:${accountId}`
pipeline.lrange(key, 0, -1)
}
const responses = await pipeline.exec()
accounts.forEach(({ accountId }, index) => {
const [err, records] = responses[index]
if (!err && records) {
result[accountId] = records.map((r) => JSON.parse(r))
} else {
result[accountId] = []
}
})
} catch (error) {
logger.error('Failed to get batch test history:', error)
}
return result
}
/**
* 保存定时测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @param {Object} config - 配置对象
* @param {boolean} config.enabled - 是否启用定时测试
* @param {string} config.cronExpression - Cron 表达式 (如 "0 8 * * *" 表示每天8点)
* @param {string} config.model - 测试使用的模型
*/
redisClient.saveAccountTestConfig = async function (accountId, platform, testConfig) {
const key = `account:test_config:${platform}:${accountId}`
try {
const client = this.getClientSafe()
await client.hset(key, {
enabled: testConfig.enabled ? 'true' : 'false',
cronExpression: testConfig.cronExpression || '0 8 * * *', // 默认每天早上8点
model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型
updatedAt: new Date().toISOString()
})
// 设置过期时间1年
await client.expire(key, ACCOUNT_TEST_CONFIG_TTL)
} catch (error) {
logger.error(`Failed to save test config for ${accountId}:`, error)
}
}
/**
* 获取定时测试配置
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<Object|null>} 配置对象
*/
redisClient.getAccountTestConfig = async function (accountId, platform) {
const key = `account:test_config:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const testConfig = await client.hgetall(key)
if (!testConfig || Object.keys(testConfig).length === 0) {
return null
}
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
let { cronExpression } = testConfig
if (!cronExpression && testConfig.testHour) {
const hour = parseInt(testConfig.testHour, 10)
cronExpression = `0 ${hour} * * *`
}
return {
enabled: testConfig.enabled === 'true',
cronExpression: cronExpression || '0 8 * * *',
model: testConfig.model || 'claude-sonnet-4-5-20250929',
updatedAt: testConfig.updatedAt
}
} catch (error) {
logger.error(`Failed to get test config for ${accountId}:`, error)
return null
}
}
/**
* 获取所有启用定时测试的账户
* @param {string} platform - 平台类型
* @returns {Promise<Array>} 账户ID列表及 cron 配置
*/
redisClient.getEnabledTestAccounts = async function (platform) {
const accountIds = []
let cursor = '0'
try {
const client = this.getClientSafe()
do {
const [newCursor, keys] = await client.scan(
cursor,
'MATCH',
`account:test_config:${platform}:*`,
'COUNT',
100
)
cursor = newCursor
for (const key of keys) {
const testConfig = await client.hgetall(key)
if (testConfig && testConfig.enabled === 'true') {
const accountId = key.replace(`account:test_config:${platform}:`, '')
// 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式
let { cronExpression } = testConfig
if (!cronExpression && testConfig.testHour) {
const hour = parseInt(testConfig.testHour, 10)
cronExpression = `0 ${hour} * * *`
}
accountIds.push({
accountId,
cronExpression: cronExpression || '0 8 * * *',
model: testConfig.model || 'claude-sonnet-4-5-20250929'
})
}
}
} while (cursor !== '0')
return accountIds
} catch (error) {
logger.error(`Failed to get enabled test accounts for ${platform}:`, error)
return []
}
}
/**
* 保存账户上次测试时间(用于调度器判断是否需要测试)
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
*/
redisClient.setAccountLastTestTime = async function (accountId, platform) {
const key = `account:last_test:${platform}:${accountId}`
try {
const client = this.getClientSafe()
await client.set(key, Date.now().toString(), 'EX', 86400 * 7) // 7天过期
} catch (error) {
logger.error(`Failed to set last test time for ${accountId}:`, error)
}
}
/**
* 获取账户上次测试时间
* @param {string} accountId - 账户ID
* @param {string} platform - 平台类型
* @returns {Promise<number|null>} 上次测试时间戳
*/
redisClient.getAccountLastTestTime = async function (accountId, platform) {
const key = `account:last_test:${platform}:${accountId}`
try {
const client = this.getClientSafe()
const timestamp = await client.get(key)
return timestamp ? parseInt(timestamp, 10) : null
} catch (error) {
logger.error(`Failed to get last test time for ${accountId}:`, error)
return null
}
}
module.exports = redisClient

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分配
@@ -945,6 +982,30 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
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}`
@@ -1006,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]
@@ -1038,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
}
@@ -1356,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({
@@ -1455,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
@@ -1566,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(
@@ -1847,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

@@ -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,9 +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')
// 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径)
@@ -36,9 +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

@@ -12,7 +12,19 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
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 = '') {
@@ -110,16 +122,18 @@ 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 服务'
}
})
}
@@ -168,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
@@ -363,14 +396,38 @@ async function handleMessagesRequest(req, res) {
}
}
// 🔥 预热请求拦截检查(在转发之前)
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(
@@ -420,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,
@@ -451,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(
@@ -506,7 +570,7 @@ async function handleMessagesRequest(req, res) {
apiKeyService
.recordUsageWithDetails(
req.apiKey.id,
_apiKeyIdConsole,
usageObject,
model,
usageAccountId,
@@ -517,7 +581,7 @@ async function handleMessagesRequest(req, res) {
})
queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoConsole,
{
inputTokens,
outputTokens,
@@ -543,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) {
@@ -550,7 +619,7 @@ async function handleMessagesRequest(req, res) {
}
const result = await bedrockRelayService.handleStreamRequest(
req.body,
_requestBodyBedrock,
bedrockAccountResult.data,
res
)
@@ -561,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,
@@ -592,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(
@@ -646,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,
@@ -687,18 +771,26 @@ 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: ${req.apiKey?.name || 'unknown'}`
`⚠️ 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 事件以追踪连接状态变化
@@ -862,6 +954,21 @@ async function handleMessagesRequest(req, res) {
}
}
// 🔥 预热请求拦截检查(非流式,在转发之前)
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)}`)
@@ -871,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转发服务
@@ -883,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') {
@@ -899,9 +1006,9 @@ async function handleMessagesRequest(req, res) {
}
const result = await bedrockRelayService.handleNonStreamRequest(
req.body,
_requestBodyNonStream,
bedrockAccountResult.data,
req.headers
_headersNonStream
)
// 构建标准响应格式
@@ -931,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
)
}
@@ -984,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,
@@ -1001,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
)
await queueRateLimitUpdate(
req.rateLimitInfo,
_rateLimitInfoNonStream,
{
inputTokens,
outputTokens,
@@ -1162,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 获取所有支持的模型
@@ -1298,20 +1464,27 @@ 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(
@@ -1354,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,
@@ -1552,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

@@ -29,6 +29,7 @@ const {
handleStreamGenerateContent,
handleLoadCodeAssist,
handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens,
handleStandardGenerateContent,
handleStandardStreamGenerateContent,
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
router.get('/key-info', authenticateApiKey, handleKeyInfo)
// ============================================================================
// v1internal 独有路由listExperiments
// v1internal 独有路由
// ============================================================================
/**
@@ -81,6 +82,12 @@ router.post(
handleSimpleEndpoint('listExperiments')
)
/**
* POST /v1internal:retrieveUserQuota
* 获取用户配额信息Gemini CLI 0.22.2+ 需要)
*/
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
/**
* POST /v1beta/models/:modelName:listExperiments
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)

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 = {}) {
@@ -275,7 +274,9 @@ const handleResponses = async (req, res) => {
'text_formatting',
'truncation',
'text',
'service_tier'
'service_tier',
'prompt_cache_retention',
'safety_identifier'
]
fieldsToRemove.forEach((field) => {
delete req.body[field]
@@ -905,7 +906,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

@@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid')
const config = require('../../config/config')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const { isClaudeFamilyModel } = require('../utils/modelHelper')
const ACCOUNT_TYPE_CONFIG = {
claude: { prefix: 'claude:account:' },
@@ -37,6 +38,58 @@ 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 []
}
// 兼容逗号分隔格式(修复历史错误数据,如 "claude,openai"
if (permissions.includes(',')) {
return permissions
.split(',')
.map((p) => p.trim())
.filter(Boolean)
}
// 旧单个字符串转为数组
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 +142,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 +185,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 +239,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 +391,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 +520,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 +578,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)
@@ -708,6 +761,9 @@ class ApiKeyService {
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
// 特殊处理数组字段
updatedData[field] = JSON.stringify(value || [])
} else if (field === 'permissions') {
// 权限字段规范化后JSON序列化与createApiKey保持一致
updatedData[field] = JSON.stringify(normalizePermissions(value))
} else if (
field === 'enableModelRestriction' ||
field === 'enableClientRestriction' ||
@@ -974,6 +1030,9 @@ class ApiKeyService {
logger.database(
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`
)
// 记录 Claude 周费用(如果适用)
await this.recordClaudeWeeklyCost(keyId, costInfo.costs.total, model, null)
} else {
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
}
@@ -1037,35 +1096,31 @@ class ApiKeyService {
}
}
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户
async recordOpusCost(keyId, cost, model, accountType) {
// 📊 记录 Claude 模型费用(API Key 维度
async recordClaudeWeeklyCost(keyId, cost, model, accountType) {
try {
// 判断是否为 Opus 模型
if (!model || !model.toLowerCase().includes('claude-opus')) {
return // 不是 Opus 模型,直接返回
// 判断是否为 Claude 系列模型(包含 Bedrock 格式等)
if (!isClaudeFamilyModel(model)) {
return
}
// 判断是否为 claude、claude-console 或 ccr 账户
if (
!accountType ||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
) {
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
return // 不是 claude 账户,直接返回
}
// 记录 Opus 周费用
// 记录 Claude 周费用
await redis.incrementWeeklyOpusCost(keyId, cost)
logger.database(
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(
`💰 Recorded Claude weekly cost for ${keyId}: $${cost.toFixed(
6
)}, model: ${model}, account type: ${accountType}`
)}, model: ${model}${accountType ? `, account type: ${accountType}` : ''}`
)
} catch (error) {
logger.error('❌ Failed to record Opus cost:', error)
logger.error('❌ Failed to record Claude weekly cost:', error)
}
}
// 向后兼容:旧名字是 Opus-only 口径;现在周费用统计已扩展为 Claude 全模型口径。
async recordOpusCost(keyId, cost, model, accountType) {
return this.recordClaudeWeeklyCost(keyId, cost, model, accountType)
}
// 📊 记录使用情况(新版本,支持详细的缓存类型)
async recordUsageWithDetails(
keyId,
@@ -1165,8 +1220,8 @@ class ApiKeyService {
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
)
// 记录 Opus 周费用(如果适用)
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
// 记录 Claude 周费用(如果适用)
await this.recordClaudeWeeklyCost(keyId, costInfo.totalCost, model, accountType)
// 记录详细的缓存费用(如果有)
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
@@ -1568,7 +1623,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 +1875,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

@@ -48,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'
)
}
}
@@ -339,8 +343,8 @@ class BedrockRelayService {
res.write(`event: ${claudeEvent.type}\n`)
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
// 提取使用统计
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
totalUsage = claudeEvent.data.usage
}
@@ -431,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',
@@ -560,14 +576,28 @@ class BedrockRelayService {
return {
type: 'message_start',
data: {
type: 'message',
id: `msg_${Date.now()}_bedrock`,
role: 'assistant',
content: [],
model: this.defaultModel,
stop_reason: null,
stop_sequence: null,
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
type: 'message_start',
message: {
id: `msg_${Date.now()}_bedrock`,
type: 'message',
role: 'assistant',
content: [],
model: this.defaultModel,
stop_reason: null,
stop_sequence: null,
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
}
}
}
}
if (bedrockChunk.type === 'content_block_start') {
return {
type: 'content_block_start',
data: {
type: 'content_block_start',
index: bedrockChunk.index || 0,
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
}
}
}
@@ -576,16 +606,28 @@ class BedrockRelayService {
return {
type: 'content_block_delta',
data: {
type: 'content_block_delta',
index: bedrockChunk.index || 0,
delta: bedrockChunk.delta || {}
}
}
}
if (bedrockChunk.type === 'content_block_stop') {
return {
type: 'content_block_stop',
data: {
type: 'content_block_stop',
index: bedrockChunk.index || 0
}
}
}
if (bedrockChunk.type === 'message_delta') {
return {
type: 'message_delta',
data: {
type: 'message_delta',
delta: bedrockChunk.delta || {},
usage: bedrockChunk.usage || {}
}
@@ -596,7 +638,7 @@ class BedrockRelayService {
return {
type: 'message_stop',
data: {
usage: bedrockChunk.usage || {}
type: 'message_stop'
}
}
}

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 刷新逻辑,不会覆盖此字段

File diff suppressed because it is too large Load Diff

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

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

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

@@ -121,12 +121,23 @@ class UserMessageQueueService {
* @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) {
async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
const cfg = await this.getConfig()
if (!cfg.enabled) {
// 账户级配置优先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 }
}

View File

@@ -0,0 +1,219 @@
const redis = require('../models/redis')
const logger = require('../utils/logger')
const pricingService = require('./pricingService')
const { isClaudeFamilyModel } = require('../utils/modelHelper')
function pad2(n) {
return String(n).padStart(2, '0')
}
// 生成配置时区下的 YYYY-MM-DD 字符串。
// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的“时区偏移后”的 Date。
function formatTzDateYmd(tzDate) {
return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}`
}
class WeeklyClaudeCostInitService {
_getCurrentWeekDatesInTimezone() {
const tzNow = redis.getDateInTimezone(new Date())
const tzToday = new Date(tzNow)
tzToday.setUTCHours(0, 0, 0, 0)
// ISO 周:周一=1 ... 周日=7
const isoDay = tzToday.getUTCDay() || 7
const tzMonday = new Date(tzToday)
tzMonday.setUTCDate(tzToday.getUTCDate() - (isoDay - 1))
const dates = []
for (let d = new Date(tzMonday); d <= tzToday; d.setUTCDate(d.getUTCDate() + 1)) {
dates.push(formatTzDateYmd(d))
}
return dates
}
_buildWeeklyClaudeKey(keyId, weekString) {
return `usage:claude:weekly:${keyId}:${weekString}`
}
/**
* 启动回填把“本周周一到今天Claude 全模型”周费用从按日/按模型统计里反算出来,
* 写入 `usage:claude:weekly:*`,保证周限额在重启后不归零。
*
* 说明:
* - 只回填本周,不做历史回填(符合“只要本周数据”诉求)
* - 会加分布式锁,避免多实例重复跑
* - 会写 done 标记:同一周内重启默认不重复回填(需要时可手动删掉 done key
*/
async backfillCurrentWeekClaudeCosts() {
const client = redis.getClientSafe()
if (!client) {
logger.warn('⚠️ 本周 Claude 周费用回填跳过Redis client 不可用')
return { success: false, reason: 'redis_unavailable' }
}
if (!pricingService || !pricingService.pricingData) {
logger.warn('⚠️ 本周 Claude 周费用回填跳过pricing service 未初始化')
return { success: false, reason: 'pricing_uninitialized' }
}
const weekString = redis.getWeekStringInTimezone()
const doneKey = `init:weekly_claude_cost:${weekString}:done`
try {
const alreadyDone = await client.get(doneKey)
if (alreadyDone) {
logger.info(` 本周 Claude 周费用回填已完成(${weekString}),跳过`)
return { success: true, skipped: true }
}
} catch (e) {
// 尽力而为:读取失败不阻断启动回填流程。
}
const lockKey = `lock:init:weekly_claude_cost:${weekString}`
const lockValue = `${process.pid}:${Date.now()}`
const lockTtlMs = 15 * 60 * 1000
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs)
if (!lockAcquired) {
logger.info(` 本周 Claude 周费用回填已在运行(${weekString}),跳过`)
return { success: true, skipped: true, reason: 'locked' }
}
const startedAt = Date.now()
try {
logger.info(`💰 开始回填本周 Claude 周费用:${weekString}(仅本周)...`)
const keyIds = await redis.scanApiKeyIds()
const dates = this._getCurrentWeekDatesInTimezone()
const costByKeyId = new Map()
let scannedKeys = 0
let matchedClaudeKeys = 0
const toInt = (v) => {
const n = parseInt(v || '0', 10)
return Number.isFinite(n) ? n : 0
}
// 扫描“按日 + 按模型”的使用统计 key并反算 Claude 系列模型的费用。
for (const dateStr of dates) {
let cursor = '0'
const pattern = `usage:*:model:daily:*:${dateStr}`
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
cursor = nextCursor
scannedKeys += keys.length
const entries = []
for (const usageKey of keys) {
// usage:{keyId}:model:daily:{model}:{YYYY-MM-DD}
const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (!match) {
continue
}
const keyId = match[1]
const model = match[2]
if (!isClaudeFamilyModel(model)) {
continue
}
matchedClaudeKeys++
entries.push({ usageKey, keyId, model })
}
if (entries.length === 0) {
continue
}
const pipeline = client.pipeline()
for (const entry of entries) {
pipeline.hgetall(entry.usageKey)
}
const results = await pipeline.exec()
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const [, data] = results[i] || []
if (!data || Object.keys(data).length === 0) {
continue
}
const inputTokens = toInt(data.totalInputTokens || data.inputTokens)
const outputTokens = toInt(data.totalOutputTokens || data.outputTokens)
const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens)
const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens)
const ephemeral5mTokens = toInt(data.ephemeral5mTokens)
const ephemeral1hTokens = toInt(data.ephemeral1hTokens)
const cacheCreationTotal =
ephemeral5mTokens > 0 || ephemeral1hTokens > 0
? ephemeral5mTokens + ephemeral1hTokens
: cacheCreateTokens
const usage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreationTotal,
cache_read_input_tokens: cacheReadTokens
}
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
usage.cache_creation = {
ephemeral_5m_input_tokens: ephemeral5mTokens,
ephemeral_1h_input_tokens: ephemeral1hTokens
}
}
const costInfo = pricingService.calculateCost(usage, entry.model)
const cost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0
if (cost <= 0) {
continue
}
costByKeyId.set(entry.keyId, (costByKeyId.get(entry.keyId) || 0) + cost)
}
} while (cursor !== '0')
}
// 为所有 API Key 写入本周 claude:weekly key避免读取时回退到旧 opus:weekly 造成口径混淆。
const ttlSeconds = 14 * 24 * 3600
const batchSize = 500
for (let i = 0; i < keyIds.length; i += batchSize) {
const batch = keyIds.slice(i, i + batchSize)
const pipeline = client.pipeline()
for (const keyId of batch) {
const weeklyKey = this._buildWeeklyClaudeKey(keyId, weekString)
const cost = costByKeyId.get(keyId) || 0
pipeline.set(weeklyKey, String(cost))
pipeline.expire(weeklyKey, ttlSeconds)
}
await pipeline.exec()
}
// 写入 done 标记(保留略长于 1 周,避免同一周内重启重复回填)。
await client.set(doneKey, new Date().toISOString(), 'EX', 10 * 24 * 3600)
const durationMs = Date.now() - startedAt
logger.info(
`✅ 本周 Claude 周费用回填完成(${weekString}keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${costByKeyId.size}${durationMs}ms`
)
return {
success: true,
weekString,
keyCount: keyIds.length,
scannedKeys,
matchedClaudeKeys,
filledKeys: costByKeyId.size,
durationMs
}
} catch (error) {
logger.error(`❌ 本周 Claude 周费用回填失败(${weekString}`, error)
return { success: false, error: error.message }
} finally {
await redis.releaseAccountLock(lockKey, lockValue)
}
}
}
module.exports = new WeeklyClaudeCostInitService()

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

@@ -79,6 +79,11 @@ const PROMPT_DEFINITIONS = {
title: 'Claude Code Compact System Prompt Agent SDK2',
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
},
claudeOtherSystemPrompt5: {
category: 'system',
title: 'Claude CLI Billing Header',
text: 'x-anthropic-billing-header: cc_version=2.1.15.c5a; cc_entrypoint=cli'
},
claudeOtherSystemPromptCompact: {
category: 'system',
title: 'Claude Code Compact System Prompt',

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

@@ -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
}
}
@@ -179,10 +188,54 @@ function isOpus45OrNewer(modelName) {
return false
}
/**
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
*
* 用于 API Key 维度的限额/统计Claude 周费用)。这里刻意覆盖以下命名:
* - 标准 Anthropic 模型claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等
* - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-...
* - 少数情况下 model 字段可能只包含家族关键词sonnet/haiku/opus也视为 Claude 系列
*
* 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。
*/
function isClaudeFamilyModel(modelName) {
if (!modelName || typeof modelName !== 'string') {
return false
}
const { baseModel } = parseVendorPrefixedModel(modelName)
const m = (baseModel || '').trim().toLowerCase()
if (!m) {
return false
}
// Bedrock 模型格式
if (
m.includes('.anthropic.claude-') ||
m.startsWith('anthropic.claude-') ||
m.includes('.claude-')
) {
return true
}
// 标准 Anthropic 模型 ID
if (m.startsWith('claude-') || m.includes('claude-')) {
return true
}
// 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) {
return true
}
return false
}
module.exports = {
parseVendorPrefixedModel,
hasVendorPrefix,
getEffectiveModel,
getVendorType,
isOpus45OrNewer
isOpus45OrNewer,
isClaudeFamilyModel
}

View File

@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference',
REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
}
@@ -35,6 +35,7 @@ function generateState() {
/**
* 生成随机的 code verifierPKCE
* 符合 RFC 7636 标准32字节随机数 → base64url编码 → 43字符
* @returns {string} base64url 编码的随机字符串
*/
function generateCodeVerifier() {

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
}

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

@@ -59,15 +59,20 @@ class ClaudeCodeValidator {
typeof customThreshold === 'number' && Number.isFinite(customThreshold)
? customThreshold
: SYSTEM_PROMPT_THRESHOLD
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}`
)
logger.warn(`Claude system prompt detail: ${rawText}`)
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,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
@@ -1632,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">
@@ -1783,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
@@ -1820,7 +2033,9 @@
文件中的凭证, 请勿使用 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 的机器上获取
@@ -2550,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
>
@@ -2615,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">
@@ -3801,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'
@@ -3936,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'
})(),
@@ -3949,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 || '',
@@ -3990,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 特定字段
@@ -4156,6 +4415,7 @@ const errors = ref({
accessKeyId: '',
secretAccessKey: '',
region: '',
bearerToken: '',
azureEndpoint: '',
deploymentName: ''
})
@@ -4275,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'
}
@@ -4312,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(
@@ -4535,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',
@@ -4675,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',
@@ -4682,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
}
@@ -4856,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 区域'
@@ -4998,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',
@@ -5089,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
@@ -5104,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'
@@ -5113,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
@@ -5155,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)
@@ -5385,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',
@@ -5420,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'
@@ -5442,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
}
@@ -5988,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>

View File

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

View File

@@ -232,10 +232,10 @@
/>
</div>
<!-- Opus 模型周费用限制 -->
<!-- Claude 模型周费用限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
Opus 模型周费用限制 (美元)
Claude 模型周费用限制 (美元)
</label>
<input
v-model="form.weeklyOpusCostLimit"
@@ -246,7 +246,7 @@
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日 Claude 官方账户
设置 Claude 模型的周费用限制周一到周日 Claude 模型请求生效
</p>
</div>
@@ -510,7 +510,7 @@ const form = reactive({
concurrencyLimit: '',
dailyCostLimit: '',
totalCostLimit: '',
weeklyOpusCostLimit: '', // 新增Opus周费用限制
weeklyOpusCostLimit: '', // 新增Claude周费用限制
permissions: '', // 空字符串表示不修改
claudeAccountId: '',
geminiAccountId: '',

View File

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

View File

@@ -324,7 +324,7 @@
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>Claude 模型周费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
@@ -366,7 +366,8 @@
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日 Claude 官方账户0 或留空表示无限制
设置 Claude 模型的周费用限制周一到周日 Claude 模型请求生效0
或留空表示无限制
</p>
</div>
</div>
@@ -412,55 +413,46 @@
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="claude"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="gemini"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="openai"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="droid"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Droid</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务
不选择任何服务表示允许访问全部服务
</p>
</div>
@@ -495,7 +487,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -509,7 +501,7 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
@@ -523,7 +515,7 @@
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
@@ -537,7 +529,7 @@
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
@@ -551,7 +543,7 @@
v-model="form.droidAccountId"
:accounts="localAccounts.droid"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
@@ -800,7 +792,7 @@ const form = reactive({
dailyCostLimit: '',
totalCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
@@ -1241,7 +1233,38 @@ onMounted(async () => {
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.totalCostLimit = props.apiKey.totalCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
// 有效的权限值
const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid']
let perms = props.apiKey.permissions
// 如果是字符串,尝试 JSON.parseRedis 可能返回 "[]" 或 "[\"gemini\"]"
if (typeof perms === 'string') {
if (perms === 'all' || perms === '') {
perms = []
} else if (perms.startsWith('[')) {
try {
perms = JSON.parse(perms)
} catch {
perms = VALID_PERMS.includes(perms) ? [perms] : []
}
} else if (perms.includes(',')) {
// 兼容逗号分隔格式(如 "claude,openai"
perms = perms
.split(',')
.map((p) => p.trim())
.filter((p) => VALID_PERMS.includes(p))
} else if (VALID_PERMS.includes(perms)) {
perms = [perms]
} else {
perms = []
}
}
if (Array.isArray(perms)) {
// 过滤掉无效值(如 "[]"
form.permissions = perms.filter((p) => VALID_PERMS.includes(p))
} else {
form.permissions = []
}
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`

View File

@@ -167,11 +167,11 @@
</div>
</div>
<!-- Opus 模型周费用限制 -->
<!-- Claude 模型周费用限制 -->
<div v-if="statsData.limits.weeklyOpusCostLimit > 0">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
>Opus 模型周费用限制</span
>Claude 模型周费用限制</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
${{ statsData.limits.weeklyOpusCost.toFixed(4) }} / ${{
@@ -284,7 +284,7 @@
</div>
<!-- 详细限制信息 -->
<div v-if="hasModelRestrictions" class="card p-4 md:p-6">
<div v-if="hasModelRestrictions" class="card !overflow-visible p-4 md:p-6">
<h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
@@ -301,7 +301,7 @@
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
受限模型列表
</h4>
<div class="space-y-1 md:space-y-2">
<div class="max-h-64 space-y-1 overflow-y-auto pr-1 md:max-h-80 md:space-y-2">
<div
v-for="model in statsData.restrictions.restrictedModels"
:key="model"
@@ -383,7 +383,7 @@ const getTotalCostProgressColor = () => {
return 'bg-blue-500'
}
// 获取Opus周费用进度
// 获取Claude周费用进度
const getOpusWeeklyCostProgress = () => {
if (
!statsData.value.limits.weeklyOpusCostLimit ||
@@ -395,7 +395,7 @@ const getOpusWeeklyCostProgress = () => {
return Math.min(percentage, 100)
}
// 获取Opus周费用进度条颜色
// 获取Claude周费用进度条颜色
const getOpusWeeklyCostProgressColor = () => {
const progress = getOpusWeeklyCostProgress()
if (progress >= 100) return 'bg-red-500'

View File

@@ -141,6 +141,28 @@
</el-tooltip>
</div>
<!-- 刷新余额按钮 -->
<div class="relative">
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
@click="refreshVisibleBalances"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i
:class="[
'fas relative text-blue-500',
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
]"
/>
<span class="relative">刷新余额</span>
</button>
</el-tooltip>
</div>
<!-- 选择/取消选择按钮 -->
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@@ -263,6 +285,11 @@
>
今日使用
</th>
<th
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
余额/配额
</th>
<th
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
@@ -765,6 +792,31 @@
</div>
<div v-else class="text-xs text-gray-400">暂无数据</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
:query-mode="
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
? 'auto'
: 'local'
"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
v-if="
!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')
"
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>
配置余额脚本
</button>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div v-if="account.platform === 'claude'" class="space-y-2">
<!-- OAuth 账户:显示三窗口 OAuth usage -->
@@ -1238,6 +1290,15 @@
<i class="fas fa-vial" />
<span class="ml-1">测试</span>
</button>
<button
v-if="canTestAccount(account)"
class="rounded bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
title="定时测试配置"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
<span class="ml-1">定时</span>
</button>
<button
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
title="编辑账户"
@@ -1416,6 +1477,32 @@
</div>
</div>
<!-- 余额/配额 -->
<div class="mb-3">
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
:query-mode="
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
? 'auto'
: 'local'
"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
v-if="!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')"
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>
配置余额脚本
</button>
</div>
</div>
<!-- 状态信息 -->
<div class="mb-3 space-y-2">
<!-- 会话窗口 -->
@@ -1707,6 +1794,15 @@
测试
</button>
<button
v-if="canTestAccount(account)"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-600 transition-colors hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
定时
</button>
<button
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
@click="editAccount(account)"
@@ -1880,6 +1976,21 @@
@close="closeAccountTestModal"
/>
<!-- 定时测试配置弹窗 -->
<AccountScheduledTestModal
:account="scheduledTestAccount"
:show="showScheduledTestModal"
@close="closeScheduledTestModal"
@saved="handleScheduledTestSaved"
/>
<AccountBalanceScriptModal
:account="selectedAccountForScript"
:show="showBalanceScriptModal"
@close="closeBalanceScriptModal"
@saved="handleBalanceScriptSaved"
/>
<!-- 账户统计弹窗 -->
<el-dialog
v-model="showAccountStatsModal"
@@ -2032,9 +2143,12 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue'
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
// 使用确认弹窗
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
@@ -2042,6 +2156,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
// 数据状态
const accounts = ref([])
const accountsLoading = ref(false)
const refreshingBalances = ref(false)
const accountsSortBy = ref('name')
const accountsSortOrder = ref('asc')
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
@@ -2088,7 +2203,8 @@ const supportedUsagePlatforms = [
'openai-responses',
'gemini',
'droid',
'gemini-api'
'gemini-api',
'bedrock'
]
// 过期时间编辑弹窗状态
@@ -2099,6 +2215,10 @@ const expiryEditModalRef = ref(null)
const showAccountTestModal = ref(false)
const testingAccount = ref(null)
// 定时测试配置弹窗状态
const showScheduledTestModal = ref(false)
const scheduledTestAccount = ref(null)
// 账户统计弹窗状态
const showAccountStatsModal = ref(false)
@@ -2365,6 +2485,13 @@ const getAccountActions = (account) => {
color: 'blue',
handler: () => openAccountTestModal(account)
})
actions.push({
key: 'scheduled-test',
label: '定时测试',
icon: 'fa-clock',
color: 'amber',
handler: () => openScheduledTestModal(account)
})
}
// 删除
@@ -2421,7 +2548,7 @@ const closeAccountUsageModal = () => {
}
// 测试账户连通性相关函数
const supportedTestPlatforms = ['claude', 'claude-console']
const supportedTestPlatforms = ['claude', 'claude-console', 'bedrock']
const canTestAccount = (account) => {
return !!account && supportedTestPlatforms.includes(account.platform)
@@ -2441,6 +2568,61 @@ const closeAccountTestModal = () => {
testingAccount.value = null
}
// 定时测试配置相关函数
const openScheduledTestModal = (account) => {
if (!canTestAccount(account)) {
showToast('该账户类型暂不支持定时测试', 'warning')
return
}
scheduledTestAccount.value = account
showScheduledTestModal.value = true
}
const closeScheduledTestModal = () => {
showScheduledTestModal.value = false
scheduledTestAccount.value = null
}
const handleScheduledTestSaved = () => {
showToast('定时测试配置已保存', 'success')
}
// 余额脚本配置
const showBalanceScriptModal = ref(false)
const selectedAccountForScript = ref(null)
const openBalanceScriptModal = (account) => {
selectedAccountForScript.value = account
showBalanceScriptModal.value = true
}
const closeBalanceScriptModal = () => {
showBalanceScriptModal.value = false
selectedAccountForScript.value = null
}
const handleBalanceScriptSaved = async () => {
showToast('余额脚本已保存', 'success')
const account = selectedAccountForScript.value
closeBalanceScriptModal()
if (!account?.id || !account?.platform) {
return
}
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用“刷新余额”按钮)
try {
const res = await apiClient.get(`/admin/accounts/${account.id}/balance`, {
params: { platform: account.platform, queryApi: false }
})
if (res?.success && res.data) {
handleBalanceRefreshed(account.id, res.data)
}
} catch (error) {
console.debug('Failed to reload balance after saving script:', error)
}
}
// 计算排序后的账户列表
const sortedAccounts = computed(() => {
let sourceAccounts = accounts.value
@@ -2711,6 +2893,104 @@ const paginatedAccounts = computed(() => {
return sortedAccounts.value.slice(start, end)
})
const canRefreshVisibleBalances = computed(() => {
const targets = paginatedAccounts.value
if (!Array.isArray(targets) || targets.length === 0) {
return false
}
return targets.some((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
})
const refreshBalanceTooltip = computed(() => {
if (accountsLoading.value) return '正在加载账户...'
if (refreshingBalances.value) return '刷新中...'
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
})
// 余额刷新成功回调
const handleBalanceRefreshed = (accountId, balanceInfo) => {
accounts.value = accounts.value.map((account) => {
if (account.id !== accountId) return account
return { ...account, balanceInfo }
})
}
// 余额请求错误回调(仅提示,不中断页面)
const handleBalanceError = (_accountId, error) => {
const message = error?.message || '余额查询失败'
showToast(message, 'error')
}
// 批量刷新当前页余额(触发查询)
const refreshVisibleBalances = async () => {
if (refreshingBalances.value) return
const targets = paginatedAccounts.value
if (!targets || targets.length === 0) {
return
}
const eligibleTargets = targets.filter((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
if (eligibleTargets.length === 0) {
showToast('当前页没有配置余额脚本的账户', 'warning')
return
}
const skippedCount = targets.length - eligibleTargets.length
refreshingBalances.value = true
try {
const results = await Promise.all(
eligibleTargets.map(async (account) => {
try {
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
platform: account.platform
})
return { id: account.id, success: !!response?.success, data: response?.data || null }
} catch (error) {
return { id: account.id, success: false, error: error?.message || '刷新失败' }
}
})
)
const updatedMap = results.reduce((map, item) => {
if (item.success && item.data) {
map[item.id] = item.data
}
return map
}, {})
const successCount = results.filter((r) => r.success).length
const failCount = results.length - successCount
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
if (Object.keys(updatedMap).length > 0) {
accounts.value = accounts.value.map((account) => {
const balanceInfo = updatedMap[account.id]
if (!balanceInfo) return account
return { ...account, balanceInfo }
})
}
if (failCount === 0) {
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
} else {
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
}
} finally {
refreshingBalances.value = false
}
}
const updateSelectAllState = () => {
const currentIds = paginatedAccounts.value.map((account) => account.id)
const selectedInCurrentPage = currentIds.filter((id) =>
@@ -2761,6 +3041,54 @@ const cleanupSelectedAccounts = () => {
updateSelectAllState()
}
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
const loadBalanceCacheForAccounts = async () => {
const current = accounts.value
if (!Array.isArray(current) || current.length === 0) {
return
}
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
if (platforms.length === 0) {
return
}
const responses = await Promise.all(
platforms.map(async (platform) => {
try {
const res = await apiClient.get(`/admin/accounts/balance/platform/${platform}`, {
params: { queryApi: false }
})
return { platform, success: !!res?.success, data: res?.data || [] }
} catch (error) {
console.debug(`Failed to load balance cache for ${platform}:`, error)
return { platform, success: false, data: [] }
}
})
)
const balanceMap = responses.reduce((map, item) => {
if (!item.success) return map
const list = Array.isArray(item.data) ? item.data : []
list.forEach((entry) => {
const accountId = entry?.data?.accountId
if (accountId) {
map[accountId] = entry.data
}
})
return map
}, {})
if (Object.keys(balanceMap).length === 0) {
return
}
accounts.value = accounts.value.map((account) => ({
...account,
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
}))
}
// 加载账户列表
const loadAccounts = async (forceReload = false) => {
accountsLoading.value = true
@@ -2953,6 +3281,11 @@ const loadAccounts = async (forceReload = false) => {
console.debug('Claude usage loading failed:', err)
})
}
// 异步加载余额缓存(按平台批量)
loadBalanceCacheForAccounts().catch((err) => {
console.debug('Balance cache loading failed:', err)
})
} catch (error) {
showToast('加载账户失败', 'error')
} finally {

View File

@@ -0,0 +1,312 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-4 lg:flex-row">
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
<div class="mb-3 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
使用自定义脚本 + 模板变量适配任意余额接口
</div>
</div>
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="loadConfig"
>
重新加载
</button>
<button
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
:disabled="saving"
@click="saveConfig"
>
<span v-if="saving">保存中...</span>
<span v-else>保存配置</span>
</button>
</div>
</div>
<div class="grid gap-4 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="sk-xxxx" type="text" />
</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"
type="text"
/>
</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" type="text" />
</div>
<div class="grid grid-cols-2 gap-3">
<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"
/>
</div>
</div>
<div class="md:col-span-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
可用变量{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}apiKey{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}accountId{{
'}'
}}{{ '}' }}{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}extra{{
'}'
}}{{ '}' }}
</p>
</div>
</div>
</div>
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
<div class="mb-3 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
填入账号上下文可选调试 extractor 输出
</div>
</div>
<button
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
:disabled="testing"
@click="testScript"
>
<span v-if="testing">测试中...</span>
<span v-else>测试脚本</span>
</button>
</div>
<div class="grid gap-3">
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>额外参数 (extra)</label
>
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
</div>
</div>
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
<div class="flex items-center justify-between text-sm">
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</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="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 v-if="testResult.mapped?.quota">
配额: {{ JSON.stringify(testResult.mapped.quota) }}
</div>
</div>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看 extractor 输出</summary>
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
>{{ formatJson(testResult.extracted) }}
</pre
>
</details>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看原始响应</summary>
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
>{{ formatJson(testResult.response) }}
</pre
>
</details>
</div>
</div>
</div>
<div class="glass-strong rounded-2xl p-4 shadow-lg">
<div class="mb-2 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
返回对象需包含 requestextractor支持模板变量替换
</div>
</div>
<button
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="applyPreset"
>
使用示例模板
</button>
</div>
<textarea
v-model="form.scriptBody"
class="min-h-[320px] 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-2 text-xs text-gray-500 dark:text-gray-400">
extractor
返回字段可选isValidinvalidMessageremainingunitplanNametotalusedextra
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
const form = reactive({
baseUrl: '',
apiKey: '',
token: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
scriptBody: ''
})
const testForm = reactive({
platform: '',
accountId: '',
extra: ''
})
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const presetScript = `({
request: {
url: "{{baseUrl}}/user/balance",
method: "GET",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD",
planName: response.plan || "默认套餐"
};
}
})`
const loadConfig = async () => {
try {
const res = await apiClient.get('/admin/balance-scripts/default')
if (res?.success && res.data) {
Object.assign(form, res.data)
}
} catch (error) {
showToast('加载配置失败', 'error')
}
}
const saveConfig = async () => {
saving.value = true
try {
const payload = { ...form }
await apiClient.put('/admin/balance-scripts/default', payload)
showToast('配置已保存', 'success')
} catch (error) {
showToast(error.message || '保存失败', 'error')
} finally {
saving.value = false
}
}
const testScript = async () => {
testing.value = true
testResult.value = null
try {
const payload = {
...form,
...testForm,
scriptBody: form.scriptBody
}
const res = await apiClient.post('/admin/balance-scripts/default/test', payload)
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)
}
}
onMounted(() => {
applyPreset()
loadConfig()
})
</script>
<style scoped>
.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

@@ -196,6 +196,105 @@
</div>
</div>
<!-- 账户余额/配额汇总 -->
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
账户余额/配额
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
<i class="fas fa-wallet" />
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
</p>
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
:disabled="loadingBalanceSummary"
@click="loadBalanceSummary"
>
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
刷新
</button>
</div>
</div>
<div class="card p-4 sm:p-6">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ lowBalanceAccounts.length }} 个
</span>
</div>
<div
v-if="loadingBalanceSummary"
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
正在加载...
</div>
<div
v-else-if="lowBalanceAccounts.length === 0"
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
>
全部正常
</div>
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
<div
v-for="account in lowBalanceAccounts"
:key="account.accountId"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
>
<div class="flex items-center justify-between gap-2">
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ account.name || account.accountId }}
</div>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{{ getBalancePlatformLabel(account.platform) }}
</span>
</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
<span v-else
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
>
</div>
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
<div
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
>
<span>配额使用</span>
<span class="text-red-600 dark:text-red-400">
{{ account.quota.percentage.toFixed(1) }}%
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-red-500"
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Token统计和性能指标 -->
<div
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
@@ -681,6 +780,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore()
@@ -732,6 +833,97 @@ const accountGroupOptions = [
const accountTrendUpdating = ref(false)
// 余额/配额汇总
const balanceSummary = ref({
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
})
const loadingBalanceSummary = ref(false)
const balanceSummaryUpdatedAt = ref(null)
const getBalancePlatformLabel = (platform) => {
const map = {
claude: 'Claude',
'claude-console': 'Claude Console',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
openai: 'OpenAI',
'openai-responses': 'OpenAI Responses',
azure_openai: 'Azure OpenAI',
bedrock: 'Bedrock',
droid: 'Droid',
ccr: 'CCR'
}
return map[platform] || platform
}
const lowBalanceAccounts = computed(() => {
const result = []
const platforms = balanceSummary.value?.platforms || {}
Object.entries(platforms).forEach(([platform, data]) => {
const list = Array.isArray(data?.accounts) ? data.accounts : []
list.forEach((entry) => {
const accountData = entry?.data
if (!accountData) return
const amount = accountData.balance?.amount
const percentage = accountData.quota?.percentage
const isLowBalance = typeof amount === 'number' && amount < 10
const isHighUsage = typeof percentage === 'number' && percentage > 90
if (isLowBalance || isHighUsage) {
result.push({
...accountData,
name: entry?.name || accountData.accountId,
platform: accountData.platform || platform
})
}
})
})
return result
})
const formatCurrencyUsd = (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 formatLastUpdate = (isoString) => {
if (!isoString) return '未知'
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const loadBalanceSummary = async () => {
loadingBalanceSummary.value = true
try {
const response = await apiClient.get('/admin/accounts/balance/summary')
if (response?.success) {
balanceSummary.value = response.data || {
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
}
balanceSummaryUpdatedAt.value = new Date().toISOString()
}
} catch (error) {
console.debug('加载余额汇总失败:', error)
showToast('加载余额汇总失败', 'error')
} finally {
loadingBalanceSummary.value = false
}
}
// 自动刷新相关
const autoRefreshEnabled = ref(false)
const autoRefreshInterval = ref(30) // 秒
@@ -1488,7 +1680,7 @@ async function refreshAllData() {
isRefreshing.value = true
try {
await Promise.all([loadDashboardData(), refreshChartsData()])
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
} finally {
isRefreshing.value = false
}