mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792ba51290 | ||
|
|
74d138a2fb | ||
|
|
b88698191e | ||
|
|
11c38b23d1 | ||
|
|
b2dfc2eb25 | ||
|
|
59ce0f091c | ||
|
|
67c20fa30e | ||
|
|
671451253f | ||
|
|
0173ab224b | ||
|
|
11fb77c8bd | ||
|
|
3d67f0b124 | ||
|
|
84f19b348b | ||
|
|
8ec8a59b07 | ||
|
|
00d8ac4bec | ||
|
|
5863816882 | ||
|
|
638d2ff189 | ||
|
|
fa2fc2fb16 | ||
|
|
6d56601550 | ||
|
|
dd8a0c95c3 | ||
|
|
126eee3712 | ||
|
|
26bfdd6892 | ||
|
|
cd3f51e9e2 | ||
|
|
9977245d59 | ||
|
|
09cf951cdc | ||
|
|
ba93ae55a9 | ||
|
|
0994eb346f | ||
|
|
4863a37328 |
35
.env.example
35
.env.example
@@ -33,41 +33,6 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
||||
CLAUDE_API_VERSION=2023-06-01
|
||||
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||
|
||||
# 🤖 Gemini OAuth / Antigravity 配置(可选)
|
||||
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖
|
||||
# GEMINI_OAUTH_CLIENT_ID=
|
||||
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||
# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode)
|
||||
# GEMINI_OAUTH_REDIRECT_URI=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch)
|
||||
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
|
||||
# Antigravity 上游地址(可选,默认 sandbox)
|
||||
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
|
||||
# Antigravity User-Agent(可选)
|
||||
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
|
||||
|
||||
# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量):
|
||||
# - /api -> Claude 账号池(默认)
|
||||
# - /antigravity/api -> Antigravity OAuth
|
||||
# - /gemini-cli/api -> Gemini CLI OAuth
|
||||
|
||||
# (可选)Claude Code 调试 Dump:会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题
|
||||
# - anthropic-requests-dump.jsonl
|
||||
# - anthropic-responses-dump.jsonl
|
||||
# - anthropic-tools-dump.jsonl
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||
#
|
||||
# (可选)Antigravity 上游请求 Dump:会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload(含 tools/schema 清洗后的结果)
|
||||
# - antigravity-upstream-requests-dump.jsonl
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
||||
|
||||
# 🚫 529错误处理配置
|
||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||
|
||||
22
README.md
22
README.md
@@ -389,31 +389,13 @@ docker-compose.yml 已包含:
|
||||
|
||||
**Claude Code 设置环境变量:**
|
||||
|
||||
默认使用标准 Claude 账号池(Claude/Console/Bedrock/CCR):
|
||||
默认使用标准 Claude 账号池:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||
```
|
||||
|
||||
如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
|
||||
|
||||
Antigravity OAuth(支持 `claude-opus-4-5` 等 Antigravity 模型):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||
```
|
||||
|
||||
Gemini CLI OAuth(使用 Gemini 模型):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
||||
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
**VSCode Claude 插件配置:**
|
||||
|
||||
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
||||
@@ -426,6 +408,8 @@ export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||
|
||||
如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
|
||||
|
||||
> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
|
||||
|
||||
**Gemini CLI 设置环境变量:**
|
||||
|
||||
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
||||
|
||||
22
README_EN.md
22
README_EN.md
@@ -238,31 +238,13 @@ Now you can replace the official API with your own service:
|
||||
|
||||
**Claude Code Set Environment Variables:**
|
||||
|
||||
Default uses standard Claude account pool (Claude/Console/Bedrock/CCR):
|
||||
Default uses standard Claude account pool:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
||||
```
|
||||
|
||||
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
|
||||
|
||||
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||
```
|
||||
|
||||
Gemini CLI OAuth (Gemini models):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
||||
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
**VSCode Claude Plugin Configuration:**
|
||||
|
||||
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
||||
@@ -622,4 +604,4 @@ This project uses the [MIT License](LICENSE).
|
||||
|
||||
**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
|
||||
|
||||
</div>
|
||||
</div>
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"ldapjs": "^3.0.7",
|
||||
"morgan": "^1.10.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"ora": "^5.4.1",
|
||||
"rate-limiter-flexible": "^5.0.5",
|
||||
@@ -891,6 +892,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -2999,6 +3001,7 @@
|
||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3080,6 +3083,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3535,6 +3539,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001737",
|
||||
"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.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4478,6 +4484,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -7028,6 +7035,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@@ -7576,6 +7592,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9094,6 +9111,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"ldapjs": "^3.0.7",
|
||||
"morgan": "^1.10.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"ora": "^5.4.1",
|
||||
"rate-limiter-flexible": "^5.0.5",
|
||||
|
||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
||||
morgan:
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.1
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
nodemailer:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.11
|
||||
@@ -108,6 +111,9 @@ importers:
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.7.4
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(prettier@3.7.4)
|
||||
supertest:
|
||||
specifier: ^6.3.3
|
||||
version: 6.3.4
|
||||
@@ -2144,6 +2150,10 @@ packages:
|
||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-cron@4.2.1:
|
||||
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -2302,6 +2312,61 @@ packages:
|
||||
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
prettier-plugin-tailwindcss@0.7.2:
|
||||
resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
|
||||
engines: {node: '>=20.19'}
|
||||
peerDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||
'@prettier/plugin-hermes': '*'
|
||||
'@prettier/plugin-oxc': '*'
|
||||
'@prettier/plugin-pug': '*'
|
||||
'@shopify/prettier-plugin-liquid': '*'
|
||||
'@trivago/prettier-plugin-sort-imports': '*'
|
||||
'@zackad/prettier-plugin-twig': '*'
|
||||
prettier: ^3.0
|
||||
prettier-plugin-astro: '*'
|
||||
prettier-plugin-css-order: '*'
|
||||
prettier-plugin-jsdoc: '*'
|
||||
prettier-plugin-marko: '*'
|
||||
prettier-plugin-multiline-arrays: '*'
|
||||
prettier-plugin-organize-attributes: '*'
|
||||
prettier-plugin-organize-imports: '*'
|
||||
prettier-plugin-sort-imports: '*'
|
||||
prettier-plugin-svelte: '*'
|
||||
peerDependenciesMeta:
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
optional: true
|
||||
'@prettier/plugin-hermes':
|
||||
optional: true
|
||||
'@prettier/plugin-oxc':
|
||||
optional: true
|
||||
'@prettier/plugin-pug':
|
||||
optional: true
|
||||
'@shopify/prettier-plugin-liquid':
|
||||
optional: true
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
optional: true
|
||||
'@zackad/prettier-plugin-twig':
|
||||
optional: true
|
||||
prettier-plugin-astro:
|
||||
optional: true
|
||||
prettier-plugin-css-order:
|
||||
optional: true
|
||||
prettier-plugin-jsdoc:
|
||||
optional: true
|
||||
prettier-plugin-marko:
|
||||
optional: true
|
||||
prettier-plugin-multiline-arrays:
|
||||
optional: true
|
||||
prettier-plugin-organize-attributes:
|
||||
optional: true
|
||||
prettier-plugin-organize-imports:
|
||||
optional: true
|
||||
prettier-plugin-sort-imports:
|
||||
optional: true
|
||||
prettier-plugin-svelte:
|
||||
optional: true
|
||||
|
||||
prettier@3.7.4:
|
||||
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -5692,6 +5757,8 @@ snapshots:
|
||||
|
||||
negotiator@0.6.4: {}
|
||||
|
||||
node-cron@4.2.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@3.3.2:
|
||||
@@ -5840,6 +5907,10 @@ snapshots:
|
||||
dependencies:
|
||||
fast-diff: 1.3.0
|
||||
|
||||
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
|
||||
dependencies:
|
||||
prettier: 3.7.4
|
||||
|
||||
prettier@3.7.4: {}
|
||||
|
||||
pretty-format@29.7.0:
|
||||
|
||||
63
src/app.js
63
src/app.js
@@ -264,25 +264,6 @@ class Application {
|
||||
this.app.use('/api', apiRoutes)
|
||||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||
// Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户
|
||||
// - /antigravity/api/v1/messages -> Antigravity OAuth
|
||||
// - /gemini-cli/api/v1/messages -> Gemini CLI OAuth
|
||||
this.app.use(
|
||||
'/antigravity/api',
|
||||
(req, res, next) => {
|
||||
req._anthropicVendor = 'antigravity'
|
||||
next()
|
||||
},
|
||||
apiRoutes
|
||||
)
|
||||
this.app.use(
|
||||
'/gemini-cli/api',
|
||||
(req, res, next) => {
|
||||
req._anthropicVendor = 'gemini-cli'
|
||||
next()
|
||||
},
|
||||
apiRoutes
|
||||
)
|
||||
this.app.use('/admin', adminRoutes)
|
||||
this.app.use('/users', userRoutes)
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
@@ -600,10 +581,11 @@ class Application {
|
||||
|
||||
const now = Date.now()
|
||||
let totalCleaned = 0
|
||||
let legacyCleaned = 0
|
||||
|
||||
// 使用 Lua 脚本批量清理所有过期项
|
||||
for (const key of keys) {
|
||||
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||
// - concurrency:queue:stats:* 是 Hash 类型
|
||||
// - concurrency:queue:wait_times:* 是 List 类型
|
||||
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||
@@ -618,11 +600,21 @@ class Application {
|
||||
}
|
||||
|
||||
try {
|
||||
const cleaned = await redis.client.eval(
|
||||
// 使用原子 Lua 脚本:先检查类型,再执行清理
|
||||
// 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除
|
||||
const result = await redis.client.eval(
|
||||
`
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
|
||||
-- 先检查键类型,只对 Sorted Set 执行清理
|
||||
local keyType = redis.call('TYPE', key)
|
||||
if keyType.ok ~= 'zset' then
|
||||
-- 非 ZSET 类型的遗留键,直接删除
|
||||
redis.call('DEL', key)
|
||||
return -1
|
||||
end
|
||||
|
||||
-- 清理过期项
|
||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||
|
||||
@@ -641,8 +633,10 @@ class Application {
|
||||
key,
|
||||
now
|
||||
)
|
||||
if (cleaned === 1) {
|
||||
if (result === 1) {
|
||||
totalCleaned++
|
||||
} else if (result === -1) {
|
||||
legacyCleaned++
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
||||
@@ -652,6 +646,9 @@ class Application {
|
||||
if (totalCleaned > 0) {
|
||||
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
||||
}
|
||||
if (legacyCleaned > 0) {
|
||||
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Concurrency cleanup task failed:', error)
|
||||
}
|
||||
@@ -680,6 +677,19 @@ class Application {
|
||||
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
|
||||
)
|
||||
}
|
||||
|
||||
// 🧪 启动账户定时测试调度器
|
||||
// 根据配置定期测试账户连通性并保存测试历史
|
||||
const accountTestSchedulerEnabled =
|
||||
process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
|
||||
config.accountTestScheduler?.enabled !== false
|
||||
if (accountTestSchedulerEnabled) {
|
||||
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||
accountTestSchedulerService.start()
|
||||
logger.info('🧪 Account test scheduler service started')
|
||||
} else {
|
||||
logger.info('🧪 Account test scheduler service disabled')
|
||||
}
|
||||
}
|
||||
|
||||
setupGracefulShutdown() {
|
||||
@@ -734,6 +744,15 @@ class Application {
|
||||
logger.error('❌ Error stopping cost rank service:', error)
|
||||
}
|
||||
|
||||
// 停止账户定时测试调度器
|
||||
try {
|
||||
const accountTestSchedulerService = require('./services/accountTestSchedulerService')
|
||||
accountTestSchedulerService.stop()
|
||||
logger.info('🧪 Account test scheduler service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping account test scheduler service:', error)
|
||||
}
|
||||
|
||||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||||
try {
|
||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||
|
||||
@@ -9,7 +9,6 @@ const logger = require('../utils/logger')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
||||
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
|
||||
const crypto = require('crypto')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
@@ -509,37 +508,20 @@ async function handleMessages(req, res) {
|
||||
// OAuth 账户:使用现有的 sendGeminiRequest
|
||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
geminiResponse = await sendAntigravityRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
signal: abortController.signal,
|
||||
projectId: effectiveProjectId,
|
||||
accountId: account.id
|
||||
})
|
||||
} else {
|
||||
geminiResponse = await sendGeminiRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
signal: abortController.signal,
|
||||
projectId: effectiveProjectId,
|
||||
accountId: account.id
|
||||
})
|
||||
}
|
||||
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) {
|
||||
@@ -772,16 +754,8 @@ async function handleModels(req, res) {
|
||||
]
|
||||
}
|
||||
} else {
|
||||
// OAuth 账户:根据 OAuth provider 选择上游
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
models =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||
account.accessToken,
|
||||
account.proxy,
|
||||
account.refreshToken
|
||||
)
|
||||
: await getAvailableModels(account.accessToken, account.proxy)
|
||||
// OAuth 账户:使用 OAuth token 获取模型列表
|
||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -953,8 +927,7 @@ function handleSimpleEndpoint(apiMethod) {
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 直接转发请求体,不做特殊处理
|
||||
@@ -1033,12 +1006,7 @@ async function handleLoadCodeAssist(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID
|
||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||
@@ -1136,12 +1104,7 @@ async function handleOnboardUser(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID
|
||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||
@@ -1293,8 +1256,7 @@ async function handleCountTokens(req, res) {
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
proxyConfig
|
||||
)
|
||||
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
||||
}
|
||||
@@ -1404,20 +1366,13 @@ async function handleGenerateContent(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||
if (!effectiveProjectId) {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
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,返回错误
|
||||
if (!effectiveProjectId) {
|
||||
return res.status(403).json({
|
||||
@@ -1461,24 +1410,14 @@ async function handleGenerateContent(req, res) {
|
||||
: '从loadCodeAssist获取'
|
||||
})
|
||||
|
||||
const response =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
: await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 记录使用统计
|
||||
if (response?.response?.usageMetadata) {
|
||||
@@ -1639,20 +1578,13 @@ async function handleStreamGenerateContent(req, res) {
|
||||
// 解析账户的代理配置
|
||||
const proxyConfig = parseProxyConfig(account)
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
)
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||
if (!effectiveProjectId) {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
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,返回错误
|
||||
if (!effectiveProjectId) {
|
||||
return res.status(403).json({
|
||||
@@ -1695,26 +1622,15 @@ async function handleStreamGenerateContent(req, res) {
|
||||
: '从loadCodeAssist获取'
|
||||
})
|
||||
|
||||
const streamResponse =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentStreamAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
: await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
user_prompt_id,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
@@ -2062,23 +1978,15 @@ async function handleStandardGenerateContent(req, res) {
|
||||
} else {
|
||||
// OAuth 账户
|
||||
const { accessToken, refreshToken } = account
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
oauthProvider
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
if (!effectiveProjectId) {
|
||||
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||
}
|
||||
} else if (!effectiveProjectId) {
|
||||
if (!effectiveProjectId) {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||
@@ -2116,25 +2024,14 @@ async function handleStandardGenerateContent(req, res) {
|
||||
|
||||
const userPromptId = `${crypto.randomUUID()}########0`
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
response = await geminiAccountService.generateContentAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
} else {
|
||||
response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
proxyConfig
|
||||
)
|
||||
}
|
||||
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(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
if (!effectiveProjectId) {
|
||||
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||
}
|
||||
} else if (!effectiveProjectId) {
|
||||
if (!effectiveProjectId) {
|
||||
try {
|
||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||
@@ -2417,27 +2306,15 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
|
||||
const userPromptId = `${crypto.randomUUID()}########0`
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
streamResponse = await geminiAccountService.generateContentStreamAntigravity(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
} else {
|
||||
streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
}
|
||||
streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId,
|
||||
effectiveProjectId,
|
||||
req.apiKey?.id,
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
|
||||
@@ -1744,9 +1744,13 @@ const requestLogger = (req, res, next) => {
|
||||
const referer = req.get('Referer') || 'none'
|
||||
|
||||
// 记录请求开始
|
||||
const isDebugRoute = req.originalUrl.includes('event_logging')
|
||||
if (req.originalUrl !== '/health') {
|
||||
// 避免健康检查日志过多
|
||||
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
if (isDebugRoute) {
|
||||
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
} else {
|
||||
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
}
|
||||
}
|
||||
|
||||
res.on('finish', () => {
|
||||
@@ -1778,7 +1782,14 @@ const requestLogger = (req, res, next) => {
|
||||
logMetadata
|
||||
)
|
||||
} else if (req.originalUrl !== '/health') {
|
||||
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
||||
if (isDebugRoute) {
|
||||
logger.debug(
|
||||
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
|
||||
logMetadata
|
||||
)
|
||||
} else {
|
||||
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
// API Key相关日志
|
||||
|
||||
@@ -96,7 +96,25 @@ class RedisClient {
|
||||
logger.warn('⚠️ Redis connection closed')
|
||||
})
|
||||
|
||||
await this.client.connect()
|
||||
// 只有在 lazyConnect 模式下才需要手动调用 connect()
|
||||
// 如果 Redis 已经连接或正在连接中,则跳过
|
||||
if (
|
||||
this.client.status !== 'connecting' &&
|
||||
this.client.status !== 'connect' &&
|
||||
this.client.status !== 'ready'
|
||||
) {
|
||||
await this.client.connect()
|
||||
} else {
|
||||
// 等待 ready 状态
|
||||
await new Promise((resolve, reject) => {
|
||||
if (this.client.status === 'ready') {
|
||||
resolve()
|
||||
} else {
|
||||
this.client.once('ready', resolve)
|
||||
this.client.once('error', reject)
|
||||
}
|
||||
})
|
||||
}
|
||||
return this.client
|
||||
} catch (error) {
|
||||
logger.error('💥 Failed to connect to Redis:', error)
|
||||
@@ -2122,6 +2140,27 @@ class RedisClient {
|
||||
const results = []
|
||||
|
||||
for (const key of keys) {
|
||||
// 跳过已知非 Sorted Set 类型的键
|
||||
// - concurrency:queue:stats:* 是 Hash 类型
|
||||
// - concurrency:queue:wait_times:* 是 List 类型
|
||||
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||
if (
|
||||
key.startsWith('concurrency:queue:stats:') ||
|
||||
key.startsWith('concurrency:queue:wait_times:') ||
|
||||
(key.startsWith('concurrency:queue:') &&
|
||||
!key.includes(':stats:') &&
|
||||
!key.includes(':wait_times:'))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查键类型,只处理 Sorted Set
|
||||
const keyType = await client.type(key)
|
||||
if (keyType !== 'zset') {
|
||||
logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取 apiKeyId(去掉 concurrency: 前缀)
|
||||
const apiKeyId = key.replace('concurrency:', '')
|
||||
|
||||
@@ -2184,6 +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')
|
||||
|
||||
@@ -2233,20 +2289,36 @@ class RedisClient {
|
||||
const client = this.getClientSafe()
|
||||
const key = `concurrency:${apiKeyId}`
|
||||
|
||||
// 获取清理前的状态
|
||||
const beforeCount = await client.zcard(key)
|
||||
// 检查键类型
|
||||
const keyType = await client.type(key)
|
||||
|
||||
// 删除整个 key
|
||||
let beforeCount = 0
|
||||
let isLegacy = false
|
||||
|
||||
if (keyType === 'zset') {
|
||||
// 正常的 zset 键,获取条目数
|
||||
beforeCount = await client.zcard(key)
|
||||
} else if (keyType !== 'none') {
|
||||
// 非 zset 且非空的遗留键
|
||||
isLegacy = true
|
||||
logger.warn(
|
||||
`⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted`
|
||||
)
|
||||
}
|
||||
|
||||
// 删除键(无论什么类型)
|
||||
await client.del(key)
|
||||
|
||||
logger.warn(
|
||||
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
|
||||
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
|
||||
)
|
||||
|
||||
return {
|
||||
apiKeyId,
|
||||
key,
|
||||
clearedCount: beforeCount,
|
||||
type: keyType,
|
||||
legacy: isLegacy,
|
||||
success: true
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2265,25 +2337,47 @@ class RedisClient {
|
||||
const keys = await client.keys('concurrency:*')
|
||||
|
||||
let totalCleared = 0
|
||||
let legacyCleared = 0
|
||||
const clearedKeys = []
|
||||
|
||||
for (const key of keys) {
|
||||
const count = await client.zcard(key)
|
||||
await client.del(key)
|
||||
totalCleared += count
|
||||
clearedKeys.push({
|
||||
key,
|
||||
clearedCount: count
|
||||
})
|
||||
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||
if (key.startsWith('concurrency:queue:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查键类型
|
||||
const keyType = await client.type(key)
|
||||
if (keyType === 'zset') {
|
||||
const count = await client.zcard(key)
|
||||
await client.del(key)
|
||||
totalCleared += count
|
||||
clearedKeys.push({
|
||||
key,
|
||||
clearedCount: count,
|
||||
type: 'zset'
|
||||
})
|
||||
} else {
|
||||
// 非 zset 类型的遗留键,直接删除
|
||||
await client.del(key)
|
||||
legacyCleared++
|
||||
clearedKeys.push({
|
||||
key,
|
||||
clearedCount: 0,
|
||||
type: keyType,
|
||||
legacy: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
|
||||
`🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
|
||||
)
|
||||
|
||||
return {
|
||||
keysCleared: keys.length,
|
||||
keysCleared: clearedKeys.length,
|
||||
totalEntriesCleared: totalCleared,
|
||||
legacyKeysCleared: legacyCleared,
|
||||
clearedKeys,
|
||||
success: true
|
||||
}
|
||||
@@ -2311,9 +2405,30 @@ class RedisClient {
|
||||
}
|
||||
|
||||
let totalCleaned = 0
|
||||
let legacyCleaned = 0
|
||||
const cleanedKeys = []
|
||||
|
||||
for (const key of keys) {
|
||||
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||
if (key.startsWith('concurrency:queue:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查键类型
|
||||
const keyType = await client.type(key)
|
||||
if (keyType !== 'zset') {
|
||||
// 非 zset 类型的遗留键,直接删除
|
||||
await client.del(key)
|
||||
legacyCleaned++
|
||||
cleanedKeys.push({
|
||||
key,
|
||||
cleanedCount: 0,
|
||||
type: keyType,
|
||||
legacy: true
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 只清理过期的条目
|
||||
const cleaned = await client.zremrangebyscore(key, '-inf', now)
|
||||
if (cleaned > 0) {
|
||||
@@ -2332,13 +2447,14 @@ class RedisClient {
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys`
|
||||
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed`
|
||||
)
|
||||
|
||||
return {
|
||||
keysProcessed: keys.length,
|
||||
keysCleaned: cleanedKeys.length,
|
||||
totalEntriesCleaned: totalCleaned,
|
||||
legacyKeysRemoved: legacyCleaned,
|
||||
cleanedKeys,
|
||||
success: true
|
||||
}
|
||||
@@ -3157,4 +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
|
||||
|
||||
@@ -9,6 +9,7 @@ const router = express.Router()
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeRelayService = require('../../services/claudeRelayService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
@@ -583,7 +584,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
useUnifiedClientId,
|
||||
unifiedClientId,
|
||||
expiresAt,
|
||||
extInfo
|
||||
extInfo,
|
||||
maxConcurrency,
|
||||
interceptWarmup
|
||||
} = req.body
|
||||
|
||||
if (!name) {
|
||||
@@ -628,7 +631,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
||||
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||
expiresAt: expiresAt || null, // 账户订阅到期时间
|
||||
extInfo: extInfo || null
|
||||
extInfo: extInfo || null,
|
||||
maxConcurrency: maxConcurrency || 0, // 账户级串行队列:0=使用全局配置,>0=强制启用
|
||||
interceptWarmup: interceptWarmup === true // 拦截预热请求:默认为false
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
@@ -903,4 +908,219 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 账户定时测试相关端点
|
||||
// ============================================================================
|
||||
|
||||
// 获取账户测试历史
|
||||
router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
|
||||
try {
|
||||
const history = await redis.getAccountTestHistory(accountId, 'claude')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
history
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get test history for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get test history',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取账户定时测试配置
|
||||
router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
|
||||
try {
|
||||
const testConfig = await redis.getAccountTestConfig(accountId, 'claude')
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
config: testConfig || {
|
||||
enabled: false,
|
||||
cronExpression: '0 8 * * *',
|
||||
model: 'claude-sonnet-4-5-20250929'
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get test config for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get test config',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 设置账户定时测试配置
|
||||
router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { enabled, cronExpression, model } = req.body
|
||||
|
||||
try {
|
||||
// 验证 enabled 参数
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'enabled must be a boolean'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 cronExpression 参数
|
||||
if (!cronExpression || typeof cronExpression !== 'string') {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'cronExpression is required and must be a string'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制 cronExpression 长度防止 DoS
|
||||
const MAX_CRON_LENGTH = 100
|
||||
if (cronExpression.length > MAX_CRON_LENGTH) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)`
|
||||
})
|
||||
}
|
||||
|
||||
// 使用 service 的方法验证 cron 表达式
|
||||
if (!accountTestSchedulerService.validateCronExpression(cronExpression)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)`
|
||||
})
|
||||
}
|
||||
|
||||
// 验证模型参数
|
||||
const testModel = model || 'claude-sonnet-4-5-20250929'
|
||||
if (typeof testModel !== 'string' || testModel.length > 256) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'model must be a valid string (max 256 characters)'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查账户是否存在
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
error: 'Account not found',
|
||||
message: `Claude account ${accountId} not found`
|
||||
})
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
await redis.saveAccountTestConfig(accountId, 'claude', {
|
||||
enabled,
|
||||
cronExpression,
|
||||
model: testModel
|
||||
})
|
||||
|
||||
logger.success(
|
||||
`📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Test config updated successfully',
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
config: { enabled, cronExpression, model: testModel }
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update test config for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to update test config',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发账户测试(非流式,返回JSON结果)
|
||||
router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
|
||||
try {
|
||||
// 检查账户是否存在
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
error: 'Account not found',
|
||||
message: `Claude account ${accountId} not found`
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`)
|
||||
|
||||
// 执行测试
|
||||
const testResult = await claudeRelayService.testAccountConnectionSync(accountId)
|
||||
|
||||
// 保存测试结果到历史
|
||||
await redis.saveAccountTestResult(accountId, 'claude', testResult)
|
||||
await redis.setAccountLastTestTime(accountId, 'claude')
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
platform: 'claude',
|
||||
result: testResult
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to run sync test for account ${accountId}:`, error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to run test',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 批量获取多个账户的测试历史
|
||||
router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => {
|
||||
const { accountIds } = req.body
|
||||
|
||||
try {
|
||||
if (!Array.isArray(accountIds) || accountIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid parameter',
|
||||
message: 'accountIds must be a non-empty array'
|
||||
})
|
||||
}
|
||||
|
||||
// 限制批量查询数量
|
||||
const limitedIds = accountIds.slice(0, 100)
|
||||
|
||||
const accounts = limitedIds.map((accountId) => ({
|
||||
accountId,
|
||||
platform: 'claude'
|
||||
}))
|
||||
|
||||
const historyMap = await redis.getAccountsTestHistory(accounts)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: historyMap
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get batch test history:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get batch test history',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
dailyQuota,
|
||||
quotaResetTime,
|
||||
maxConcurrentTasks,
|
||||
disableAutoProtection
|
||||
disableAutoProtection,
|
||||
interceptWarmup
|
||||
} = req.body
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||
? Number(maxConcurrentTasks)
|
||||
: 0,
|
||||
disableAutoProtection: normalizedDisableAutoProtection
|
||||
disableAutoProtection: normalizedDisableAutoProtection,
|
||||
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||
|
||||
@@ -6,11 +6,13 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const pricingService = require('../../services/pricingService')
|
||||
const config = require('../../../config/config')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -11,19 +11,14 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
const router = express.Router()
|
||||
|
||||
// 🤖 Gemini OAuth 账户管理
|
||||
function getDefaultRedirectUri(oauthProvider) {
|
||||
if (oauthProvider === 'antigravity') {
|
||||
return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462'
|
||||
}
|
||||
return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode'
|
||||
}
|
||||
|
||||
// 生成 Gemini OAuth 授权 URL
|
||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { state, proxy, 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}`)
|
||||
|
||||
@@ -31,9 +26,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
authUrl,
|
||||
state: authState,
|
||||
codeVerifier,
|
||||
redirectUri: finalRedirectUri,
|
||||
oauthProvider: resolvedOauthProvider
|
||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
|
||||
redirectUri: finalRedirectUri
|
||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
|
||||
|
||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||
const sessionId = authState
|
||||
@@ -43,7 +37,6 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
redirectUri: finalRedirectUri,
|
||||
codeVerifier, // 保存 PKCE code verifier
|
||||
proxy: proxy || null, // 保存代理配置
|
||||
oauthProvider: resolvedOauthProvider,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
@@ -52,8 +45,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
success: true,
|
||||
data: {
|
||||
authUrl,
|
||||
sessionId,
|
||||
oauthProvider: resolvedOauthProvider
|
||||
sessionId
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -88,14 +80,13 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||
// 交换 Gemini 授权码
|
||||
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
|
||||
let resolvedOauthProvider = oauthProvider
|
||||
const { code, sessionId, proxy: requestProxy } = req.body
|
||||
|
||||
if (!code) {
|
||||
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 proxyConfig = null
|
||||
|
||||
@@ -106,16 +97,11 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
const {
|
||||
redirectUri: sessionRedirectUri,
|
||||
codeVerifier: sessionCodeVerifier,
|
||||
proxy,
|
||||
oauthProvider: sessionOauthProvider
|
||||
proxy
|
||||
} = sessionData
|
||||
redirectUri = sessionRedirectUri || redirectUri
|
||||
codeVerifier = sessionCodeVerifier
|
||||
proxyConfig = proxy // 获取代理配置
|
||||
if (!resolvedOauthProvider && sessionOauthProvider) {
|
||||
// 会话里保存的 provider 仅作为兜底
|
||||
resolvedOauthProvider = sessionOauthProvider
|
||||
}
|
||||
logger.info(
|
||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
||||
)
|
||||
@@ -134,8 +120,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
proxyConfig, // 传递代理配置
|
||||
resolvedOauthProvider
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 清理 OAuth 会话
|
||||
@@ -144,7 +129,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
||||
|
||||
@@ -24,6 +24,7 @@ const usageStatsRoutes = require('./usageStats')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -39,6 +40,7 @@ router.use('/', usageStatsRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
460
src/routes/admin/sync.js
Normal file
460
src/routes/admin/sync.js
Normal 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
|
||||
@@ -12,12 +12,14 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
const {
|
||||
handleAnthropicMessagesToGemini,
|
||||
handleAnthropicCountTokensToGemini
|
||||
} = require('../services/anthropicGeminiBridgeService')
|
||||
isWarmupRequest,
|
||||
buildMockWarmupResponse,
|
||||
sendMockWarmupStream
|
||||
} = require('../utils/warmupInterceptor')
|
||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||
const router = express.Router()
|
||||
|
||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||
@@ -115,6 +117,20 @@ async function handleMessagesRequest(req, res) {
|
||||
try {
|
||||
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对象存储状态)
|
||||
if (req._concurrencyRetryAttempted === undefined) {
|
||||
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
|
||||
|
||||
@@ -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') {
|
||||
// 官方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
|
||||
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
|
||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
||||
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
||||
const model = usageBaseModel || rawModel
|
||||
const { baseModel } = parseVendorPrefixedModel(rawModel)
|
||||
const model = baseModel || rawModel
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const { accountId: responseAccountId } = response
|
||||
@@ -1197,66 +1201,6 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
|
||||
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
// Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表
|
||||
//(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
if (forcedVendor === 'antigravity') {
|
||||
const permissions = req.apiKey?.permissions || 'all'
|
||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: '此 API Key 无权访问 Gemini 服务'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
|
||||
let accountSelection
|
||||
try {
|
||||
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
null,
|
||||
null,
|
||||
{ oauthProvider: 'antigravity' }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to select Gemini OAuth account (antigravity models):', error)
|
||||
return res.status(503).json({ error: 'No available Gemini OAuth accounts' })
|
||||
}
|
||||
|
||||
const account = await geminiAccountService.getAccount(accountSelection.accountId)
|
||||
if (!account) {
|
||||
return res.status(503).json({ error: 'Gemini OAuth account not found' })
|
||||
}
|
||||
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig =
|
||||
typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const models = await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||
account.accessToken,
|
||||
proxyConfig,
|
||||
account.refreshToken
|
||||
)
|
||||
|
||||
// 可选:根据 API Key 的模型限制过滤(黑名单语义)
|
||||
let filteredModels = models
|
||||
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
||||
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
|
||||
}
|
||||
|
||||
return res.json({ object: 'list', data: filteredModels })
|
||||
}
|
||||
|
||||
const modelService = require('../services/modelService')
|
||||
|
||||
// 从 modelService 获取所有支持的模型
|
||||
@@ -1393,22 +1337,6 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
|
||||
|
||||
// 🔢 Token计数端点 - count_tokens beta API
|
||||
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||
const forcedVendor = req._anthropicVendor || null
|
||||
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||
const permissions = req.apiKey?.permissions || 'all'
|
||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'permission_error',
|
||||
message: 'This API key does not have permission to access Gemini'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (
|
||||
req.apiKey.permissions &&
|
||||
@@ -1465,9 +1393,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
const maxAttempts = 2
|
||||
let attempt = 0
|
||||
|
||||
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
|
||||
const processRequest = async () => {
|
||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
@@ -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.handleMessagesRequest = handleMessagesRequest
|
||||
|
||||
@@ -19,16 +19,6 @@ function generateSessionHash(req) {
|
||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
||||
}
|
||||
|
||||
function ensureAntigravityProjectId(account) {
|
||||
if (account.projectId) {
|
||||
return account.projectId
|
||||
}
|
||||
if (account.tempProjectId) {
|
||||
return account.tempProjectId
|
||||
}
|
||||
return `ag-${crypto.randomBytes(8).toString('hex')}`
|
||||
}
|
||||
|
||||
// 检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData.permissions || 'all'
|
||||
@@ -345,48 +335,25 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const client = await geminiAccountService.getOauthClient(
|
||||
account.accessToken,
|
||||
account.refreshToken,
|
||||
proxyConfig,
|
||||
account.oauthProvider
|
||||
proxyConfig
|
||||
)
|
||||
if (actualStream) {
|
||||
// 流式响应
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
let { projectId } = account
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
projectId = ensureAntigravityProjectId(account)
|
||||
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||
account.tempProjectId = projectId
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('StreamGenerateContent request', {
|
||||
model,
|
||||
projectId,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: apiKeyData.id
|
||||
})
|
||||
|
||||
const streamResponse =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentStreamAntigravity(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId,
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
: await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal, // 传递中止信号
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 设置流式响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
@@ -592,41 +559,20 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
} else {
|
||||
// 非流式响应
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
let { projectId } = account
|
||||
|
||||
if (oauthProvider === 'antigravity') {
|
||||
projectId = ensureAntigravityProjectId(account)
|
||||
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||
account.tempProjectId = projectId
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('GenerateContent request', {
|
||||
model,
|
||||
projectId,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: apiKeyData.id
|
||||
})
|
||||
|
||||
const response =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.generateContentAntigravity(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId,
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
: await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: geminiRequestBody },
|
||||
null, // user_prompt_id
|
||||
account.projectId, // 使用有权限的项目ID
|
||||
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig // 传递代理配置
|
||||
)
|
||||
|
||||
// 转换为 OpenAI 格式并返回
|
||||
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false)
|
||||
@@ -658,15 +604,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const duration = Date.now() - startTime
|
||||
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
||||
} catch (error) {
|
||||
const statusForLog = error?.status || error?.response?.status
|
||||
logger.error('OpenAI-Gemini request error', {
|
||||
message: error?.message,
|
||||
status: statusForLog,
|
||||
code: error?.code,
|
||||
requestUrl: error?.config?.url,
|
||||
requestMethod: error?.config?.method,
|
||||
upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id']
|
||||
})
|
||||
logger.error('OpenAI-Gemini request error:', error)
|
||||
|
||||
// 处理速率限制
|
||||
if (error.status === 429) {
|
||||
@@ -727,21 +665,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
let models = []
|
||||
|
||||
if (account) {
|
||||
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
|
||||
try {
|
||||
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||
models =
|
||||
oauthProvider === 'antigravity'
|
||||
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||
account.accessToken,
|
||||
account.proxy,
|
||||
account.refreshToken
|
||||
)
|
||||
: await getAvailableModels(account.accessToken, account.proxy)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error)
|
||||
models = []
|
||||
}
|
||||
// 获取实际的模型列表
|
||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
||||
} else {
|
||||
// 返回默认模型列表
|
||||
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) {
|
||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||
|
||||
420
src/services/accountTestSchedulerService.js
Normal file
420
src/services/accountTestSchedulerService.js
Normal 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
@@ -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 模型移除 maxOutputTokens(Antigravity 环境不稳定)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -91,7 +91,9 @@ class ClaudeAccountService {
|
||||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||
unifiedClientId = '', // 统一的客户端标识
|
||||
expiresAt = null, // 账户订阅到期时间
|
||||
extInfo = null // 额外扩展信息
|
||||
extInfo = null, // 额外扩展信息
|
||||
maxConcurrency = 0, // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -136,7 +138,11 @@ class ClaudeAccountService {
|
||||
// 账户订阅到期时间
|
||||
subscriptionExpiresAt: expiresAt || '',
|
||||
// 扩展信息
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
}
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
@@ -168,7 +174,11 @@ class ClaudeAccountService {
|
||||
// 账户订阅到期时间
|
||||
subscriptionExpiresAt: expiresAt || '',
|
||||
// 扩展信息
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +226,8 @@ class ClaudeAccountService {
|
||||
useUnifiedUserAgent,
|
||||
useUnifiedClientId,
|
||||
unifiedClientId,
|
||||
extInfo: normalizedExtInfo
|
||||
extInfo: normalizedExtInfo,
|
||||
interceptWarmup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,7 +585,11 @@ class ClaudeAccountService {
|
||||
// 添加停止原因
|
||||
stoppedReason: account.stoppedReason || null,
|
||||
// 扩展信息
|
||||
extInfo: parsedExtInfo
|
||||
extInfo: parsedExtInfo,
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: account.interceptWarmup === 'true'
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -666,7 +681,9 @@ class ClaudeAccountService {
|
||||
'useUnifiedClientId',
|
||||
'unifiedClientId',
|
||||
'subscriptionExpiresAt',
|
||||
'extInfo'
|
||||
'extInfo',
|
||||
'maxConcurrency',
|
||||
'interceptWarmup'
|
||||
]
|
||||
const updatedData = { ...accountData }
|
||||
let shouldClearAutoStopFields = false
|
||||
@@ -681,7 +698,7 @@ class ClaudeAccountService {
|
||||
updatedData[field] = this._encryptSensitiveData(value)
|
||||
} else if (field === 'proxy') {
|
||||
updatedData[field] = value ? JSON.stringify(value) : ''
|
||||
} else if (field === 'priority') {
|
||||
} else if (field === 'priority' || field === 'maxConcurrency') {
|
||||
updatedData[field] = value.toString()
|
||||
} else if (field === 'subscriptionInfo') {
|
||||
// 处理订阅信息更新
|
||||
|
||||
@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '', // 因额度停用的时间
|
||||
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
|
||||
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
|
||||
quotaStoppedAt: null,
|
||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||
disableAutoProtection, // 新增:返回自动防护开关
|
||||
interceptWarmup, // 新增:返回预热请求拦截开关
|
||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||
}
|
||||
}
|
||||
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
|
||||
// 并发控制相关
|
||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||
activeTaskCount,
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true',
|
||||
// 拦截预热请求
|
||||
interceptWarmup: accountData.interceptWarmup === 'true'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||
}
|
||||
if (updates.interceptWarmup !== undefined) {
|
||||
updatedData.interceptWarmup = updates.interceptWarmup.toString()
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||
|
||||
@@ -210,7 +210,17 @@ class ClaudeRelayService {
|
||||
logger.error('❌ accountId missing for queue lock in relayRequest')
|
||||
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) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
@@ -323,17 +333,46 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||
const response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
options
|
||||
)
|
||||
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||
let retryCount = 0
|
||||
let response
|
||||
let shouldRetry = false
|
||||
|
||||
do {
|
||||
response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
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),不是请求完成时刻
|
||||
@@ -398,9 +437,10 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
// 检查是否为403状态码(禁止访问)
|
||||
// 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果
|
||||
else if (response.statusCode === 403) {
|
||||
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)
|
||||
}
|
||||
@@ -1314,7 +1354,17 @@ class ClaudeRelayService {
|
||||
logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture')
|
||||
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) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
@@ -1497,8 +1547,10 @@ class ClaudeRelayService {
|
||||
streamTransformer = null,
|
||||
requestOptions = {},
|
||||
isDedicatedOfficialAccount = false,
|
||||
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
retryCount = 0 // 🔄 403 重试计数器
|
||||
) {
|
||||
const maxRetries = 2 // 最大重试次数
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
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 () => {
|
||||
if (res.statusCode === 401) {
|
||||
@@ -1634,8 +1731,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
}
|
||||
} else if (res.statusCode === 403) {
|
||||
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
|
||||
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
|
||||
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)
|
||||
} 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)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
|
||||
async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
|
||||
const testRequestBody = createClaudeTestPayload(model, { stream: true })
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(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) {
|
||||
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() {
|
||||
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()
|
||||
|
||||
@@ -16,62 +16,11 @@ const {
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
|
||||
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
|
||||
const OAUTH_PROVIDERS = {
|
||||
[OAUTH_PROVIDER_GEMINI_CLI]: {
|
||||
// Gemini CLI OAuth 配置(公开)
|
||||
clientId:
|
||||
process.env.GEMINI_OAUTH_CLIENT_ID ||
|
||||
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
|
||||
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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',
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform']
|
||||
},
|
||||
[OAUTH_PROVIDER_ANTIGRAVITY]: {
|
||||
// Antigravity OAuth 配置(参考 gcli2api)
|
||||
clientId:
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_ID ||
|
||||
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
|
||||
clientSecret:
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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',
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) {
|
||||
logger.warn(
|
||||
'⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||
)
|
||||
}
|
||||
if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) {
|
||||
logger.warn(
|
||||
'⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeOauthProvider(oauthProvider) {
|
||||
if (!oauthProvider) {
|
||||
return OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
|
||||
? OAUTH_PROVIDER_ANTIGRAVITY
|
||||
: OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
|
||||
function getOauthProviderConfig(oauthProvider) {
|
||||
const normalized = normalizeOauthProvider(oauthProvider)
|
||||
return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI]
|
||||
}
|
||||
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
||||
const OAUTH_CLIENT_SECRET = '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'
|
||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
|
||||
// 🌐 TCP Keep-Alive Agent 配置
|
||||
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
||||
@@ -85,117 +34,6 @@ const keepAliveAgent = new https.Agent({
|
||||
|
||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||
|
||||
async function fetchAvailableModelsAntigravity(
|
||||
accessToken,
|
||||
proxyConfig = null,
|
||||
refreshToken = null
|
||||
) {
|
||||
try {
|
||||
let effectiveToken = accessToken
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const client = await getOauthClient(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
proxyConfig,
|
||||
OAUTH_PROVIDER_ANTIGRAVITY
|
||||
)
|
||||
if (client && client.getAccessToken) {
|
||||
const latest = await client.getAccessToken()
|
||||
if (latest?.token) {
|
||||
effectiveToken = latest.token
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refresh Antigravity access token for models list:', {
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const data = await antigravityClient.fetchAvailableModels({
|
||||
accessToken: effectiveToken,
|
||||
proxyConfig
|
||||
})
|
||||
const modelsDict = data?.models
|
||||
const created = Math.floor(Date.now() / 1000)
|
||||
|
||||
const models = []
|
||||
const seen = new Set()
|
||||
const {
|
||||
getAntigravityModelAlias,
|
||||
getAntigravityModelMetadata,
|
||||
normalizeAntigravityModelInput
|
||||
} = require('../utils/antigravityModel')
|
||||
|
||||
const pushModel = (modelId) => {
|
||||
if (!modelId || seen.has(modelId)) {
|
||||
return
|
||||
}
|
||||
seen.add(modelId)
|
||||
const metadata = getAntigravityModelMetadata(modelId)
|
||||
const entry = {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
created,
|
||||
owned_by: 'antigravity'
|
||||
}
|
||||
if (metadata?.name) {
|
||||
entry.name = metadata.name
|
||||
}
|
||||
if (metadata?.maxCompletionTokens) {
|
||||
entry.max_completion_tokens = metadata.maxCompletionTokens
|
||||
}
|
||||
if (metadata?.thinking) {
|
||||
entry.thinking = metadata.thinking
|
||||
}
|
||||
models.push(entry)
|
||||
}
|
||||
|
||||
if (modelsDict && typeof modelsDict === 'object') {
|
||||
for (const modelId of Object.keys(modelsDict)) {
|
||||
const normalized = normalizeAntigravityModelInput(modelId)
|
||||
const alias = getAntigravityModelAlias(normalized)
|
||||
if (!alias) {
|
||||
continue
|
||||
}
|
||||
pushModel(alias)
|
||||
|
||||
if (alias.endsWith('-thinking')) {
|
||||
pushModel(alias.replace(/-thinking$/, ''))
|
||||
}
|
||||
|
||||
if (alias.startsWith('gemini-claude-')) {
|
||||
pushModel(alias.replace(/^gemini-/, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return models
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message)
|
||||
return [
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'antigravity'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function countTokensAntigravity(client, contents, model, proxyConfig = null) {
|
||||
const { token } = await client.getAccessToken()
|
||||
const response = await antigravityClient.countTokens({
|
||||
accessToken: token,
|
||||
proxyConfig,
|
||||
contents,
|
||||
model
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||
@@ -286,15 +124,14 @@ setInterval(
|
||||
)
|
||||
|
||||
// 创建 OAuth2 客户端(支持代理配置)
|
||||
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) {
|
||||
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
||||
// 如果没有提供 redirectUri,使用默认值
|
||||
const uri = redirectUri || 'http://localhost:45462'
|
||||
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||
|
||||
// 准备客户端选项
|
||||
const clientOptions = {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
redirectUri: uri
|
||||
}
|
||||
|
||||
@@ -315,17 +152,10 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvide
|
||||
}
|
||||
|
||||
// 生成授权 URL (支持 PKCE 和代理)
|
||||
async function generateAuthUrl(
|
||||
state = null,
|
||||
redirectUri = null,
|
||||
proxyConfig = null,
|
||||
oauthProvider = null
|
||||
) {
|
||||
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
|
||||
// 使用新的 redirect URI
|
||||
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider)
|
||||
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
|
||||
|
||||
if (proxyConfig) {
|
||||
logger.info(
|
||||
@@ -342,7 +172,7 @@ async function generateAuthUrl(
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
redirect_uri: finalRedirectUri,
|
||||
access_type: 'offline',
|
||||
scope: oauthConfig.scopes,
|
||||
scope: OAUTH_SCOPES,
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: codeVerifier.codeChallenge,
|
||||
state: stateValue,
|
||||
@@ -353,8 +183,7 @@ async function generateAuthUrl(
|
||||
authUrl,
|
||||
state: stateValue,
|
||||
codeVerifier: codeVerifier.codeVerifier,
|
||||
redirectUri: finalRedirectUri,
|
||||
oauthProvider: normalizedProvider
|
||||
redirectUri: finalRedirectUri
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,14 +244,11 @@ async function exchangeCodeForTokens(
|
||||
code,
|
||||
redirectUri = null,
|
||||
codeVerifier = null,
|
||||
proxyConfig = null,
|
||||
oauthProvider = null
|
||||
proxyConfig = null
|
||||
) {
|
||||
try {
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
// 创建带代理配置的 OAuth2Client
|
||||
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider)
|
||||
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
|
||||
|
||||
if (proxyConfig) {
|
||||
logger.info(
|
||||
@@ -448,7 +274,7 @@ async function exchangeCodeForTokens(
|
||||
return {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
scope: tokens.scope || oauthConfig.scopes.join(' '),
|
||||
scope: tokens.scope || OAUTH_SCOPES.join(' '),
|
||||
token_type: tokens.token_type || 'Bearer',
|
||||
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) {
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
||||
// 创建带代理配置的 OAuth2Client
|
||||
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||
const oAuth2Client = createOAuth2Client(null, proxyConfig)
|
||||
|
||||
try {
|
||||
// 设置 refresh_token
|
||||
@@ -495,7 +319,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvide
|
||||
return {
|
||||
access_token: credentials.access_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',
|
||||
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
||||
}
|
||||
@@ -515,8 +339,6 @@ async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvide
|
||||
async function createAccount(accountData) {
|
||||
const id = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||
|
||||
// 处理凭证数据
|
||||
let geminiOauth = null
|
||||
@@ -549,7 +371,7 @@ async function createAccount(accountData) {
|
||||
geminiOauth = JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
scope: accountData.scope || oauthConfig.scopes.join(' '),
|
||||
scope: accountData.scope || OAUTH_SCOPES.join(' '),
|
||||
token_type: accountData.tokenType || 'Bearer',
|
||||
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
||||
})
|
||||
@@ -577,8 +399,7 @@ async function createAccount(accountData) {
|
||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
||||
// 只有OAuth方式才有scopes,手动添加的没有
|
||||
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '',
|
||||
oauthProvider,
|
||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
||||
|
||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||
@@ -687,10 +508,6 @@ async function updateAccount(accountId, updates) {
|
||||
updates.schedulable = updates.schedulable.toString()
|
||||
}
|
||||
|
||||
if (updates.oauthProvider !== undefined) {
|
||||
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
|
||||
}
|
||||
|
||||
// 加密敏感字段
|
||||
if (updates.geminiOauth) {
|
||||
updates.geminiOauth = encrypt(
|
||||
@@ -1068,13 +885,12 @@ async function refreshAccountToken(accountId) {
|
||||
// 重新获取账户数据(可能已被其他进程刷新)
|
||||
const updatedAccount = await getAccount(accountId)
|
||||
if (updatedAccount && updatedAccount.accessToken) {
|
||||
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
|
||||
const accessToken = decrypt(updatedAccount.accessToken)
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
||||
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
|
||||
scope: updatedAccount.scopes || oauthConfig.scopes.join(' '),
|
||||
scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
}
|
||||
@@ -1088,11 +904,7 @@ async function refreshAccountToken(accountId) {
|
||||
|
||||
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
||||
// 传入账户的代理配置
|
||||
const newTokens = await refreshAccessToken(
|
||||
account.refreshToken,
|
||||
account.proxy,
|
||||
account.oauthProvider
|
||||
)
|
||||
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
|
||||
|
||||
// 更新账户信息
|
||||
const updates = {
|
||||
@@ -1224,15 +1036,14 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
}
|
||||
|
||||
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
||||
const client = createOAuth2Client(null, proxyConfig)
|
||||
|
||||
const creds = {
|
||||
access_token: accessToken,
|
||||
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',
|
||||
expiry_date: 1754269905646
|
||||
}
|
||||
@@ -1698,43 +1509,6 @@ async function generateContent(
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 调用 Antigravity 上游生成内容(非流式)
|
||||
async function generateContentAntigravity(
|
||||
client,
|
||||
requestData,
|
||||
userPromptId,
|
||||
projectId = null,
|
||||
sessionId = null,
|
||||
proxyConfig = null
|
||||
) {
|
||||
const { token } = await client.getAccessToken()
|
||||
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId
|
||||
})
|
||||
|
||||
logger.info('🪐 Antigravity generateContent API调用开始', {
|
||||
model,
|
||||
userPromptId,
|
||||
projectId,
|
||||
sessionId
|
||||
})
|
||||
|
||||
const { response } = await antigravityClient.request({
|
||||
accessToken: token,
|
||||
proxyConfig,
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId,
|
||||
stream: false
|
||||
})
|
||||
logger.info('✅ Antigravity generateContent API调用成功')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 调用 Code Assist API 生成内容(流式)
|
||||
async function generateContentStream(
|
||||
client,
|
||||
@@ -1819,46 +1593,6 @@ async function generateContentStream(
|
||||
return response.data // 返回流对象
|
||||
}
|
||||
|
||||
// 调用 Antigravity 上游生成内容(流式)
|
||||
async function generateContentStreamAntigravity(
|
||||
client,
|
||||
requestData,
|
||||
userPromptId,
|
||||
projectId = null,
|
||||
sessionId = null,
|
||||
signal = null,
|
||||
proxyConfig = null
|
||||
) {
|
||||
const { token } = await client.getAccessToken()
|
||||
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId
|
||||
})
|
||||
|
||||
logger.info('🌊 Antigravity streamGenerateContent API调用开始', {
|
||||
model,
|
||||
userPromptId,
|
||||
projectId,
|
||||
sessionId
|
||||
})
|
||||
|
||||
const { response } = await antigravityClient.request({
|
||||
accessToken: token,
|
||||
proxyConfig,
|
||||
requestData,
|
||||
projectId,
|
||||
sessionId,
|
||||
userPromptId,
|
||||
stream: true,
|
||||
signal,
|
||||
params: { alt: 'sse' }
|
||||
})
|
||||
logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 更新账户的临时项目 ID
|
||||
async function updateTempProjectId(accountId, tempProjectId) {
|
||||
if (!tempProjectId) {
|
||||
@@ -1953,12 +1687,10 @@ module.exports = {
|
||||
generateEncryptionKey,
|
||||
decryptCache, // 暴露缓存对象以便测试和监控
|
||||
countTokens,
|
||||
countTokensAntigravity,
|
||||
generateContent,
|
||||
generateContentStream,
|
||||
generateContentAntigravity,
|
||||
generateContentStreamAntigravity,
|
||||
fetchAvailableModelsAntigravity,
|
||||
updateTempProjectId,
|
||||
resetAccountStatus
|
||||
resetAccountStatus,
|
||||
OAUTH_CLIENT_ID,
|
||||
OAUTH_SCOPES
|
||||
}
|
||||
|
||||
@@ -4,35 +4,11 @@ const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY]
|
||||
|
||||
function normalizeOauthProvider(oauthProvider) {
|
||||
if (!oauthProvider) {
|
||||
return OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
|
||||
? OAUTH_PROVIDER_ANTIGRAVITY
|
||||
: OAUTH_PROVIDER_GEMINI_CLI
|
||||
}
|
||||
|
||||
class UnifiedGeminiScheduler {
|
||||
constructor() {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'
|
||||
}
|
||||
|
||||
_getSessionMappingKey(sessionHash, oauthProvider = null) {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
if (!oauthProvider) {
|
||||
return `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
}
|
||||
const normalized = normalizeOauthProvider(oauthProvider)
|
||||
return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}`
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
@@ -56,8 +32,7 @@ class UnifiedGeminiScheduler {
|
||||
requestedModel = null,
|
||||
options = {}
|
||||
) {
|
||||
const { allowApiAccounts = false, oauthProvider = null } = options
|
||||
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
|
||||
const { allowApiAccounts = false } = options
|
||||
|
||||
try {
|
||||
// 如果API Key绑定了专属账户或分组,优先使用
|
||||
@@ -108,23 +83,14 @@ class UnifiedGeminiScheduler {
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
|
||||
) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Gemini OAuth account ${boundAccount.name} oauthProvider=${normalizeOauthProvider(boundAccount.oauthProvider)} does not match requested oauthProvider=${normalizedOauthProvider}, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
// 更新账户的最后使用时间
|
||||
await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId)
|
||||
return {
|
||||
accountId: apiKeyData.geminiAccountId,
|
||||
accountType: 'gemini'
|
||||
}
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
// 更新账户的最后使用时间
|
||||
await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId)
|
||||
return {
|
||||
accountId: apiKeyData.geminiAccountId,
|
||||
accountType: 'gemini'
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
@@ -136,7 +102,7 @@ class UnifiedGeminiScheduler {
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider)
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
@@ -145,7 +111,7 @@ class UnifiedGeminiScheduler {
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -166,10 +132,11 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 获取所有可用账户
|
||||
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, {
|
||||
allowApiAccounts,
|
||||
oauthProvider: normalizedOauthProvider
|
||||
})
|
||||
const availableAccounts = await this._getAllAvailableAccounts(
|
||||
apiKeyData,
|
||||
requestedModel,
|
||||
allowApiAccounts
|
||||
)
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
// 提供更详细的错误信息
|
||||
@@ -193,8 +160,7 @@ class UnifiedGeminiScheduler {
|
||||
await this._setSessionMapping(
|
||||
sessionHash,
|
||||
selectedAccount.accountId,
|
||||
selectedAccount.accountType,
|
||||
normalizedOauthProvider
|
||||
selectedAccount.accountType
|
||||
)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
|
||||
@@ -223,18 +189,7 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 📋 获取所有可用账户
|
||||
async _getAllAvailableAccounts(
|
||||
apiKeyData,
|
||||
requestedModel = null,
|
||||
allowApiAccountsOrOptions = false
|
||||
) {
|
||||
const options =
|
||||
allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object'
|
||||
? allowApiAccountsOrOptions
|
||||
: { allowApiAccounts: allowApiAccountsOrOptions }
|
||||
const { allowApiAccounts = false, oauthProvider = null } = options
|
||||
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
|
||||
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) {
|
||||
const availableAccounts = []
|
||||
|
||||
// 如果API Key绑定了专属账户,优先返回
|
||||
@@ -299,12 +254,6 @@ class UnifiedGeminiScheduler {
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
|
||||
) {
|
||||
return availableAccounts
|
||||
}
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (!isRateLimited) {
|
||||
// 检查模型支持
|
||||
@@ -354,12 +303,6 @@ class UnifiedGeminiScheduler {
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
if (
|
||||
normalizedOauthProvider &&
|
||||
normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// 检查是否可调度
|
||||
|
||||
// 检查token是否过期
|
||||
@@ -494,10 +437,9 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 🔗 获取会话映射
|
||||
async _getSessionMapping(sessionHash, oauthProvider = null) {
|
||||
async _getSessionMapping(sessionHash) {
|
||||
const client = redis.getClientSafe()
|
||||
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||
const mappingData = key ? await client.get(key) : null
|
||||
const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
|
||||
if (mappingData) {
|
||||
try {
|
||||
@@ -512,42 +454,27 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 💾 设置会话映射
|
||||
async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) {
|
||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
await client.setex(key, ttlSeconds, mappingData)
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
async _deleteSessionMapping(sessionHash) {
|
||||
const client = redis.getClientSafe()
|
||||
if (!sessionHash) {
|
||||
return
|
||||
}
|
||||
|
||||
const keys = [this._getSessionMappingKey(sessionHash)]
|
||||
for (const provider of KNOWN_OAUTH_PROVIDERS) {
|
||||
keys.push(this._getSessionMappingKey(sessionHash, provider))
|
||||
}
|
||||
await client.del(keys.filter(Boolean))
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash, oauthProvider = null) {
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
if (remainingTTL === -2) {
|
||||
|
||||
@@ -121,12 +121,23 @@ class UserMessageQueueService {
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} requestId - 请求ID(可选,会自动生成)
|
||||
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
|
||||
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
|
||||
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用,=0使用全局配置
|
||||
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
|
||||
*/
|
||||
async acquireQueueLock(accountId, requestId = null, timeoutMs = null) {
|
||||
async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
|
||||
const cfg = await this.getConfig()
|
||||
|
||||
if (!cfg.enabled) {
|
||||
// 账户级配置优先:maxConcurrency > 0 时强制启用,忽略全局开关
|
||||
let queueEnabled = cfg.enabled
|
||||
if (accountConfig && accountConfig.maxConcurrency > 0) {
|
||||
queueEnabled = true
|
||||
logger.debug(
|
||||
`📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})`
|
||||
)
|
||||
}
|
||||
|
||||
if (!queueEnabled) {
|
||||
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -55,69 +55,16 @@ function sanitizeUpstreamError(errorData) {
|
||||
return errorData
|
||||
}
|
||||
|
||||
// AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息
|
||||
const looksLikeAxiosError =
|
||||
errorData.isAxiosError ||
|
||||
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
|
||||
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
|
||||
|
||||
if (looksLikeAxiosError || looksLikeError) {
|
||||
const statusCode = errorData.response?.status
|
||||
const upstreamBody = errorData.response?.data
|
||||
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '')
|
||||
|
||||
return {
|
||||
name: errorData.name || 'Error',
|
||||
code: errorData.code,
|
||||
statusCode,
|
||||
message: sanitizeErrorMessage(errorData.message || ''),
|
||||
upstreamMessage: upstreamMessage || undefined,
|
||||
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
|
||||
}
|
||||
}
|
||||
// 深拷贝避免修改原始对象
|
||||
const sanitized = JSON.parse(JSON.stringify(errorData))
|
||||
|
||||
// 递归清理嵌套的错误对象
|
||||
const visited = new WeakSet()
|
||||
|
||||
const shouldRedactKey = (key) => {
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
const lowerKey = String(key).toLowerCase()
|
||||
return (
|
||||
lowerKey === 'authorization' ||
|
||||
lowerKey === 'cookie' ||
|
||||
lowerKey.includes('api_key') ||
|
||||
lowerKey.includes('apikey') ||
|
||||
lowerKey.includes('access_token') ||
|
||||
lowerKey.includes('refresh_token') ||
|
||||
lowerKey.endsWith('token') ||
|
||||
lowerKey.includes('secret') ||
|
||||
lowerKey.includes('password')
|
||||
)
|
||||
}
|
||||
|
||||
const sanitizeObject = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (visited.has(obj)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
visited.add(obj)
|
||||
|
||||
// 主动剔除常见“超大且敏感”的字段
|
||||
if (obj.config || obj.request || obj.response) {
|
||||
return '[Redacted]'
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
if (shouldRedactKey(key)) {
|
||||
obj[key] = '[REDACTED]'
|
||||
continue
|
||||
}
|
||||
|
||||
// 清理所有字符串字段,不仅仅是 message
|
||||
if (typeof obj[key] === 'string') {
|
||||
obj[key] = sanitizeErrorMessage(obj[key])
|
||||
@@ -129,9 +76,7 @@ function sanitizeUpstreamError(errorData) {
|
||||
return obj
|
||||
}
|
||||
|
||||
// 尽量不修改原对象:浅拷贝后递归清理
|
||||
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
|
||||
return sanitizeObject(clone)
|
||||
return sanitizeObject(sanitized)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {} }
|
||||
}
|
||||
|
||||
// $ref:Gemini/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
|
||||
}
|
||||
}
|
||||
|
||||
// type(flatten 数组 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(', '))
|
||||
}
|
||||
|
||||
// additionalProperties:Gemini/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
|
||||
}
|
||||
@@ -5,10 +5,6 @@
|
||||
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
||||
*/
|
||||
|
||||
// 仅保留原仓库既有的模型前缀:CCR 路由
|
||||
// Gemini/Antigravity 采用“路径分流”,避免在 model 字段里混入 vendor 前缀造成混乱
|
||||
const SUPPORTED_VENDOR_PREFIXES = ['ccr']
|
||||
|
||||
/**
|
||||
* Parse vendor-prefixed model string
|
||||
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
||||
@@ -23,21 +19,16 @@ function parseVendorPrefixedModel(modelStr) {
|
||||
const trimmed = modelStr.trim()
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
|
||||
for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) {
|
||||
if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for ccr prefix (case insensitive)
|
||||
if (lowerTrimmed.startsWith('ccr,')) {
|
||||
const parts = trimmed.split(',')
|
||||
if (parts.length < 2) {
|
||||
break
|
||||
}
|
||||
|
||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||
const baseModel = parts.slice(1).join(',').trim()
|
||||
return {
|
||||
vendor: vendorPrefix,
|
||||
baseModel
|
||||
if (parts.length >= 2) {
|
||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||
const baseModel = parts.slice(1).join(',').trim()
|
||||
return {
|
||||
vendor: 'ccr',
|
||||
baseModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
const path = require('path')
|
||||
|
||||
// 该文件位于 src/utils 下,向上两级即项目根目录。
|
||||
function getProjectRoot() {
|
||||
return path.resolve(__dirname, '..', '..')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProjectRoot
|
||||
}
|
||||
202
src/utils/warmupInterceptor.js
Normal file
202
src/utils/warmupInterceptor.js
Normal 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
|
||||
}
|
||||
@@ -62,17 +62,12 @@ class ClaudeCodeValidator {
|
||||
|
||||
for (const entry of systemEntries) {
|
||||
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)
|
||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||
if (bestScore < threshold) {
|
||||
logger.error(
|
||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||
)
|
||||
const preview = typeof maskedRaw === 'string' ? maskedRaw.slice(0, 200) : ''
|
||||
logger.warn(
|
||||
`Claude system prompt detail: templateId=${templateId || 'unknown'}, preview=${preview}${
|
||||
maskedRaw && maskedRaw.length > 200 ? '…' : ''
|
||||
}`
|
||||
)
|
||||
logger.warn(`Claude system prompt detail: ${rawText}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +125,8 @@ class CodexCliValidator {
|
||||
const part1 = parts1[i] || 0
|
||||
const part2 = parts2[i] || 0
|
||||
|
||||
if (part1 < part2) {
|
||||
return -1
|
||||
}
|
||||
if (part1 > part2) {
|
||||
return 1
|
||||
}
|
||||
if (part1 < part2) return -1
|
||||
if (part1 > part2) return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -53,7 +53,7 @@ class GeminiCliValidator {
|
||||
// 2. 对于 /gemini 路径,检查是否包含 generateContent
|
||||
if (path.includes('generateContent')) {
|
||||
// 包含 generateContent 的路径需要验证 User-Agent
|
||||
const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i
|
||||
const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
|
||||
if (!geminiCliPattern.test(userAgent)) {
|
||||
logger.debug(
|
||||
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
|
||||
@@ -84,12 +84,8 @@ class GeminiCliValidator {
|
||||
const part1 = parts1[i] || 0
|
||||
const part2 = parts2[i] || 0
|
||||
|
||||
if (part1 < part2) {
|
||||
return -1
|
||||
}
|
||||
if (part1 > part2) {
|
||||
return 1
|
||||
}
|
||||
if (part1 < part2) return -1
|
||||
if (part1 > part2) return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -477,36 +477,6 @@
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'gemini-antigravity'
|
||||
? 'border-purple-500 bg-purple-50 dark:border-purple-400 dark:bg-purple-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-purple-500 dark:hover:bg-purple-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="gemini-antigravity"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-rocket text-sm text-purple-600 dark:text-purple-400"></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Antigravity</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">OAuth</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'gemini-antigravity'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
@@ -802,7 +772,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
>项目 ID (可选)</label
|
||||
>
|
||||
@@ -1662,6 +1632,47 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Claude 账户级串行队列开关 -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.serialQueueEnabled"
|
||||
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
启用账户级串行队列
|
||||
</span>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 拦截预热请求开关(Claude 和 Claude Console) -->
|
||||
<div
|
||||
v-if="form.platform === 'claude' || form.platform === 'claude-console'"
|
||||
class="mt-4"
|
||||
>
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.interceptWarmup"
|
||||
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
拦截预热请求
|
||||
</span>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
启用后,对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Claude User-Agent 版本配置 -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
@@ -1813,7 +1824,7 @@
|
||||
Token,建议也一并填写以支持自动刷新。
|
||||
</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"
|
||||
>
|
||||
请输入有效的 Gemini Access Token。如果您有 Refresh
|
||||
@@ -1850,9 +1861,7 @@
|
||||
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
|
||||
</p>
|
||||
<p
|
||||
v-else-if="
|
||||
form.platform === 'gemini' || form.platform === 'gemini-antigravity'
|
||||
"
|
||||
v-else-if="form.platform === 'gemini'"
|
||||
class="text-xs text-blue-800 dark:text-blue-300"
|
||||
>
|
||||
请从已登录 Gemini CLI 的机器上获取
|
||||
@@ -2582,7 +2591,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
>项目 ID (可选)</label
|
||||
>
|
||||
@@ -2647,6 +2656,44 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Claude 账户级串行队列开关(编辑模式) -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.serialQueueEnabled"
|
||||
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
启用账户级串行队列
|
||||
</span>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 拦截预热请求开关(Claude 和 Claude Console 编辑模式) -->
|
||||
<div v-if="form.platform === 'claude' || form.platform === 'claude-console'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.interceptWarmup"
|
||||
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
拦截预热请求
|
||||
</span>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
启用后,对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Claude User-Agent 版本配置(编辑模式) -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
@@ -3833,7 +3880,7 @@ const determinePlatformGroup = (platform) => {
|
||||
return 'claude'
|
||||
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
|
||||
return 'openai'
|
||||
} else if (['gemini', 'gemini-antigravity', 'gemini-api'].includes(platform)) {
|
||||
} else if (['gemini', 'gemini-api'].includes(platform)) {
|
||||
return 'gemini'
|
||||
} else if (platform === 'droid') {
|
||||
return 'droid'
|
||||
@@ -3968,8 +4015,7 @@ const form = ref({
|
||||
platform: props.account?.platform || 'claude',
|
||||
addType: (() => {
|
||||
const platform = props.account?.platform || 'claude'
|
||||
if (platform === 'gemini' || platform === 'gemini-antigravity' || platform === 'openai')
|
||||
return 'oauth'
|
||||
if (platform === 'gemini' || platform === 'openai') return 'oauth'
|
||||
if (platform === 'claude') return 'oauth'
|
||||
return 'manual'
|
||||
})(),
|
||||
@@ -3982,6 +4028,9 @@ const form = ref({
|
||||
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
||||
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
||||
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
||||
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
|
||||
interceptWarmup:
|
||||
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
|
||||
groupId: '',
|
||||
groupIds: [],
|
||||
projectId: props.account?.projectId || '',
|
||||
@@ -4308,7 +4357,7 @@ const selectPlatformGroup = (group) => {
|
||||
} else if (group === 'openai') {
|
||||
form.value.platform = 'openai'
|
||||
} else if (group === 'gemini') {
|
||||
form.value.platform = 'gemini' // Default to Gemini CLI, user can select Antigravity
|
||||
form.value.platform = 'gemini'
|
||||
} else if (group === 'droid') {
|
||||
form.value.platform = 'droid'
|
||||
}
|
||||
@@ -4345,11 +4394,7 @@ const nextStep = async () => {
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目 ID
|
||||
if (
|
||||
(form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') &&
|
||||
oauthStep.value === 1 &&
|
||||
form.value.addType === 'oauth'
|
||||
) {
|
||||
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await showConfirm(
|
||||
@@ -4572,9 +4617,11 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
|
||||
claudeAiOauth: claudeOauthPayload,
|
||||
priority: form.value.priority || 50,
|
||||
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
||||
interceptWarmup: form.value.interceptWarmup || false,
|
||||
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
||||
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
||||
unifiedClientId: clientId,
|
||||
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
|
||||
subscriptionInfo: {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||
@@ -4712,6 +4759,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||
// 添加订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -4719,14 +4767,9 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||
manuallySet: true // 标记为手动设置
|
||||
}
|
||||
} else if (currentPlatform === 'gemini' || currentPlatform === 'gemini-antigravity') {
|
||||
// Gemini/Antigravity使用geminiOauth字段
|
||||
} else if (currentPlatform === 'gemini') {
|
||||
// Gemini使用geminiOauth字段
|
||||
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
||||
// 根据 platform 设置 oauthProvider
|
||||
data.oauthProvider =
|
||||
currentPlatform === 'gemini-antigravity'
|
||||
? 'antigravity'
|
||||
: tokenInfo.oauthProvider || 'gemini-cli'
|
||||
if (form.value.projectId) {
|
||||
data.projectId = form.value.projectId
|
||||
}
|
||||
@@ -5040,6 +5083,7 @@ const createAccount = async () => {
|
||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||
// 添加订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -5131,6 +5175,7 @@ const createAccount = async () => {
|
||||
// 上游错误处理(仅 Claude Console)
|
||||
if (form.value.platform === 'claude-console') {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
data.interceptWarmup = !!form.value.interceptWarmup
|
||||
}
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
@@ -5146,10 +5191,6 @@ const createAccount = async () => {
|
||||
data.rateLimitDuration = 60 // 默认值60,不从用户输入获取
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
} else if (form.value.platform === 'gemini-antigravity') {
|
||||
// Antigravity OAuth - set oauthProvider, submission happens below
|
||||
data.oauthProvider = 'antigravity'
|
||||
data.priority = form.value.priority || 50
|
||||
} else if (form.value.platform === 'gemini-api') {
|
||||
// Gemini API 账户特定数据
|
||||
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
|
||||
@@ -5201,7 +5242,7 @@ const createAccount = async () => {
|
||||
result = await accountsStore.createOpenAIAccount(data)
|
||||
} else if (form.value.platform === 'azure_openai') {
|
||||
result = await accountsStore.createAzureOpenAIAccount(data)
|
||||
} else if (form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') {
|
||||
} else if (form.value.platform === 'gemini') {
|
||||
result = await accountsStore.createGeminiAccount(data)
|
||||
} else if (form.value.platform === 'gemini-api') {
|
||||
result = await accountsStore.createGeminiApiAccount(data)
|
||||
@@ -5431,9 +5472,11 @@ const updateAccount = async () => {
|
||||
|
||||
data.priority = form.value.priority || 50
|
||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||
data.interceptWarmup = form.value.interceptWarmup || false
|
||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
|
||||
// 更新订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -5466,6 +5509,8 @@ const updateAccount = async () => {
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
// 拦截预热请求
|
||||
data.interceptWarmup = !!form.value.interceptWarmup
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
@@ -6034,9 +6079,12 @@ watch(
|
||||
accountType: newAccount.accountType || 'shared',
|
||||
subscriptionType: subscriptionType,
|
||||
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
||||
interceptWarmup:
|
||||
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
|
||||
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
||||
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
||||
unifiedClientId: newAccount.unifiedClientId || '',
|
||||
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
|
||||
groupId: groupId,
|
||||
groupIds: [],
|
||||
projectId: newAccount.projectId || '',
|
||||
|
||||
@@ -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>
|
||||
@@ -303,16 +303,6 @@
|
||||
请按照以下步骤完成 Gemini 账户的授权:
|
||||
</p>
|
||||
|
||||
<!-- 授权来源显示(由平台类型决定) -->
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-green-800 dark:text-green-300">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
授权类型:<span class="font-semibold">{{
|
||||
platform === 'gemini-antigravity' ? 'Antigravity OAuth' : 'Gemini CLI OAuth'
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div
|
||||
@@ -828,13 +818,6 @@ const exchanging = ref(false)
|
||||
const authUrl = ref('')
|
||||
const authCode = ref('')
|
||||
const copied = ref(false)
|
||||
// oauthProvider is now derived from platform prop
|
||||
const geminiOauthProvider = computed(() => {
|
||||
if (props.platform === 'gemini-antigravity') {
|
||||
return 'antigravity'
|
||||
}
|
||||
return 'gemini-cli'
|
||||
})
|
||||
const sessionId = ref('') // 保存sessionId用于后续交换
|
||||
const userCode = ref('')
|
||||
const verificationUri = ref('')
|
||||
@@ -938,11 +921,7 @@ watch(authCode, (newValue) => {
|
||||
console.error('Failed to parse URL:', error)
|
||||
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
|
||||
}
|
||||
} else if (
|
||||
props.platform === 'gemini' ||
|
||||
props.platform === 'gemini-antigravity' ||
|
||||
props.platform === 'openai'
|
||||
) {
|
||||
} else if (props.platform === 'gemini' || props.platform === 'openai') {
|
||||
// Gemini 和 OpenAI 平台可能使用不同的回调URL
|
||||
// 尝试从任何URL中提取code参数
|
||||
try {
|
||||
@@ -993,11 +972,8 @@ const generateAuthUrl = async () => {
|
||||
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
|
||||
authUrl.value = result.authUrl
|
||||
sessionId.value = result.sessionId
|
||||
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||
const result = await accountsStore.generateGeminiAuthUrl({
|
||||
...proxyConfig,
|
||||
oauthProvider: geminiOauthProvider.value
|
||||
})
|
||||
} else if (props.platform === 'gemini') {
|
||||
const result = await accountsStore.generateGeminiAuthUrl(proxyConfig)
|
||||
authUrl.value = result.authUrl
|
||||
sessionId.value = result.sessionId
|
||||
} else if (props.platform === 'openai') {
|
||||
@@ -1020,8 +996,6 @@ const generateAuthUrl = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// onGeminiOauthProviderChange removed - oauthProvider is now computed from platform
|
||||
|
||||
// 重新生成授权URL
|
||||
const regenerateAuthUrl = () => {
|
||||
stopCountdown()
|
||||
@@ -1105,12 +1079,11 @@ const exchangeCode = async () => {
|
||||
sessionId: sessionId.value,
|
||||
callbackUrl: authCode.value.trim()
|
||||
}
|
||||
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||
// Gemini/Antigravity使用code和sessionId
|
||||
} else if (props.platform === 'gemini') {
|
||||
// Gemini使用code和sessionId
|
||||
data = {
|
||||
code: authCode.value.trim(),
|
||||
sessionId: sessionId.value,
|
||||
oauthProvider: geminiOauthProvider.value
|
||||
sessionId: sessionId.value
|
||||
}
|
||||
} else if (props.platform === 'openai') {
|
||||
// OpenAI使用code和sessionId
|
||||
@@ -1138,12 +1111,8 @@ const exchangeCode = async () => {
|
||||
let tokenInfo
|
||||
if (props.platform === 'claude') {
|
||||
tokenInfo = await accountsStore.exchangeClaudeCode(data)
|
||||
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||
} else if (props.platform === 'gemini') {
|
||||
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
||||
// 附加 oauthProvider 信息到 tokenInfo
|
||||
if (tokenInfo) {
|
||||
tokenInfo.oauthProvider = geminiOauthProvider.value
|
||||
}
|
||||
} else if (props.platform === 'openai') {
|
||||
tokenInfo = await accountsStore.exchangeOpenAICode(data)
|
||||
} else if (props.platform === 'droid') {
|
||||
|
||||
@@ -1238,6 +1238,15 @@
|
||||
<i class="fas fa-vial" />
|
||||
<span class="ml-1">测试</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canTestAccount(account)"
|
||||
class="rounded bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
|
||||
title="定时测试配置"
|
||||
@click="openScheduledTestModal(account)"
|
||||
>
|
||||
<i class="fas fa-clock" />
|
||||
<span class="ml-1">定时</span>
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
||||
title="编辑账户"
|
||||
@@ -1707,6 +1716,15 @@
|
||||
测试
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canTestAccount(account)"
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-600 transition-colors hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
|
||||
@click="openScheduledTestModal(account)"
|
||||
>
|
||||
<i class="fas fa-clock" />
|
||||
定时
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
||||
@click="editAccount(account)"
|
||||
@@ -1880,6 +1898,14 @@
|
||||
@close="closeAccountTestModal"
|
||||
/>
|
||||
|
||||
<!-- 定时测试配置弹窗 -->
|
||||
<AccountScheduledTestModal
|
||||
:account="scheduledTestAccount"
|
||||
:show="showScheduledTestModal"
|
||||
@close="closeScheduledTestModal"
|
||||
@saved="handleScheduledTestSaved"
|
||||
/>
|
||||
|
||||
<!-- 账户统计弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAccountStatsModal"
|
||||
@@ -2032,6 +2058,7 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||||
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
|
||||
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
|
||||
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
@@ -2099,6 +2126,10 @@ const expiryEditModalRef = ref(null)
|
||||
const showAccountTestModal = ref(false)
|
||||
const testingAccount = ref(null)
|
||||
|
||||
// 定时测试配置弹窗状态
|
||||
const showScheduledTestModal = ref(false)
|
||||
const scheduledTestAccount = ref(null)
|
||||
|
||||
// 账户统计弹窗状态
|
||||
const showAccountStatsModal = ref(false)
|
||||
|
||||
@@ -2365,6 +2396,13 @@ const getAccountActions = (account) => {
|
||||
color: 'blue',
|
||||
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
|
||||
}
|
||||
|
||||
// 定时测试配置相关函数
|
||||
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(() => {
|
||||
let sourceAccounts = accounts.value
|
||||
|
||||
Reference in New Issue
Block a user