Compare commits

..

27 Commits

Author SHA1 Message Date
github-actions[bot]
792ba51290 chore: sync VERSION file with release v1.1.240 [skip ci] 2025-12-25 02:46:09 +00:00
Wesley Liddick
74d138a2fb Merge pull request #842 from IanShaw027/feat/account-export-api
feat(admin): 添加账户导出同步 API
2025-12-24 21:45:55 -05:00
IanShaw027
b88698191e style(admin): fix ESLint curly rule violations in sync.js
为单行 if 语句添加花括号以符合 ESLint curly 规则要求
2025-12-24 17:57:30 -08:00
IanShaw027
11c38b23d1 style(admin): format sync.js with prettier
修复 CI 格式化检查失败问题
2025-12-24 17:52:51 -08:00
IanShaw027
b2dfc2eb25 feat(admin): 添加账户导出同步 API
- 新增 /api/accounts 端点,支持导出所有账户数据
- 新增 /api/proxies 端点,支持导出所有代理配置
- 支持 Sub2API 从 CRS 批量同步账户
- 包含完整的 credentials 和 extra 字段
- 提供账户类型标识 (oauth/setup_token/api_key)

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

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

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

- 遗留键会被自动识别并删除,同时记录日志
2025-12-24 17:51:19 +08:00
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
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
Wesley Liddick
ba93ae55a9 Merge pull request #811 from sususu98/feat/event-logging-endpoint
feat: 添加 Claude Code 遥测端点并优化日志级别
2025-12-16 19:34:44 -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
45 changed files with 2953 additions and 4599 deletions

View File

@@ -33,41 +33,6 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
CLAUDE_API_VERSION=2023-06-01 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 CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
# 🤖 Gemini OAuth / Antigravity 配置(可选)
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret可在此覆盖
# GEMINI_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_SECRET=
# Gemini CLI OAuth redirect_uri可选默认 https://codeassist.google.com/authcode
# GEMINI_OAUTH_REDIRECT_URI=
# ANTIGRAVITY_OAUTH_CLIENT_ID=
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
# Antigravity OAuth redirect_uri可选默认 http://localhost:45462用于避免 redirect_uri_mismatch
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
# Antigravity 上游地址(可选,默认 sandbox
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
# Antigravity User-Agent可选
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
# Claude CodeAnthropic Messages API路由分流无需额外环境变量
# - /api -> Claude 账号池(默认)
# - /antigravity/api -> Antigravity OAuth
# - /gemini-cli/api -> Gemini CLI OAuth
# 可选Claude Code 调试 Dump会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题
# - anthropic-requests-dump.jsonl
# - anthropic-responses-dump.jsonl
# - anthropic-tools-dump.jsonl
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
#
# 可选Antigravity 上游请求 Dump会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload含 tools/schema 清洗后的结果)
# - antigravity-upstream-requests-dump.jsonl
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
# 🚫 529错误处理配置 # 🚫 529错误处理配置
# 启用529错误处理0表示禁用>0表示过载状态持续时间分钟 # 启用529错误处理0表示禁用>0表示过载状态持续时间分钟
CLAUDE_OVERLOAD_HANDLING_MINUTES=0 CLAUDE_OVERLOAD_HANDLING_MINUTES=0

View File

@@ -389,31 +389,13 @@ docker-compose.yml 已包含:
**Claude Code 设置环境变量:** **Claude Code 设置环境变量:**
默认使用标准 Claude 账号池Claude/Console/Bedrock/CCR 默认使用标准 Claude 账号池:
```bash ```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
``` ```
如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
Antigravity OAuth支持 `claude-opus-4-5` 等 Antigravity 模型):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth使用 Gemini 模型):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥permissions 需要是 all 或 gemini"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude 插件配置:** **VSCode Claude 插件配置:**
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置: 如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
@@ -426,6 +408,8 @@ export ANTHROPIC_MODEL="gemini-2.5-pro"
如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。 如果该文件不存在请手动创建。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 CLI 设置环境变量:**
**方式一(推荐):通过 Gemini Assist API 方式访问** **方式一(推荐):通过 Gemini Assist API 方式访问**

View File

@@ -238,31 +238,13 @@ Now you can replace the official API with your own service:
**Claude Code Set Environment Variables:** **Claude Code Set Environment Variables:**
Default uses standard Claude account pool (Claude/Console/Bedrock/CCR): Default uses standard Claude account pool:
```bash ```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
export ANTHROPIC_AUTH_TOKEN="API key created in the backend" export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
``` ```
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="claude-opus-4-5"
```
Gemini CLI OAuth (Gemini models):
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
export ANTHROPIC_MODEL="gemini-2.5-pro"
```
**VSCode Claude Plugin Configuration:** **VSCode Claude Plugin Configuration:**
If using VSCode Claude plugin, configure in `~/.claude/config.json`: If using VSCode Claude plugin, configure in `~/.claude/config.json`:
@@ -622,4 +604,4 @@ This project uses the [MIT License](LICENSE).
**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions** **🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
</div> </div>

View File

@@ -1 +1 @@
1.1.248 1.1.240

18
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"ldapjs": "^3.0.7", "ldapjs": "^3.0.7",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6", "nodemailer": "^7.0.6",
"ora": "^5.4.1", "ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5", "rate-limiter-flexible": "^5.0.5",
@@ -891,6 +892,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -2999,6 +3001,7 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3080,6 +3083,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3535,6 +3539,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001737", "caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211", "electron-to-chromium": "^1.5.211",
@@ -4422,6 +4427,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -4478,6 +4484,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -7028,6 +7035,15 @@
"node": ">= 0.6" "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": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -7576,6 +7592,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -9094,6 +9111,7 @@
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@colors/colors": "^1.6.0", "@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2", "@dabh/diagnostics": "^2.0.2",

View File

@@ -65,6 +65,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"ldapjs": "^3.0.7", "ldapjs": "^3.0.7",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6", "nodemailer": "^7.0.6",
"ora": "^5.4.1", "ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5", "rate-limiter-flexible": "^5.0.5",

71
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
morgan: morgan:
specifier: ^1.10.0 specifier: ^1.10.0
version: 1.10.1 version: 1.10.1
node-cron:
specifier: ^4.2.1
version: 4.2.1
nodemailer: nodemailer:
specifier: ^7.0.6 specifier: ^7.0.6
version: 7.0.11 version: 7.0.11
@@ -108,6 +111,9 @@ importers:
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.7.4 version: 3.7.4
prettier-plugin-tailwindcss:
specifier: ^0.7.2
version: 0.7.2(prettier@3.7.4)
supertest: supertest:
specifier: ^6.3.3 specifier: ^6.3.3
version: 6.3.4 version: 6.3.4
@@ -2144,6 +2150,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'} 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: node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'} engines: {node: '>=10.5.0'}
@@ -2302,6 +2312,61 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'} 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: prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -5692,6 +5757,8 @@ snapshots:
negotiator@0.6.4: {} negotiator@0.6.4: {}
node-cron@4.2.1: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
node-fetch@3.3.2: node-fetch@3.3.2:
@@ -5840,6 +5907,10 @@ snapshots:
dependencies: dependencies:
fast-diff: 1.3.0 fast-diff: 1.3.0
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
dependencies:
prettier: 3.7.4
prettier@3.7.4: {} prettier@3.7.4: {}
pretty-format@29.7.0: pretty-format@29.7.0:

View File

@@ -264,25 +264,6 @@ class Application {
this.app.use('/api', apiRoutes) this.app.use('/api', apiRoutes)
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等) this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 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('/admin', adminRoutes)
this.app.use('/users', userRoutes) this.app.use('/users', userRoutes)
// 使用 web 路由(包含 auth 和页面重定向) // 使用 web 路由(包含 auth 和页面重定向)
@@ -600,10 +581,11 @@ class Application {
const now = Date.now() const now = Date.now()
let totalCleaned = 0 let totalCleaned = 0
let legacyCleaned = 0
// 使用 Lua 脚本批量清理所有过期项 // 使用 Lua 脚本批量清理所有过期项
for (const key of keys) { for (const key of keys) {
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑) // 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型 // - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型 // - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型 // - concurrency:queue:* (不含stats/wait_times) 是 String 类型
@@ -618,11 +600,21 @@ class Application {
} }
try { try {
const cleaned = await redis.client.eval( // 使用原子 Lua 脚本:先检查类型,再执行清理
// 返回值0 = 正常清理无删除1 = 清理后删除空键,-1 = 遗留键已删除
const result = await redis.client.eval(
` `
local key = KEYS[1] local key = KEYS[1]
local now = tonumber(ARGV[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) redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
@@ -641,8 +633,10 @@ class Application {
key, key,
now now
) )
if (cleaned === 1) { if (result === 1) {
totalCleaned++ totalCleaned++
} else if (result === -1) {
legacyCleaned++
} }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to clean concurrency key ${key}:`, error) logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
@@ -652,6 +646,9 @@ class Application {
if (totalCleaned > 0) { if (totalCleaned > 0) {
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`) logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
} }
if (legacyCleaned > 0) {
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
}
} catch (error) { } catch (error) {
logger.error('❌ Concurrency cleanup task failed:', error) logger.error('❌ Concurrency cleanup task failed:', error)
} }
@@ -680,6 +677,19 @@ class Application {
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)' '🚦 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() { setupGracefulShutdown() {
@@ -734,6 +744,15 @@ class Application {
logger.error('❌ Error stopping cost rank service:', error) 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 修复:防止重启泄漏) // 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try { try {
logger.info('🔢 Cleaning up all concurrency counters...') logger.info('🔢 Cleaning up all concurrency counters...')

View File

@@ -9,7 +9,6 @@ const logger = require('../utils/logger')
const geminiAccountService = require('../services/geminiAccountService') const geminiAccountService = require('../services/geminiAccountService')
const geminiApiAccountService = require('../services/geminiApiAccountService') const geminiApiAccountService = require('../services/geminiApiAccountService')
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService') const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
const crypto = require('crypto') const crypto = require('crypto')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
@@ -509,37 +508,20 @@ async function handleMessages(req, res) {
// OAuth 账户:使用现有的 sendGeminiRequest // OAuth 账户:使用现有的 sendGeminiRequest
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId // 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
const effectiveProjectId = account.projectId || account.tempProjectId || null const effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
if (oauthProvider === 'antigravity') { geminiResponse = await sendGeminiRequest({
geminiResponse = await sendAntigravityRequest({ messages,
messages, model,
model, temperature,
temperature, maxTokens: max_tokens,
maxTokens: max_tokens, stream,
stream, accessToken: account.accessToken,
accessToken: account.accessToken, proxy: account.proxy,
proxy: account.proxy, apiKeyId: apiKeyData.id,
apiKeyId: apiKeyData.id, signal: abortController.signal,
signal: abortController.signal, projectId: effectiveProjectId,
projectId: effectiveProjectId, accountId: account.id
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) { if (stream) {
@@ -772,16 +754,8 @@ async function handleModels(req, res) {
] ]
} }
} else { } else {
// OAuth 账户:根据 OAuth provider 选择上游 // OAuth 账户:使用 OAuth token 获取模型列表
const oauthProvider = account.oauthProvider || 'gemini-cli' models = await getAvailableModels(account.accessToken, account.proxy)
models =
oauthProvider === 'antigravity'
? await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
account.proxy,
account.refreshToken
)
: await getAvailableModels(account.accessToken, account.proxy)
} }
res.json({ res.json({
@@ -953,8 +927,7 @@ function handleSimpleEndpoint(apiMethod) {
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(
accessToken, accessToken,
refreshToken, refreshToken,
proxyConfig, proxyConfig
account.oauthProvider
) )
// 直接转发请求体,不做特殊处理 // 直接转发请求体,不做特殊处理
@@ -1033,12 +1006,7 @@ async function handleLoadCodeAssist(req, res) {
// 解析账户的代理配置 // 解析账户的代理配置
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID // 智能处理项目ID
const effectiveProjectId = projectId || cloudaicompanionProject || null const effectiveProjectId = projectId || cloudaicompanionProject || null
@@ -1136,12 +1104,7 @@ async function handleOnboardUser(req, res) {
// 解析账户的代理配置 // 解析账户的代理配置
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID // 智能处理项目ID
const effectiveProjectId = projectId || cloudaicompanionProject || null const effectiveProjectId = projectId || cloudaicompanionProject || null
@@ -1293,8 +1256,7 @@ async function handleCountTokens(req, res) {
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(
accessToken, accessToken,
refreshToken, refreshToken,
proxyConfig, proxyConfig
account.oauthProvider
) )
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
} }
@@ -1404,20 +1366,13 @@ async function handleGenerateContent(req, res) {
// 解析账户的代理配置 // 解析账户的代理配置
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId // 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
let effectiveProjectId = account.projectId || account.tempProjectId || null let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
// 如果没有任何项目ID尝试调用 loadCodeAssist 获取 // 如果没有任何项目ID尝试调用 loadCodeAssist 获取
if (!effectiveProjectId && oauthProvider !== 'antigravity') { if (!effectiveProjectId) {
try { try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -1433,12 +1388,6 @@ 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返回错误 // 如果还是没有项目ID返回错误
if (!effectiveProjectId) { if (!effectiveProjectId) {
return res.status(403).json({ return res.status(403).json({
@@ -1461,24 +1410,14 @@ async function handleGenerateContent(req, res) {
: '从loadCodeAssist获取' : '从loadCodeAssist获取'
}) })
const response = const response = await geminiAccountService.generateContent(
oauthProvider === 'antigravity' client,
? await geminiAccountService.generateContentAntigravity( { model, request: actualRequestData },
client, user_prompt_id,
{ model, request: actualRequestData }, effectiveProjectId,
user_prompt_id, req.apiKey?.id,
effectiveProjectId, proxyConfig
req.apiKey?.id, )
proxyConfig
)
: await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
// 记录使用统计 // 记录使用统计
if (response?.response?.usageMetadata) { if (response?.response?.usageMetadata) {
@@ -1639,20 +1578,13 @@ async function handleStreamGenerateContent(req, res) {
// 解析账户的代理配置 // 解析账户的代理配置
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
accessToken,
refreshToken,
proxyConfig,
account.oauthProvider
)
// 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId // 智能处理项目ID优先使用配置的 projectId降级到临时 tempProjectId
let effectiveProjectId = account.projectId || account.tempProjectId || null let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli'
// 如果没有任何项目ID尝试调用 loadCodeAssist 获取 // 如果没有任何项目ID尝试调用 loadCodeAssist 获取
if (!effectiveProjectId && oauthProvider !== 'antigravity') { if (!effectiveProjectId) {
try { try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -1668,11 +1600,6 @@ async function handleStreamGenerateContent(req, res) {
} }
} }
if (!effectiveProjectId && oauthProvider === 'antigravity') {
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
}
// 如果还是没有项目ID返回错误 // 如果还是没有项目ID返回错误
if (!effectiveProjectId) { if (!effectiveProjectId) {
return res.status(403).json({ return res.status(403).json({
@@ -1695,26 +1622,15 @@ async function handleStreamGenerateContent(req, res) {
: '从loadCodeAssist获取' : '从loadCodeAssist获取'
}) })
const streamResponse = const streamResponse = await geminiAccountService.generateContentStream(
oauthProvider === 'antigravity' client,
? await geminiAccountService.generateContentStreamAntigravity( { model, request: actualRequestData },
client, user_prompt_id,
{ model, request: actualRequestData }, effectiveProjectId,
user_prompt_id, req.apiKey?.id,
effectiveProjectId, abortController.signal,
req.apiKey?.id, proxyConfig
abortController.signal, )
proxyConfig
)
: await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
user_prompt_id,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
// 设置 SSE 响应头 // 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Content-Type', 'text/event-stream')
@@ -2062,23 +1978,15 @@ async function handleStandardGenerateContent(req, res) {
} else { } else {
// OAuth 账户 // OAuth 账户
const { accessToken, refreshToken } = account const { accessToken, refreshToken } = account
const oauthProvider = account.oauthProvider || 'gemini-cli'
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(
accessToken, accessToken,
refreshToken, refreshToken,
proxyConfig, proxyConfig
oauthProvider
) )
let effectiveProjectId = account.projectId || account.tempProjectId || null let effectiveProjectId = account.projectId || account.tempProjectId || null
if (oauthProvider === 'antigravity') { if (!effectiveProjectId) {
if (!effectiveProjectId) {
// Antigravity 账号允许没有 projectId生成一个稳定的临时 projectId 并缓存
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
}
} else if (!effectiveProjectId) {
try { try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -2116,25 +2024,14 @@ async function handleStandardGenerateContent(req, res) {
const userPromptId = `${crypto.randomUUID()}########0` const userPromptId = `${crypto.randomUUID()}########0`
if (oauthProvider === 'antigravity') { response = await geminiAccountService.generateContent(
response = await geminiAccountService.generateContentAntigravity( client,
client, { model, request: actualRequestData },
{ model, request: actualRequestData }, userPromptId,
userPromptId, effectiveProjectId,
effectiveProjectId, req.apiKey?.id,
req.apiKey?.id, proxyConfig
proxyConfig )
)
} else {
response = await geminiAccountService.generateContent(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
proxyConfig
)
}
} }
// 记录使用统计 // 记录使用统计
@@ -2366,20 +2263,12 @@ async function handleStandardStreamGenerateContent(req, res) {
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(
accessToken, accessToken,
refreshToken, refreshToken,
proxyConfig, proxyConfig
account.oauthProvider
) )
let effectiveProjectId = account.projectId || account.tempProjectId || null let effectiveProjectId = account.projectId || account.tempProjectId || null
const oauthProvider = account.oauthProvider || 'gemini-cli' if (!effectiveProjectId) {
if (oauthProvider === 'antigravity') {
if (!effectiveProjectId) {
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
}
} else if (!effectiveProjectId) {
try { try {
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
@@ -2417,27 +2306,15 @@ async function handleStandardStreamGenerateContent(req, res) {
const userPromptId = `${crypto.randomUUID()}########0` const userPromptId = `${crypto.randomUUID()}########0`
if (oauthProvider === 'antigravity') { streamResponse = await geminiAccountService.generateContentStream(
streamResponse = await geminiAccountService.generateContentStreamAntigravity( client,
client, { model, request: actualRequestData },
{ model, request: actualRequestData }, userPromptId,
userPromptId, effectiveProjectId,
effectiveProjectId, req.apiKey?.id,
req.apiKey?.id, abortController.signal,
abortController.signal, proxyConfig
proxyConfig )
)
} else {
streamResponse = await geminiAccountService.generateContentStream(
client,
{ model, request: actualRequestData },
userPromptId,
effectiveProjectId,
req.apiKey?.id,
abortController.signal,
proxyConfig
)
}
} }
// 设置 SSE 响应头 // 设置 SSE 响应头

View File

@@ -1744,9 +1744,13 @@ const requestLogger = (req, res, next) => {
const referer = req.get('Referer') || 'none' const referer = req.get('Referer') || 'none'
// 记录请求开始 // 记录请求开始
const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') { if (req.originalUrl !== '/health') {
// 避免健康检查日志过多 if (isDebugRoute) {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} else {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
}
} }
res.on('finish', () => { res.on('finish', () => {
@@ -1778,7 +1782,14 @@ const requestLogger = (req, res, next) => {
logMetadata logMetadata
) )
} else if (req.originalUrl !== '/health') { } 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相关日志 // API Key相关日志

View File

@@ -96,7 +96,25 @@ class RedisClient {
logger.warn('⚠️ Redis connection closed') 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 return this.client
} catch (error) { } catch (error) {
logger.error('💥 Failed to connect to Redis:', error) logger.error('💥 Failed to connect to Redis:', error)
@@ -2122,6 +2140,27 @@ class RedisClient {
const results = [] const results = []
for (const key of keys) { 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: 前缀) // 提取 apiKeyId去掉 concurrency: 前缀)
const apiKeyId = key.replace('concurrency:', '') const apiKeyId = key.replace('concurrency:', '')
@@ -2184,6 +2223,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') const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
@@ -2233,20 +2289,36 @@ class RedisClient {
const client = this.getClientSafe() const client = this.getClientSafe()
const key = `concurrency:${apiKeyId}` 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) await client.del(key)
logger.warn( logger.warn(
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries` `🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
) )
return { return {
apiKeyId, apiKeyId,
key, key,
clearedCount: beforeCount, clearedCount: beforeCount,
type: keyType,
legacy: isLegacy,
success: true success: true
} }
} catch (error) { } catch (error) {
@@ -2265,25 +2337,47 @@ class RedisClient {
const keys = await client.keys('concurrency:*') const keys = await client.keys('concurrency:*')
let totalCleared = 0 let totalCleared = 0
let legacyCleared = 0
const clearedKeys = [] const clearedKeys = []
for (const key of keys) { for (const key of keys) {
const count = await client.zcard(key) // 跳过 queue 相关的键(它们有各自的清理逻辑)
await client.del(key) if (key.startsWith('concurrency:queue:')) {
totalCleared += count continue
clearedKeys.push({ }
key,
clearedCount: count // 检查键类型
}) 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( 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 { return {
keysCleared: keys.length, keysCleared: clearedKeys.length,
totalEntriesCleared: totalCleared, totalEntriesCleared: totalCleared,
legacyKeysCleared: legacyCleared,
clearedKeys, clearedKeys,
success: true success: true
} }
@@ -2311,9 +2405,30 @@ class RedisClient {
} }
let totalCleaned = 0 let totalCleaned = 0
let legacyCleaned = 0
const cleanedKeys = [] const cleanedKeys = []
for (const key of keys) { 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) const cleaned = await client.zremrangebyscore(key, '-inf', now)
if (cleaned > 0) { if (cleaned > 0) {
@@ -2332,13 +2447,14 @@ class RedisClient {
} }
logger.info( 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 { return {
keysProcessed: keys.length, keysProcessed: keys.length,
keysCleaned: cleanedKeys.length, keysCleaned: cleanedKeys.length,
totalEntriesCleaned: totalCleaned, totalEntriesCleaned: totalCleaned,
legacyKeysRemoved: legacyCleaned,
cleanedKeys, cleanedKeys,
success: true success: true
} }
@@ -3157,4 +3273,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 module.exports = redisClient

View File

@@ -9,6 +9,7 @@ const router = express.Router()
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/claudeAccountService')
const claudeRelayService = require('../../services/claudeRelayService') const claudeRelayService = require('../../services/claudeRelayService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
@@ -583,7 +584,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedClientId, useUnifiedClientId,
unifiedClientId, unifiedClientId,
expiresAt, expiresAt,
extInfo extInfo,
maxConcurrency,
interceptWarmup
} = req.body } = req.body
if (!name) { if (!name) {
@@ -628,7 +631,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
useUnifiedClientId: useUnifiedClientId === true, // 默认为false useUnifiedClientId: useUnifiedClientId === true, // 默认为false
unifiedClientId: unifiedClientId || '', // 统一的客户端标识 unifiedClientId: unifiedClientId || '', // 统一的客户端标识
expiresAt: expiresAt || null, // 账户订阅到期时间 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 module.exports = router

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ const usageStatsRoutes = require('./usageStats')
const systemRoutes = require('./system') const systemRoutes = require('./system')
const concurrencyRoutes = require('./concurrency') const concurrencyRoutes = require('./concurrency')
const claudeRelayConfigRoutes = require('./claudeRelayConfig') const claudeRelayConfigRoutes = require('./claudeRelayConfig')
const syncRoutes = require('./sync')
// 挂载所有子路由 // 挂载所有子路由
// 使用完整路径的模块(直接挂载到根路径) // 使用完整路径的模块(直接挂载到根路径)
@@ -39,6 +40,7 @@ router.use('/', usageStatsRoutes)
router.use('/', systemRoutes) router.use('/', systemRoutes)
router.use('/', concurrencyRoutes) router.use('/', concurrencyRoutes)
router.use('/', claudeRelayConfigRoutes) router.use('/', claudeRelayConfigRoutes)
router.use('/', syncRoutes)
// 使用相对路径的模块(需要指定基础路径前缀) // 使用相对路径的模块(需要指定基础路径前缀)
router.use('/account-groups', accountGroupsRoutes) 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

@@ -12,12 +12,14 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService') const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer') const claudeAccountService = require('../services/claudeAccountService')
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump') const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const { const {
handleAnthropicMessagesToGemini, isWarmupRequest,
handleAnthropicCountTokensToGemini buildMockWarmupResponse,
} = require('../services/anthropicGeminiBridgeService') sendMockWarmupStream
} = require('../utils/warmupInterceptor')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
const router = express.Router() const router = express.Router()
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
@@ -115,6 +117,20 @@ async function handleMessagesRequest(req, res) {
try { try {
const startTime = Date.now() const startTime = Date.now()
// Claude 服务权限校验,阻止未授权的 Key
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 🔄 并发满额重试标志最多重试一次使用req对象存储状态 // 🔄 并发满额重试标志最多重试一次使用req对象存储状态
if (req._concurrencyRetryAttempted === undefined) { if (req._concurrencyRetryAttempted === undefined) {
req._concurrencyRetryAttempted = false req._concurrencyRetryAttempted = false
@@ -159,50 +175,6 @@ async function handleMessagesRequest(req, res) {
} }
} }
const forcedVendor = req._anthropicVendor || null
logger.api('📥 /v1/messages request received', {
model: req.body.model || null,
forcedVendor,
stream: req.body.stream === true
})
dumpAnthropicMessagesRequest(req, {
route: '/v1/messages',
forcedVendor,
model: req.body?.model || null,
stream: req.body?.stream === true
})
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
}
// Claude 服务权限校验,阻止未授权的 Key默认路径保持不变
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 检查是否为流式请求 // 检查是否为流式请求
const isStream = req.body.stream === true const isStream = req.body.stream === true
@@ -398,6 +370,23 @@ 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') { if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号 // 官方Claude账号使用原有的转发服务会自己选择账号
@@ -897,6 +886,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(req.body)) {
logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
)
return res.json(buildMockWarmupResponse(req.body.model))
}
}
// 根据账号类型选择对应的转发服务 // 根据账号类型选择对应的转发服务
let response let response
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
@@ -1020,8 +1024,8 @@ async function handleMessagesRequest(req, res) {
const cacheReadTokens = jsonData.usage.cache_read_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") // 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 rawModel = jsonData.model || req.body.model || 'unknown'
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel) const { baseModel } = parseVendorPrefixedModel(rawModel)
const model = usageBaseModel || rawModel const model = baseModel || rawModel
// 记录真实的token使用量包含模型信息和所有4种token以及账户ID // 记录真实的token使用量包含模型信息和所有4种token以及账户ID
const { accountId: responseAccountId } = response const { accountId: responseAccountId } = response
@@ -1197,66 +1201,6 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini // 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
router.get('/v1/models', authenticateApiKey, async (req, res) => { router.get('/v1/models', authenticateApiKey, async (req, res) => {
try { try {
// Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表
//(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
const geminiAccountService = require('../services/geminiAccountService')
let accountSelection
try {
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
req.apiKey,
null,
null,
{ oauthProvider: 'antigravity' }
)
} catch (error) {
logger.error('Failed to select Gemini OAuth account (antigravity models):', error)
return res.status(503).json({ error: 'No available Gemini OAuth accounts' })
}
const account = await geminiAccountService.getAccount(accountSelection.accountId)
if (!account) {
return res.status(503).json({ error: 'Gemini OAuth account not found' })
}
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig =
typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const models = await geminiAccountService.fetchAvailableModelsAntigravity(
account.accessToken,
proxyConfig,
account.refreshToken
)
// 可选:根据 API Key 的模型限制过滤(黑名单语义)
let filteredModels = models
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
}
return res.json({ object: 'list', data: filteredModels })
}
const modelService = require('../services/modelService') const modelService = require('../services/modelService')
// 从 modelService 获取所有支持的模型 // 从 modelService 获取所有支持的模型
@@ -1393,22 +1337,6 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
// 🔢 Token计数端点 - count_tokens beta API // 🔢 Token计数端点 - count_tokens beta API
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Gemini'
}
})
}
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
}
// 检查权限 // 检查权限
if ( if (
req.apiKey.permissions && req.apiKey.permissions &&
@@ -1465,9 +1393,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
const maxAttempts = 2 const maxAttempts = 2
let attempt = 0 let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => { const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey, req.apiKey,
@@ -1663,5 +1588,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 = router
module.exports.handleMessagesRequest = handleMessagesRequest module.exports.handleMessagesRequest = handleMessagesRequest

View File

@@ -19,16 +19,6 @@ function generateSessionHash(req) {
return crypto.createHash('sha256').update(sessionData).digest('hex') 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 权限 // 检查 API Key 权限
function checkPermissions(apiKeyData, requiredPermission = 'gemini') { function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
const permissions = apiKeyData.permissions || 'all' const permissions = apiKeyData.permissions || 'all'
@@ -345,48 +335,25 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
const client = await geminiAccountService.getOauthClient( const client = await geminiAccountService.getOauthClient(
account.accessToken, account.accessToken,
account.refreshToken, account.refreshToken,
proxyConfig, proxyConfig
account.oauthProvider
) )
if (actualStream) { 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', { logger.info('StreamGenerateContent request', {
model, model,
projectId, projectId: account.projectId,
apiKeyId: apiKeyData.id apiKeyId: apiKeyData.id
}) })
const streamResponse = const streamResponse = await geminiAccountService.generateContentStream(
oauthProvider === 'antigravity' client,
? await geminiAccountService.generateContentStreamAntigravity( { model, request: geminiRequestBody },
client, null, // user_prompt_id
{ model, request: geminiRequestBody }, account.projectId, // 使用有权限的项目ID
null, // user_prompt_id apiKeyData.id, // 使用 API Key ID 作为 session ID
projectId, abortController.signal, // 传递中止信号
apiKeyData.id, // 使用 API Key ID 作为 session ID proxyConfig // 传递代理配置
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') res.setHeader('Content-Type', 'text/event-stream')
@@ -592,41 +559,20 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
}) })
} else { } 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', { logger.info('GenerateContent request', {
model, model,
projectId, projectId: account.projectId,
apiKeyId: apiKeyData.id apiKeyId: apiKeyData.id
}) })
const response = const response = await geminiAccountService.generateContent(
oauthProvider === 'antigravity' client,
? await geminiAccountService.generateContentAntigravity( { model, request: geminiRequestBody },
client, null, // user_prompt_id
{ model, request: geminiRequestBody }, account.projectId, // 使用有权限的项目ID
null, // user_prompt_id apiKeyData.id, // 使用 API Key ID 作为 session ID
projectId, proxyConfig // 传递代理配置
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 格式并返回 // 转换为 OpenAI 格式并返回
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false) const openaiResponse = convertGeminiResponseToOpenAI(response, model, false)
@@ -658,15 +604,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
const duration = Date.now() - startTime const duration = Date.now() - startTime
logger.info(`OpenAI-Gemini request completed in ${duration}ms`) logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
} catch (error) { } catch (error) {
const statusForLog = error?.status || error?.response?.status logger.error('OpenAI-Gemini request error:', error)
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) { if (error.status === 429) {
@@ -727,21 +665,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
let models = [] let models = []
if (account) { if (account) {
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性) // 获取实际的模型列表
try { models = await getAvailableModels(account.accessToken, account.proxy)
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 { } else {
// 返回默认模型列表 // 返回默认模型列表
models = [ models = [
@@ -754,17 +679,6 @@ 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) { if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))

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

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

View File

@@ -1,170 +0,0 @@
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

@@ -91,7 +91,9 @@ class ClaudeAccountService {
useUnifiedClientId = false, // 是否使用统一的客户端标识 useUnifiedClientId = false, // 是否使用统一的客户端标识
unifiedClientId = '', // 统一的客户端标识 unifiedClientId = '', // 统一的客户端标识
expiresAt = null, // 账户订阅到期时间 expiresAt = null, // 账户订阅到期时间
extInfo = null // 额外扩展信息 extInfo = null, // 额外扩展信息
maxConcurrency = 0, // 账户级用户消息串行队列0=使用全局配置,>0=强制启用串行
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -136,7 +138,11 @@ class ClaudeAccountService {
// 账户订阅到期时间 // 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '', subscriptionExpiresAt: expiresAt || '',
// 扩展信息 // 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '' extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
} }
} else { } else {
// 兼容旧格式 // 兼容旧格式
@@ -168,7 +174,11 @@ class ClaudeAccountService {
// 账户订阅到期时间 // 账户订阅到期时间
subscriptionExpiresAt: expiresAt || '', 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, useUnifiedUserAgent,
useUnifiedClientId, useUnifiedClientId,
unifiedClientId, unifiedClientId,
extInfo: normalizedExtInfo extInfo: normalizedExtInfo,
interceptWarmup
} }
} }
@@ -574,7 +585,11 @@ class ClaudeAccountService {
// 添加停止原因 // 添加停止原因
stoppedReason: account.stoppedReason || null, 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', 'useUnifiedClientId',
'unifiedClientId', 'unifiedClientId',
'subscriptionExpiresAt', 'subscriptionExpiresAt',
'extInfo' 'extInfo',
'maxConcurrency',
'interceptWarmup'
] ]
const updatedData = { ...accountData } const updatedData = { ...accountData }
let shouldClearAutoStopFields = false let shouldClearAutoStopFields = false
@@ -681,7 +698,7 @@ class ClaudeAccountService {
updatedData[field] = this._encryptSensitiveData(value) updatedData[field] = this._encryptSensitiveData(value)
} else if (field === 'proxy') { } else if (field === 'proxy') {
updatedData[field] = value ? JSON.stringify(value) : '' updatedData[field] = value ? JSON.stringify(value) : ''
} else if (field === 'priority') { } else if (field === 'priority' || field === 'maxConcurrency') {
updatedData[field] = value.toString() updatedData[field] = value.toString()
} else if (field === 'subscriptionInfo') { } else if (field === 'subscriptionInfo') {
// 处理订阅信息更新 // 处理订阅信息更新

View File

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

View File

@@ -210,7 +210,17 @@ class ClaudeRelayService {
logger.error('❌ accountId missing for queue lock in relayRequest') logger.error('❌ accountId missing for queue lock in relayRequest')
throw new Error('accountId missing for queue lock') throw new Error('accountId missing for queue lock')
} }
const queueResult = await userMessageQueueService.acquireQueueLock(accountId) // 获取账户信息以检查账户级串行队列配置
const accountForQueue = await claudeAccountService.getAccount(accountId)
const accountConfig = accountForQueue
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
: null
const queueResult = await userMessageQueueService.acquireQueueLock(
accountId,
null,
null,
accountConfig
)
if (!queueResult.acquired && !queueResult.skipped) { if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时 // 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error' const isBackendError = queueResult.error === 'queue_backend_error'
@@ -323,17 +333,46 @@ class ClaudeRelayService {
} }
// 发送请求到Claude API传入回调以获取请求对象 // 发送请求到Claude API传入回调以获取请求对象
const response = await this._makeClaudeRequest( // 🔄 403 重试机制:仅对 claude-official 类型账户OAuth 或 Setup Token
processedBody, const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
accessToken, let retryCount = 0
proxyAgent, let response
clientHeaders, let shouldRetry = false
accountId,
(req) => { do {
upstreamRequest = req response = await this._makeClaudeRequest(
}, processedBody,
options accessToken,
) proxyAgent,
clientHeaders,
accountId,
(req) => {
upstreamRequest = req
},
options
)
// 检查是否需要重试 403
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
if (shouldRetry) {
retryCount++
logger.warn(
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
)
await this._sleep(2000)
}
} while (shouldRetry)
// 如果进行了重试,记录最终结果
if (retryCount > 0) {
if (response.statusCode === 403) {
logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`)
} else {
logger.info(
`✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}`
)
}
}
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻 // 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
@@ -398,9 +437,10 @@ class ClaudeRelayService {
} }
} }
// 检查是否为403状态码禁止访问 // 检查是否为403状态码禁止访问
// 注意如果进行了重试retryCount > 0这里的 403 是重试后最终的结果
else if (response.statusCode === 403) { else if (response.statusCode === 403) {
logger.error( logger.error(
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked` `🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} }
@@ -1314,7 +1354,17 @@ class ClaudeRelayService {
logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture') logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture')
throw new Error('accountId missing for queue lock') throw new Error('accountId missing for queue lock')
} }
const queueResult = await userMessageQueueService.acquireQueueLock(accountId) // 获取账户信息以检查账户级串行队列配置
const accountForQueue = await claudeAccountService.getAccount(accountId)
const accountConfig = accountForQueue
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
: null
const queueResult = await userMessageQueueService.acquireQueueLock(
accountId,
null,
null,
accountConfig
)
if (!queueResult.acquired && !queueResult.skipped) { if (!queueResult.acquired && !queueResult.skipped) {
// 区分 Redis 后端错误和队列超时 // 区分 Redis 后端错误和队列超时
const isBackendError = queueResult.error === 'queue_backend_error' const isBackendError = queueResult.error === 'queue_backend_error'
@@ -1497,8 +1547,10 @@ class ClaudeRelayService {
streamTransformer = null, streamTransformer = null,
requestOptions = {}, requestOptions = {},
isDedicatedOfficialAccount = false, isDedicatedOfficialAccount = false,
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁 onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
retryCount = 0 // 🔄 403 重试计数器
) { ) {
const maxRetries = 2 // 最大重试次数
// 获取账户信息用于统一 User-Agent // 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId) const account = await claudeAccountService.getAccount(accountId)
@@ -1611,6 +1663,51 @@ class ClaudeRelayService {
} }
} }
// 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理)
// 否则重试时旧响应的 on('end') 会与新请求产生竞态条件
if (res.statusCode === 403) {
const canRetry =
this._shouldRetryOn403(accountType) &&
retryCount < maxRetries &&
!responseStream.headersSent
if (canRetry) {
logger.warn(
`🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s`
)
// 消费当前响应并销毁请求
res.resume()
req.destroy()
// 等待 2 秒后递归重试
await this._sleep(2000)
try {
// 递归调用自身进行重试
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
body,
accessToken,
proxyAgent,
clientHeaders,
responseStream,
usageCallback,
accountId,
accountType,
sessionHash,
streamTransformer,
requestOptions,
isDedicatedOfficialAccount,
onResponseStart,
retryCount + 1
)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
return // 重要:提前返回,不设置后续的错误处理器
}
}
// 将错误处理逻辑封装在一个异步函数中 // 将错误处理逻辑封装在一个异步函数中
const handleErrorResponse = async () => { const handleErrorResponse = async () => {
if (res.statusCode === 401) { if (res.statusCode === 401) {
@@ -1634,8 +1731,10 @@ class ClaudeRelayService {
) )
} }
} else if (res.statusCode === 403) { } else if (res.statusCode === 403) {
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
logger.error( logger.error(
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked` `🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} else if (res.statusCode === 529) { } else if (res.statusCode === 529) {
@@ -2456,28 +2555,35 @@ class ClaudeRelayService {
} }
} }
// 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用)
async _prepareAccountForTest(accountId) {
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
if (!accessToken) {
throw new Error('Failed to get valid access token')
}
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
return { account, accessToken, proxyAgent }
}
// 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture // 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture
async testAccountConnection(accountId, responseStream) { async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }) const testRequestBody = createClaudeTestPayload(model, { stream: true })
try { try {
// 获取账户信息 const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
const account = await claudeAccountService.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`) logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
// 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
if (!accessToken) {
throw new Error('Failed to get valid access token')
}
// 获取代理配置
const proxyAgent = await this._getProxyAgent(accountId)
// 设置响应头 // 设置响应头
if (!responseStream.headersSent) { if (!responseStream.headersSent) {
const existingConnection = responseStream.getHeader const existingConnection = responseStream.getHeader
@@ -2526,6 +2632,125 @@ class ClaudeRelayService {
} }
} }
// 🧪 非流式测试账号连接(供定时任务使用)
// 复用流式请求方法,收集结果后返回
async testAccountConnectionSync(accountId, model = 'claude-sonnet-4-5-20250929') {
const testRequestBody = createClaudeTestPayload(model, { stream: true })
const startTime = Date.now()
try {
// 使用公共方法准备测试所需的账户信息、token 和代理
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
logger.info(`🧪 Testing Claude account connection (sync): ${account.name} (${accountId})`)
// 创建一个收集器来捕获流式响应
let responseText = ''
let capturedUsage = null
let capturedModel = model
let hasError = false
let errorMessage = ''
// 创建模拟的响应流对象
const mockResponseStream = {
headersSent: true, // 跳过设置响应头
write: (data) => {
// 解析 SSE 数据
if (typeof data === 'string' && data.startsWith('data: ')) {
try {
const jsonStr = data.replace('data: ', '').trim()
if (jsonStr && jsonStr !== '[DONE]') {
const parsed = JSON.parse(jsonStr)
// 提取文本内容
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
responseText += parsed.delta.text
}
// 提取 usage 信息
if (parsed.type === 'message_delta' && parsed.usage) {
capturedUsage = parsed.usage
}
// 提取模型信息
if (parsed.type === 'message_start' && parsed.message?.model) {
capturedModel = parsed.message.model
}
// 检测错误
if (parsed.type === 'error') {
hasError = true
errorMessage = parsed.error?.message || 'Unknown error'
}
}
} catch {
// 忽略解析错误
}
}
return true
},
end: () => {},
on: () => {},
once: () => {},
emit: () => {},
writable: true
}
// 复用流式请求方法
await this._makeClaudeStreamRequestWithUsageCapture(
testRequestBody,
accessToken,
proxyAgent,
{}, // clientHeaders - 测试不需要客户端headers
mockResponseStream,
null, // usageCallback - 测试不需要统计
accountId,
'claude-official', // accountType
null, // sessionHash - 测试不需要会话
null, // streamTransformer - 不需要转换,直接解析原始格式
{}, // requestOptions
false // isDedicatedOfficialAccount
)
const latencyMs = Date.now() - startTime
if (hasError) {
logger.warn(`⚠️ Test completed with error for account: ${account.name} - ${errorMessage}`)
return {
success: false,
error: errorMessage,
latencyMs,
timestamp: new Date().toISOString()
}
}
logger.info(`✅ Test completed for account: ${account.name} (${latencyMs}ms)`)
return {
success: true,
message: responseText.substring(0, 200), // 截取前200字符
latencyMs,
model: capturedModel,
usage: capturedUsage,
timestamp: new Date().toISOString()
}
} catch (error) {
const latencyMs = Date.now() - startTime
logger.error(`❌ Test account connection (sync) failed:`, error.message)
// 提取错误详情
let errorMessage = error.message
if (error.response) {
errorMessage =
error.response.data?.error?.message || error.response.statusText || error.message
}
return {
success: false,
error: errorMessage,
statusCode: error.response?.status,
latencyMs,
timestamp: new Date().toISOString()
}
}
}
// 🎯 健康检查 // 🎯 健康检查
async healthCheck() { async healthCheck() {
try { try {
@@ -2547,6 +2772,17 @@ class ClaudeRelayService {
} }
} }
} }
// 🔄 判断账户是否应该在 403 错误时进行重试
// 仅 claude-official 类型账户OAuth 或 Setup Token 授权)需要重试
_shouldRetryOn403(accountType) {
return accountType === 'claude-official'
}
// ⏱️ 等待指定毫秒数
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
} }
module.exports = new ClaudeRelayService() module.exports = new ClaudeRelayService()

View File

@@ -16,62 +16,11 @@ const {
} = require('../utils/tokenRefreshLogger') } = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../utils/lruCache')
const antigravityClient = require('./antigravityClient')
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用 // Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' const OAUTH_CLIENT_SECRET = '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'
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
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 配置 // 🌐 TCP Keep-Alive Agent 配置
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题 // 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
@@ -85,117 +34,6 @@ const keepAliveAgent = new https.Agent({
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support') 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 ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'gemini-account-salt' const ENCRYPTION_SALT = 'gemini-account-salt'
@@ -286,15 +124,14 @@ setInterval(
) )
// 创建 OAuth2 客户端(支持代理配置) // 创建 OAuth2 客户端(支持代理配置)
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) { function createOAuth2Client(redirectUri = null, proxyConfig = null) {
// 如果没有提供 redirectUri使用默认值 // 如果没有提供 redirectUri使用默认值
const uri = redirectUri || 'http://localhost:45462' const uri = redirectUri || 'http://localhost:45462'
const oauthConfig = getOauthProviderConfig(oauthProvider)
// 准备客户端选项 // 准备客户端选项
const clientOptions = { const clientOptions = {
clientId: oauthConfig.clientId, clientId: OAUTH_CLIENT_ID,
clientSecret: oauthConfig.clientSecret, clientSecret: OAUTH_CLIENT_SECRET,
redirectUri: uri redirectUri: uri
} }
@@ -315,17 +152,10 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvide
} }
// 生成授权 URL (支持 PKCE 和代理) // 生成授权 URL (支持 PKCE 和代理)
async function generateAuthUrl( async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
state = null,
redirectUri = null,
proxyConfig = null,
oauthProvider = null
) {
// 使用新的 redirect URI // 使用新的 redirect URI
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
const normalizedProvider = normalizeOauthProvider(oauthProvider) const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider)
if (proxyConfig) { if (proxyConfig) {
logger.info( logger.info(
@@ -342,7 +172,7 @@ async function generateAuthUrl(
const authUrl = oAuth2Client.generateAuthUrl({ const authUrl = oAuth2Client.generateAuthUrl({
redirect_uri: finalRedirectUri, redirect_uri: finalRedirectUri,
access_type: 'offline', access_type: 'offline',
scope: oauthConfig.scopes, scope: OAUTH_SCOPES,
code_challenge_method: 'S256', code_challenge_method: 'S256',
code_challenge: codeVerifier.codeChallenge, code_challenge: codeVerifier.codeChallenge,
state: stateValue, state: stateValue,
@@ -353,8 +183,7 @@ async function generateAuthUrl(
authUrl, authUrl,
state: stateValue, state: stateValue,
codeVerifier: codeVerifier.codeVerifier, codeVerifier: codeVerifier.codeVerifier,
redirectUri: finalRedirectUri, redirectUri: finalRedirectUri
oauthProvider: normalizedProvider
} }
} }
@@ -415,14 +244,11 @@ async function exchangeCodeForTokens(
code, code,
redirectUri = null, redirectUri = null,
codeVerifier = null, codeVerifier = null,
proxyConfig = null, proxyConfig = null
oauthProvider = null
) { ) {
try { try {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
// 创建带代理配置的 OAuth2Client // 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider) const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
if (proxyConfig) { if (proxyConfig) {
logger.info( logger.info(
@@ -448,7 +274,7 @@ async function exchangeCodeForTokens(
return { return {
access_token: tokens.access_token, access_token: tokens.access_token,
refresh_token: tokens.refresh_token, refresh_token: tokens.refresh_token,
scope: tokens.scope || oauthConfig.scopes.join(' '), scope: tokens.scope || OAUTH_SCOPES.join(' '),
token_type: tokens.token_type || 'Bearer', token_type: tokens.token_type || 'Bearer',
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000 expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
} }
@@ -459,11 +285,9 @@ async function exchangeCodeForTokens(
} }
// 刷新访问令牌 // 刷新访问令牌
async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) { async function refreshAccessToken(refreshToken, proxyConfig = null) {
const normalizedProvider = normalizeOauthProvider(oauthProvider)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
// 创建带代理配置的 OAuth2Client // 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider) const oAuth2Client = createOAuth2Client(null, proxyConfig)
try { try {
// 设置 refresh_token // 设置 refresh_token
@@ -495,7 +319,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvide
return { return {
access_token: credentials.access_token, access_token: credentials.access_token,
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的 refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
scope: credentials.scope || oauthConfig.scopes.join(' '), scope: credentials.scope || OAUTH_SCOPES.join(' '),
token_type: credentials.token_type || 'Bearer', token_type: credentials.token_type || 'Bearer',
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期 expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
} }
@@ -515,8 +339,6 @@ async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvide
async function createAccount(accountData) { async function createAccount(accountData) {
const id = uuidv4() const id = uuidv4()
const now = new Date().toISOString() const now = new Date().toISOString()
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
const oauthConfig = getOauthProviderConfig(oauthProvider)
// 处理凭证数据 // 处理凭证数据
let geminiOauth = null let geminiOauth = null
@@ -549,7 +371,7 @@ async function createAccount(accountData) {
geminiOauth = JSON.stringify({ geminiOauth = JSON.stringify({
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken,
scope: accountData.scope || oauthConfig.scopes.join(' '), scope: accountData.scope || OAUTH_SCOPES.join(' '),
token_type: accountData.tokenType || 'Bearer', token_type: accountData.tokenType || 'Bearer',
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时 expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
}) })
@@ -577,8 +399,7 @@ async function createAccount(accountData) {
refreshToken: refreshToken ? encrypt(refreshToken) : '', refreshToken: refreshToken ? encrypt(refreshToken) : '',
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新) expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
// 只有OAuth方式才有scopes手动添加的没有 // 只有OAuth方式才有scopes手动添加的没有
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '', scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
oauthProvider,
// ✅ 新增:账户订阅到期时间(业务字段,手动管理) // ✅ 新增:账户订阅到期时间(业务字段,手动管理)
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
@@ -687,10 +508,6 @@ async function updateAccount(accountId, updates) {
updates.schedulable = updates.schedulable.toString() updates.schedulable = updates.schedulable.toString()
} }
if (updates.oauthProvider !== undefined) {
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
}
// 加密敏感字段 // 加密敏感字段
if (updates.geminiOauth) { if (updates.geminiOauth) {
updates.geminiOauth = encrypt( updates.geminiOauth = encrypt(
@@ -1068,13 +885,12 @@ async function refreshAccountToken(accountId) {
// 重新获取账户数据(可能已被其他进程刷新) // 重新获取账户数据(可能已被其他进程刷新)
const updatedAccount = await getAccount(accountId) const updatedAccount = await getAccount(accountId)
if (updatedAccount && updatedAccount.accessToken) { if (updatedAccount && updatedAccount.accessToken) {
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
const accessToken = decrypt(updatedAccount.accessToken) const accessToken = decrypt(updatedAccount.accessToken)
return { return {
access_token: accessToken, access_token: accessToken,
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '', refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0, expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
scope: updatedAccount.scopes || oauthConfig.scopes.join(' '), scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
token_type: 'Bearer' token_type: 'Bearer'
} }
} }
@@ -1088,11 +904,7 @@ async function refreshAccountToken(accountId) {
// account.refreshToken 已经是解密后的值(从 getAccount 返回) // account.refreshToken 已经是解密后的值(从 getAccount 返回)
// 传入账户的代理配置 // 传入账户的代理配置
const newTokens = await refreshAccessToken( const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
account.refreshToken,
account.proxy,
account.oauthProvider
)
// 更新账户信息 // 更新账户信息
const updates = { const updates = {
@@ -1224,15 +1036,14 @@ async function getAccountRateLimitInfo(accountId) {
} }
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法支持代理 // 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法支持代理
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) { async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
const normalizedProvider = normalizeOauthProvider(oauthProvider) const client = createOAuth2Client(null, proxyConfig)
const oauthConfig = getOauthProviderConfig(normalizedProvider)
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
const creds = { const creds = {
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken,
scope: oauthConfig.scopes.join(' '), scope:
'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
token_type: 'Bearer', token_type: 'Bearer',
expiry_date: 1754269905646 expiry_date: 1754269905646
} }
@@ -1698,43 +1509,6 @@ async function generateContent(
return response.data 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 生成内容(流式) // 调用 Code Assist API 生成内容(流式)
async function generateContentStream( async function generateContentStream(
client, client,
@@ -1819,46 +1593,6 @@ async function generateContentStream(
return response.data // 返回流对象 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 // 更新账户的临时项目 ID
async function updateTempProjectId(accountId, tempProjectId) { async function updateTempProjectId(accountId, tempProjectId) {
if (!tempProjectId) { if (!tempProjectId) {
@@ -1953,12 +1687,10 @@ module.exports = {
generateEncryptionKey, generateEncryptionKey,
decryptCache, // 暴露缓存对象以便测试和监控 decryptCache, // 暴露缓存对象以便测试和监控
countTokens, countTokens,
countTokensAntigravity,
generateContent, generateContent,
generateContentStream, generateContentStream,
generateContentAntigravity,
generateContentStreamAntigravity,
fetchAvailableModelsAntigravity,
updateTempProjectId, updateTempProjectId,
resetAccountStatus resetAccountStatus,
OAUTH_CLIENT_ID,
OAUTH_SCOPES
} }

View File

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

View File

@@ -121,12 +121,23 @@ class UserMessageQueueService {
* @param {string} accountId - 账户ID * @param {string} accountId - 账户ID
* @param {string} requestId - 请求ID可选会自动生成 * @param {string} requestId - 请求ID可选会自动生成
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值) * @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用=0使用全局配置
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>} * @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() 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 } return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
} }

View File

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

View File

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

View File

@@ -1,138 +0,0 @@
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

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

View File

@@ -55,69 +55,16 @@ function sanitizeUpstreamError(errorData) {
return errorData return errorData
} }
// AxiosError / Error返回摘要避免泄露请求体/headers/token 等敏感信息 // 深拷贝避免修改原始对象
const looksLikeAxiosError = const sanitized = JSON.parse(JSON.stringify(errorData))
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) => { const sanitizeObject = (obj) => {
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return obj 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) { for (const key in obj) {
if (shouldRedactKey(key)) {
obj[key] = '[REDACTED]'
continue
}
// 清理所有字符串字段,不仅仅是 message // 清理所有字符串字段,不仅仅是 message
if (typeof obj[key] === 'string') { if (typeof obj[key] === 'string') {
obj[key] = sanitizeErrorMessage(obj[key]) obj[key] = sanitizeErrorMessage(obj[key])
@@ -129,9 +76,7 @@ function sanitizeUpstreamError(errorData) {
return obj return obj
} }
// 尽量不修改原对象:浅拷贝后递归清理 return sanitizeObject(sanitized)
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
return sanitizeObject(clone)
} }
/** /**

View File

@@ -1,265 +0,0 @@
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,10 +5,6 @@
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model. * 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 * Parse vendor-prefixed model string
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro") * @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
@@ -23,21 +19,16 @@ function parseVendorPrefixedModel(modelStr) {
const trimmed = modelStr.trim() const trimmed = modelStr.trim()
const lowerTrimmed = trimmed.toLowerCase() const lowerTrimmed = trimmed.toLowerCase()
for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) { // Check for ccr prefix (case insensitive)
if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) { if (lowerTrimmed.startsWith('ccr,')) {
continue
}
const parts = trimmed.split(',') const parts = trimmed.split(',')
if (parts.length < 2) { 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 {
// Extract base model (everything after the first comma, rejoined in case model name contains commas) vendor: 'ccr',
const baseModel = parts.slice(1).join(',').trim() baseModel
return { }
vendor: vendorPrefix,
baseModel
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -477,36 +477,6 @@
<i class="fas fa-check text-xs text-white"></i> <i class="fas fa-check text-xs text-white"></i>
</div> </div>
</label> </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 <label
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
@@ -802,7 +772,7 @@
</div> </div>
<!-- Gemini 项目 ID 字段 --> <!-- Gemini 项目 ID 字段 -->
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'"> <div v-if="form.platform === 'gemini'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>项目 ID (可选)</label >项目 ID (可选)</label
> >
@@ -1662,6 +1632,47 @@
</label> </label>
</div> </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 版本配置 --> <!-- Claude User-Agent 版本配置 -->
<div v-if="form.platform === 'claude'" class="mt-4"> <div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start"> <label class="flex items-start">
@@ -1813,7 +1824,7 @@
Token建议也一并填写以支持自动刷新。 Token建议也一并填写以支持自动刷新。
</p> </p>
<p <p
v-else-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'" v-else-if="form.platform === 'gemini'"
class="mb-2 text-sm text-blue-800 dark:text-blue-300" class="mb-2 text-sm text-blue-800 dark:text-blue-300"
> >
请输入有效的 Gemini Access Token。如果您有 Refresh 请输入有效的 Gemini Access Token。如果您有 Refresh
@@ -1850,9 +1861,7 @@
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。 文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
</p> </p>
<p <p
v-else-if=" v-else-if="form.platform === 'gemini'"
form.platform === 'gemini' || form.platform === 'gemini-antigravity'
"
class="text-xs text-blue-800 dark:text-blue-300" class="text-xs text-blue-800 dark:text-blue-300"
> >
请从已登录 Gemini CLI 的机器上获取 请从已登录 Gemini CLI 的机器上获取
@@ -2582,7 +2591,7 @@
</div> </div>
<!-- Gemini 项目 ID 字段 --> <!-- Gemini 项目 ID 字段 -->
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'"> <div v-if="form.platform === 'gemini'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>项目 ID (可选)</label >项目 ID (可选)</label
> >
@@ -2647,6 +2656,44 @@
</label> </label>
</div> </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 版本配置(编辑模式) --> <!-- Claude User-Agent 版本配置(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4"> <div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start"> <label class="flex items-start">
@@ -3833,7 +3880,7 @@ const determinePlatformGroup = (platform) => {
return 'claude' return 'claude'
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) { } else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
return 'openai' return 'openai'
} else if (['gemini', 'gemini-antigravity', 'gemini-api'].includes(platform)) { } else if (['gemini', 'gemini-api'].includes(platform)) {
return 'gemini' return 'gemini'
} else if (platform === 'droid') { } else if (platform === 'droid') {
return 'droid' return 'droid'
@@ -3968,8 +4015,7 @@ const form = ref({
platform: props.account?.platform || 'claude', platform: props.account?.platform || 'claude',
addType: (() => { addType: (() => {
const platform = props.account?.platform || 'claude' const platform = props.account?.platform || 'claude'
if (platform === 'gemini' || platform === 'gemini-antigravity' || platform === 'openai') if (platform === 'gemini' || platform === 'openai') return 'oauth'
return 'oauth'
if (platform === 'claude') return 'oauth' if (platform === 'claude') return 'oauth'
return 'manual' return 'manual'
})(), })(),
@@ -3982,6 +4028,9 @@ const form = ref({
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本 useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识 useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识 unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
interceptWarmup:
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
groupId: '', groupId: '',
groupIds: [], groupIds: [],
projectId: props.account?.projectId || '', projectId: props.account?.projectId || '',
@@ -4308,7 +4357,7 @@ const selectPlatformGroup = (group) => {
} else if (group === 'openai') { } else if (group === 'openai') {
form.value.platform = 'openai' form.value.platform = 'openai'
} else if (group === 'gemini') { } else if (group === 'gemini') {
form.value.platform = 'gemini' // Default to Gemini CLI, user can select Antigravity form.value.platform = 'gemini'
} else if (group === 'droid') { } else if (group === 'droid') {
form.value.platform = 'droid' form.value.platform = 'droid'
} }
@@ -4345,11 +4394,7 @@ const nextStep = async () => {
} }
// 对于Gemini账户检查项目 ID // 对于Gemini账户检查项目 ID
if ( if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
(form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') &&
oauthStep.value === 1 &&
form.value.addType === 'oauth'
) {
if (!form.value.projectId || form.value.projectId.trim() === '') { if (!form.value.projectId || form.value.projectId.trim() === '') {
// 使用自定义确认弹窗 // 使用自定义确认弹窗
const confirmed = await showConfirm( const confirmed = await showConfirm(
@@ -4572,9 +4617,11 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
claudeAiOauth: claudeOauthPayload, claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50, priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false, autoStopOnWarning: form.value.autoStopOnWarning || false,
interceptWarmup: form.value.interceptWarmup || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false, useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false, useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId, unifiedClientId: clientId,
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
subscriptionInfo: { subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max', hasClaudeMax: form.value.subscriptionType === 'claude_max',
@@ -4712,6 +4759,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || '' data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息 // 添加订阅类型信息
data.subscriptionInfo = { data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
@@ -4719,14 +4767,9 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
hasClaudePro: form.value.subscriptionType === 'claude_pro', hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true // 标记为手动设置 manuallySet: true // 标记为手动设置
} }
} else if (currentPlatform === 'gemini' || currentPlatform === 'gemini-antigravity') { } else if (currentPlatform === 'gemini') {
// Gemini/Antigravity使用geminiOauth字段 // Gemini使用geminiOauth字段
data.geminiOauth = tokenInfo.tokens || tokenInfo data.geminiOauth = tokenInfo.tokens || tokenInfo
// 根据 platform 设置 oauthProvider
data.oauthProvider =
currentPlatform === 'gemini-antigravity'
? 'antigravity'
: tokenInfo.oauthProvider || 'gemini-cli'
if (form.value.projectId) { if (form.value.projectId) {
data.projectId = form.value.projectId data.projectId = form.value.projectId
} }
@@ -5040,6 +5083,7 @@ const createAccount = async () => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || '' data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息 // 添加订阅类型信息
data.subscriptionInfo = { data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
@@ -5131,6 +5175,7 @@ const createAccount = async () => {
// 上游错误处理(仅 Claude Console // 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') { if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection data.disableAutoProtection = !!form.value.disableAutoProtection
data.interceptWarmup = !!form.value.interceptWarmup
} }
// 额度管理字段 // 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0 data.dailyQuota = form.value.dailyQuota || 0
@@ -5146,10 +5191,6 @@ const createAccount = async () => {
data.rateLimitDuration = 60 // 默认值60不从用户输入获取 data.rateLimitDuration = 60 // 默认值60不从用户输入获取
data.dailyQuota = form.value.dailyQuota || 0 data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00' 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') { } else if (form.value.platform === 'gemini-api') {
// Gemini API 账户特定数据 // Gemini API 账户特定数据
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com' data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
@@ -5201,7 +5242,7 @@ const createAccount = async () => {
result = await accountsStore.createOpenAIAccount(data) result = await accountsStore.createOpenAIAccount(data)
} else if (form.value.platform === 'azure_openai') { } else if (form.value.platform === 'azure_openai') {
result = await accountsStore.createAzureOpenAIAccount(data) result = await accountsStore.createAzureOpenAIAccount(data)
} else if (form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') { } else if (form.value.platform === 'gemini') {
result = await accountsStore.createGeminiAccount(data) result = await accountsStore.createGeminiAccount(data)
} else if (form.value.platform === 'gemini-api') { } else if (form.value.platform === 'gemini-api') {
result = await accountsStore.createGeminiApiAccount(data) result = await accountsStore.createGeminiApiAccount(data)
@@ -5431,9 +5472,11 @@ const updateAccount = async () => {
data.priority = form.value.priority || 50 data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false data.autoStopOnWarning = form.value.autoStopOnWarning || false
data.interceptWarmup = form.value.interceptWarmup || false
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || '' data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 更新订阅类型信息 // 更新订阅类型信息
data.subscriptionInfo = { data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max', accountType: form.value.subscriptionType || 'claude_max',
@@ -5466,6 +5509,8 @@ const updateAccount = async () => {
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理 // 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection data.disableAutoProtection = !!form.value.disableAutoProtection
// 拦截预热请求
data.interceptWarmup = !!form.value.interceptWarmup
// 额度管理字段 // 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0 data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00' data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -6034,9 +6079,12 @@ watch(
accountType: newAccount.accountType || 'shared', accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType, subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false, autoStopOnWarning: newAccount.autoStopOnWarning || false,
interceptWarmup:
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false, useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
useUnifiedClientId: newAccount.useUnifiedClientId || false, useUnifiedClientId: newAccount.useUnifiedClientId || false,
unifiedClientId: newAccount.unifiedClientId || '', unifiedClientId: newAccount.unifiedClientId || '',
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
groupId: groupId, groupId: groupId,
groupIds: [], groupIds: [],
projectId: newAccount.projectId || '', 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

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

View File

@@ -1238,6 +1238,15 @@
<i class="fas fa-vial" /> <i class="fas fa-vial" />
<span class="ml-1">测试</span> <span class="ml-1">测试</span>
</button> </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 <button
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200" class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
title="编辑账户" title="编辑账户"
@@ -1707,6 +1716,15 @@
测试 测试
</button> </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 <button
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100" 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)" @click="editAccount(account)"
@@ -1880,6 +1898,14 @@
@close="closeAccountTestModal" @close="closeAccountTestModal"
/> />
<!-- 定时测试配置弹窗 -->
<AccountScheduledTestModal
:account="scheduledTestAccount"
:show="showScheduledTestModal"
@close="closeScheduledTestModal"
@saved="handleScheduledTestSaved"
/>
<!-- 账户统计弹窗 --> <!-- 账户统计弹窗 -->
<el-dialog <el-dialog
v-model="showAccountStatsModal" v-model="showAccountStatsModal"
@@ -2032,6 +2058,7 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue' import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue' import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import AccountTestModal from '@/components/accounts/AccountTestModal.vue' import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue' import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue' import ActionDropdown from '@/components/common/ActionDropdown.vue'
@@ -2099,6 +2126,10 @@ const expiryEditModalRef = ref(null)
const showAccountTestModal = ref(false) const showAccountTestModal = ref(false)
const testingAccount = ref(null) const testingAccount = ref(null)
// 定时测试配置弹窗状态
const showScheduledTestModal = ref(false)
const scheduledTestAccount = ref(null)
// 账户统计弹窗状态 // 账户统计弹窗状态
const showAccountStatsModal = ref(false) const showAccountStatsModal = ref(false)
@@ -2365,6 +2396,13 @@ const getAccountActions = (account) => {
color: 'blue', color: 'blue',
handler: () => openAccountTestModal(account) handler: () => openAccountTestModal(account)
}) })
actions.push({
key: 'scheduled-test',
label: '定时测试',
icon: 'fa-clock',
color: 'amber',
handler: () => openScheduledTestModal(account)
})
} }
// 删除 // 删除
@@ -2441,6 +2479,25 @@ const closeAccountTestModal = () => {
testingAccount.value = null 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 sortedAccounts = computed(() => { const sortedAccounts = computed(() => {
let sourceAccounts = accounts.value let sourceAccounts = accounts.value