mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 实现 Antigravity OAuth 账户支持与路径分流
This commit is contained in:
35
.env.example
35
.env.example
@@ -33,6 +33,41 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
|||||||
CLAUDE_API_VERSION=2023-06-01
|
CLAUDE_API_VERSION=2023-06-01
|
||||||
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14
|
||||||
|
|
||||||
|
# 🤖 Gemini OAuth / Antigravity 配置(可选)
|
||||||
|
# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖
|
||||||
|
# GEMINI_OAUTH_CLIENT_ID=
|
||||||
|
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||||
|
# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode)
|
||||||
|
# GEMINI_OAUTH_REDIRECT_URI=
|
||||||
|
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||||
|
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||||
|
# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch)
|
||||||
|
# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462
|
||||||
|
# Antigravity 上游地址(可选,默认 sandbox)
|
||||||
|
# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
|
||||||
|
# Antigravity User-Agent(可选)
|
||||||
|
# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64
|
||||||
|
|
||||||
|
# Claude 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错误处理配置
|
||||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||||
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
CLAUDE_OVERLOAD_HANDLING_MINUTES=0
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -389,13 +389,31 @@ docker-compose.yml 已包含:
|
|||||||
|
|
||||||
**Claude Code 设置环境变量:**
|
**Claude Code 设置环境变量:**
|
||||||
|
|
||||||
默认使用标准 Claude 账号池:
|
默认使用标准 Claude 账号池(Claude/Console/Bedrock/CCR):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
|
||||||
|
|
||||||
|
Antigravity OAuth(支持 `claude-opus-4-5` 等 Antigravity 模型):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
||||||
|
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemini CLI OAuth(使用 Gemini 模型):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
||||||
|
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||||
|
```
|
||||||
|
|
||||||
**VSCode Claude 插件配置:**
|
**VSCode Claude 插件配置:**
|
||||||
|
|
||||||
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
||||||
|
|||||||
20
README_EN.md
20
README_EN.md
@@ -238,13 +238,31 @@ Now you can replace the official API with your own service:
|
|||||||
|
|
||||||
**Claude Code Set Environment Variables:**
|
**Claude Code Set Environment Variables:**
|
||||||
|
|
||||||
Default uses standard Claude account pool:
|
Default uses standard Claude account pool (Claude/Console/Bedrock/CCR):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
|
||||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
|
||||||
|
|
||||||
|
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
||||||
|
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemini CLI OAuth (Gemini models):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
||||||
|
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
||||||
|
```
|
||||||
|
|
||||||
**VSCode Claude Plugin Configuration:**
|
**VSCode Claude Plugin Configuration:**
|
||||||
|
|
||||||
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -891,7 +891,6 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -3000,7 +2999,6 @@
|
|||||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3082,7 +3080,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3538,7 +3535,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001737",
|
"caniuse-lite": "^1.0.30001737",
|
||||||
"electron-to-chromium": "^1.5.211",
|
"electron-to-chromium": "^1.5.211",
|
||||||
@@ -4426,7 +4422,6 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4483,7 +4478,6 @@
|
|||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -7582,7 +7576,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -9101,7 +9094,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.2",
|
"@dabh/diagnostics": "^2.0.2",
|
||||||
|
|||||||
19
src/app.js
19
src/app.js
@@ -264,6 +264,25 @@ class Application {
|
|||||||
this.app.use('/api', apiRoutes)
|
this.app.use('/api', apiRoutes)
|
||||||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||||||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||||||
|
// Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户
|
||||||
|
// - /antigravity/api/v1/messages -> Antigravity OAuth
|
||||||
|
// - /gemini-cli/api/v1/messages -> Gemini CLI OAuth
|
||||||
|
this.app.use(
|
||||||
|
'/antigravity/api',
|
||||||
|
(req, res, next) => {
|
||||||
|
req._anthropicVendor = 'antigravity'
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
apiRoutes
|
||||||
|
)
|
||||||
|
this.app.use(
|
||||||
|
'/gemini-cli/api',
|
||||||
|
(req, res, next) => {
|
||||||
|
req._anthropicVendor = 'gemini-cli'
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
apiRoutes
|
||||||
|
)
|
||||||
this.app.use('/admin', adminRoutes)
|
this.app.use('/admin', adminRoutes)
|
||||||
this.app.use('/users', userRoutes)
|
this.app.use('/users', userRoutes)
|
||||||
// 使用 web 路由(包含 auth 和页面重定向)
|
// 使用 web 路由(包含 auth 和页面重定向)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const logger = require('../utils/logger')
|
|||||||
const geminiAccountService = require('../services/geminiAccountService')
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
||||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
||||||
|
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
@@ -508,7 +509,23 @@ async function handleMessages(req, res) {
|
|||||||
// OAuth 账户:使用现有的 sendGeminiRequest
|
// OAuth 账户:使用现有的 sendGeminiRequest
|
||||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
const effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
geminiResponse = await 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({
|
geminiResponse = await sendGeminiRequest({
|
||||||
messages,
|
messages,
|
||||||
model,
|
model,
|
||||||
@@ -523,6 +540,7 @@ async function handleMessages(req, res) {
|
|||||||
accountId: account.id
|
accountId: account.id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
// 设置流式响应头
|
// 设置流式响应头
|
||||||
@@ -754,8 +772,16 @@ async function handleModels(req, res) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// OAuth 账户:使用 OAuth token 获取模型列表
|
// OAuth 账户:根据 OAuth provider 选择上游
|
||||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
models =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||||
|
account.accessToken,
|
||||||
|
account.proxy,
|
||||||
|
account.refreshToken
|
||||||
|
)
|
||||||
|
: await getAvailableModels(account.accessToken, account.proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -927,7 +953,8 @@ function handleSimpleEndpoint(apiMethod) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
// 直接转发请求体,不做特殊处理
|
// 直接转发请求体,不做特殊处理
|
||||||
@@ -1006,7 +1033,12 @@ async function handleLoadCodeAssist(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID
|
// 智能处理项目ID
|
||||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||||
@@ -1104,7 +1136,12 @@ async function handleOnboardUser(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID
|
// 智能处理项目ID
|
||||||
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
const effectiveProjectId = projectId || cloudaicompanionProject || null
|
||||||
@@ -1256,7 +1293,8 @@ async function handleCountTokens(req, res) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
response = await geminiAccountService.countTokens(client, contents, model, proxyConfig)
|
||||||
}
|
}
|
||||||
@@ -1366,13 +1404,20 @@ async function handleGenerateContent(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -1388,6 +1433,12 @@ async function handleGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||||
|
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果还是没有项目ID,返回错误
|
// 如果还是没有项目ID,返回错误
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1410,7 +1461,17 @@ async function handleGenerateContent(req, res) {
|
|||||||
: '从loadCodeAssist获取'
|
: '从loadCodeAssist获取'
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await geminiAccountService.generateContent(
|
const response =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.generateContentAntigravity(
|
||||||
|
client,
|
||||||
|
{ model, request: actualRequestData },
|
||||||
|
user_prompt_id,
|
||||||
|
effectiveProjectId,
|
||||||
|
req.apiKey?.id,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
: await geminiAccountService.generateContent(
|
||||||
client,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
user_prompt_id,
|
user_prompt_id,
|
||||||
@@ -1578,13 +1639,20 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
// 解析账户的代理配置
|
// 解析账户的代理配置
|
||||||
const proxyConfig = parseProxyConfig(account)
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
const client = await geminiAccountService.getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
// 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
// 如果没有任何项目ID,尝试调用 loadCodeAssist 获取
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId && oauthProvider !== 'antigravity') {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -1600,6 +1668,11 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveProjectId && oauthProvider === 'antigravity') {
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果还是没有项目ID,返回错误
|
// 如果还是没有项目ID,返回错误
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1622,7 +1695,18 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
: '从loadCodeAssist获取'
|
: '从loadCodeAssist获取'
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamResponse = await geminiAccountService.generateContentStream(
|
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,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
user_prompt_id,
|
user_prompt_id,
|
||||||
@@ -1978,15 +2062,23 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
} else {
|
} else {
|
||||||
// OAuth 账户
|
// OAuth 账户
|
||||||
const { accessToken, refreshToken } = account
|
const { accessToken, refreshToken } = account
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
|
// Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
} else if (!effectiveProjectId) {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -2024,6 +2116,16 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
|
|
||||||
const userPromptId = `${crypto.randomUUID()}########0`
|
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(
|
response = await geminiAccountService.generateContent(
|
||||||
client,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
@@ -2033,6 +2135,7 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
proxyConfig
|
proxyConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (response?.response?.usageMetadata) {
|
if (response?.response?.usageMetadata) {
|
||||||
@@ -2263,12 +2366,20 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
let effectiveProjectId = account.projectId || account.tempProjectId || null
|
||||||
|
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
if (!effectiveProjectId) {
|
if (!effectiveProjectId) {
|
||||||
|
effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId)
|
||||||
|
}
|
||||||
|
} else if (!effectiveProjectId) {
|
||||||
try {
|
try {
|
||||||
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...')
|
||||||
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig)
|
||||||
@@ -2306,6 +2417,17 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
|
|
||||||
const userPromptId = `${crypto.randomUUID()}########0`
|
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(
|
streamResponse = await geminiAccountService.generateContentStream(
|
||||||
client,
|
client,
|
||||||
{ model, request: actualRequestData },
|
{ model, request: actualRequestData },
|
||||||
@@ -2316,6 +2438,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
proxyConfig
|
proxyConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 设置 SSE 响应头
|
// 设置 SSE 响应头
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req,
|
|||||||
|
|
||||||
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
hasProxy: !!proxy
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async
|
|||||||
|
|
||||||
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
||||||
sessionKeyLength: trimmedSessionKey.length,
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`,
|
||||||
hasProxy: !!proxy
|
hasProxy: !!proxy
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
|
|||||||
const ccrAccountService = require('../../services/ccrAccountService')
|
const ccrAccountService = require('../../services/ccrAccountService')
|
||||||
const geminiAccountService = require('../../services/geminiAccountService')
|
const geminiAccountService = require('../../services/geminiAccountService')
|
||||||
const droidAccountService = require('../../services/droidAccountService')
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
const openaiAccountService = require('../../services/openaiAccountService')
|
|
||||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
const CostCalculator = require('../../utils/costCalculator')
|
const CostCalculator = require('../../utils/costCalculator')
|
||||||
const pricingService = require('../../services/pricingService')
|
|
||||||
const config = require('../../../config/config')
|
const config = require('../../../config/config')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|||||||
@@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
// 🤖 Gemini OAuth 账户管理
|
// 🤖 Gemini OAuth 账户管理
|
||||||
|
function getDefaultRedirectUri(oauthProvider) {
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462'
|
||||||
|
}
|
||||||
|
return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode'
|
||||||
|
}
|
||||||
|
|
||||||
// 生成 Gemini OAuth 授权 URL
|
// 生成 Gemini OAuth 授权 URL
|
||||||
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { state, proxy } = req.body // 接收代理配置
|
const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider
|
||||||
|
|
||||||
// 使用新的 codeassist.google.com 回调地址
|
const redirectUri = getDefaultRedirectUri(oauthProvider)
|
||||||
const redirectUri = 'https://codeassist.google.com/authcode'
|
|
||||||
|
|
||||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`)
|
||||||
|
|
||||||
@@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
authUrl,
|
authUrl,
|
||||||
state: authState,
|
state: authState,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
redirectUri: finalRedirectUri
|
redirectUri: finalRedirectUri,
|
||||||
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy)
|
oauthProvider: resolvedOauthProvider
|
||||||
|
} = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider)
|
||||||
|
|
||||||
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
|
||||||
const sessionId = authState
|
const sessionId = authState
|
||||||
@@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
redirectUri: finalRedirectUri,
|
redirectUri: finalRedirectUri,
|
||||||
codeVerifier, // 保存 PKCE code verifier
|
codeVerifier, // 保存 PKCE code verifier
|
||||||
proxy: proxy || null, // 保存代理配置
|
proxy: proxy || null, // 保存代理配置
|
||||||
|
oauthProvider: resolvedOauthProvider,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
authUrl,
|
authUrl,
|
||||||
sessionId
|
sessionId,
|
||||||
|
oauthProvider: resolvedOauthProvider
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => {
|
|||||||
// 交换 Gemini 授权码
|
// 交换 Gemini 授权码
|
||||||
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code, sessionId, proxy: requestProxy } = req.body
|
const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body
|
||||||
|
let resolvedOauthProvider = oauthProvider
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return res.status(400).json({ error: 'Authorization code is required' })
|
return res.status(400).json({ error: 'Authorization code is required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirectUri = 'https://codeassist.google.com/authcode'
|
let redirectUri = getDefaultRedirectUri(resolvedOauthProvider)
|
||||||
let codeVerifier = null
|
let codeVerifier = null
|
||||||
let proxyConfig = null
|
let proxyConfig = null
|
||||||
|
|
||||||
@@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
const {
|
const {
|
||||||
redirectUri: sessionRedirectUri,
|
redirectUri: sessionRedirectUri,
|
||||||
codeVerifier: sessionCodeVerifier,
|
codeVerifier: sessionCodeVerifier,
|
||||||
proxy
|
proxy,
|
||||||
|
oauthProvider: sessionOauthProvider
|
||||||
} = sessionData
|
} = sessionData
|
||||||
redirectUri = sessionRedirectUri || redirectUri
|
redirectUri = sessionRedirectUri || redirectUri
|
||||||
codeVerifier = sessionCodeVerifier
|
codeVerifier = sessionCodeVerifier
|
||||||
proxyConfig = proxy // 获取代理配置
|
proxyConfig = proxy // 获取代理配置
|
||||||
|
if (!resolvedOauthProvider && sessionOauthProvider) {
|
||||||
|
// 会话里保存的 provider 仅作为兜底
|
||||||
|
resolvedOauthProvider = sessionOauthProvider
|
||||||
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
|
||||||
)
|
)
|
||||||
@@ -120,7 +134,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
code,
|
code,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
proxyConfig // 传递代理配置
|
proxyConfig, // 传递代理配置
|
||||||
|
resolvedOauthProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
// 清理 OAuth 会话
|
// 清理 OAuth 会话
|
||||||
@@ -129,7 +144,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.success('✅ Successfully exchanged Gemini authorization code')
|
logger.success('✅ Successfully exchanged Gemini authorization code')
|
||||||
return res.json({ success: true, data: { tokens } })
|
return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
logger.error('❌ Failed to exchange Gemini authorization code:', error)
|
||||||
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
return res.status(500).json({ error: 'Failed to exchange code', message: error.message })
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ const sessionHelper = require('../utils/sessionHelper')
|
|||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||||
|
const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump')
|
||||||
|
const {
|
||||||
|
handleAnthropicMessagesToGemini,
|
||||||
|
handleAnthropicCountTokensToGemini
|
||||||
|
} = require('../services/anthropicGeminiBridgeService')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||||
@@ -110,20 +115,6 @@ async function handleMessagesRequest(req, res) {
|
|||||||
try {
|
try {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
// Claude 服务权限校验,阻止未授权的 Key
|
|
||||||
if (
|
|
||||||
req.apiKey.permissions &&
|
|
||||||
req.apiKey.permissions !== 'all' &&
|
|
||||||
req.apiKey.permissions !== 'claude'
|
|
||||||
) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: {
|
|
||||||
type: 'permission_error',
|
|
||||||
message: '此 API Key 无权访问 Claude 服务'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔄 并发满额重试标志:最多重试一次(使用req对象存储状态)
|
// 🔄 并发满额重试标志:最多重试一次(使用req对象存储状态)
|
||||||
if (req._concurrencyRetryAttempted === undefined) {
|
if (req._concurrencyRetryAttempted === undefined) {
|
||||||
req._concurrencyRetryAttempted = false
|
req._concurrencyRetryAttempted = false
|
||||||
@@ -168,6 +159,50 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const forcedVendor = req._anthropicVendor || null
|
||||||
|
logger.api('📥 /v1/messages request received', {
|
||||||
|
model: req.body.model || null,
|
||||||
|
forcedVendor,
|
||||||
|
stream: req.body.stream === true
|
||||||
|
})
|
||||||
|
|
||||||
|
dumpAnthropicMessagesRequest(req, {
|
||||||
|
route: '/v1/messages',
|
||||||
|
forcedVendor,
|
||||||
|
model: req.body?.model || null,
|
||||||
|
stream: req.body?.stream === true
|
||||||
|
})
|
||||||
|
|
||||||
|
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||||
|
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||||
|
const permissions = req.apiKey?.permissions || 'all'
|
||||||
|
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'permission_error',
|
||||||
|
message: '此 API Key 无权访问 Gemini 服务'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseModel = (req.body.model || '').trim()
|
||||||
|
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude 服务权限校验,阻止未授权的 Key(默认路径保持不变)
|
||||||
|
if (
|
||||||
|
req.apiKey.permissions &&
|
||||||
|
req.apiKey.permissions !== 'all' &&
|
||||||
|
req.apiKey.permissions !== 'claude'
|
||||||
|
) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'permission_error',
|
||||||
|
message: '此 API Key 无权访问 Claude 服务'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否为流式请求
|
// 检查是否为流式请求
|
||||||
const isStream = req.body.stream === true
|
const isStream = req.body.stream === true
|
||||||
|
|
||||||
@@ -985,8 +1020,8 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
const rawModel = jsonData.model || req.body.model || 'unknown'
|
||||||
const { baseModel } = parseVendorPrefixedModel(rawModel)
|
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
||||||
const model = baseModel || rawModel
|
const model = usageBaseModel || rawModel
|
||||||
|
|
||||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
const { accountId: responseAccountId } = response
|
const { accountId: responseAccountId } = response
|
||||||
@@ -1162,6 +1197,66 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest)
|
|||||||
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
|
// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini
|
||||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表
|
||||||
|
//(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。
|
||||||
|
const forcedVendor = req._anthropicVendor || null
|
||||||
|
if (forcedVendor === 'antigravity') {
|
||||||
|
const permissions = req.apiKey?.permissions || 'all'
|
||||||
|
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'permission_error',
|
||||||
|
message: '此 API Key 无权访问 Gemini 服务'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
|
|
||||||
|
let accountSelection
|
||||||
|
try {
|
||||||
|
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
|
req.apiKey,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
{ oauthProvider: 'antigravity' }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to select Gemini OAuth account (antigravity models):', error)
|
||||||
|
return res.status(503).json({ error: 'No available Gemini OAuth accounts' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await geminiAccountService.getAccount(accountSelection.accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(503).json({ error: 'Gemini OAuth account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxyConfig = null
|
||||||
|
if (account.proxy) {
|
||||||
|
try {
|
||||||
|
proxyConfig =
|
||||||
|
typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to parse proxy configuration:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||||
|
account.accessToken,
|
||||||
|
proxyConfig,
|
||||||
|
account.refreshToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// 可选:根据 API Key 的模型限制过滤(黑名单语义)
|
||||||
|
let filteredModels = models
|
||||||
|
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
||||||
|
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ object: 'list', data: filteredModels })
|
||||||
|
}
|
||||||
|
|
||||||
const modelService = require('../services/modelService')
|
const modelService = require('../services/modelService')
|
||||||
|
|
||||||
// 从 modelService 获取所有支持的模型
|
// 从 modelService 获取所有支持的模型
|
||||||
@@ -1298,6 +1393,22 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
|
|||||||
|
|
||||||
// 🔢 Token计数端点 - count_tokens beta API
|
// 🔢 Token计数端点 - count_tokens beta API
|
||||||
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||||
|
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||||
|
const forcedVendor = req._anthropicVendor || null
|
||||||
|
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||||
|
const permissions = req.apiKey?.permissions || 'all'
|
||||||
|
if (permissions !== 'all' && permissions !== 'gemini') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'permission_error',
|
||||||
|
message: 'This API key does not have permission to access Gemini'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
|
||||||
|
}
|
||||||
|
|
||||||
// 检查权限
|
// 检查权限
|
||||||
if (
|
if (
|
||||||
req.apiKey.permissions &&
|
req.apiKey.permissions &&
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ function generateSessionHash(req) {
|
|||||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureAntigravityProjectId(account) {
|
||||||
|
if (account.projectId) {
|
||||||
|
return account.projectId
|
||||||
|
}
|
||||||
|
if (account.tempProjectId) {
|
||||||
|
return account.tempProjectId
|
||||||
|
}
|
||||||
|
return `ag-${crypto.randomBytes(8).toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
// 检查 API Key 权限
|
// 检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||||
const permissions = apiKeyData.permissions || 'all'
|
const permissions = apiKeyData.permissions || 'all'
|
||||||
@@ -335,21 +345,44 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
const client = await geminiAccountService.getOauthClient(
|
const client = await geminiAccountService.getOauthClient(
|
||||||
account.accessToken,
|
account.accessToken,
|
||||||
account.refreshToken,
|
account.refreshToken,
|
||||||
proxyConfig
|
proxyConfig,
|
||||||
|
account.oauthProvider
|
||||||
)
|
)
|
||||||
if (actualStream) {
|
if (actualStream) {
|
||||||
// 流式响应
|
// 流式响应
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
let { projectId } = account
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
projectId = ensureAntigravityProjectId(account)
|
||||||
|
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||||
|
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||||
|
account.tempProjectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('StreamGenerateContent request', {
|
logger.info('StreamGenerateContent request', {
|
||||||
model,
|
model,
|
||||||
projectId: account.projectId,
|
projectId,
|
||||||
apiKeyId: apiKeyData.id
|
apiKeyId: apiKeyData.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamResponse = await geminiAccountService.generateContentStream(
|
const streamResponse =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.generateContentStreamAntigravity(
|
||||||
client,
|
client,
|
||||||
{ model, request: geminiRequestBody },
|
{ model, request: geminiRequestBody },
|
||||||
null, // user_prompt_id
|
null, // user_prompt_id
|
||||||
account.projectId, // 使用有权限的项目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
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
abortController.signal, // 传递中止信号
|
abortController.signal, // 传递中止信号
|
||||||
proxyConfig // 传递代理配置
|
proxyConfig // 传递代理配置
|
||||||
@@ -559,17 +592,38 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 非流式响应
|
// 非流式响应
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
let { projectId } = account
|
||||||
|
|
||||||
|
if (oauthProvider === 'antigravity') {
|
||||||
|
projectId = ensureAntigravityProjectId(account)
|
||||||
|
if (!account.projectId && account.tempProjectId !== projectId) {
|
||||||
|
await geminiAccountService.updateTempProjectId(account.id, projectId)
|
||||||
|
account.tempProjectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('GenerateContent request', {
|
logger.info('GenerateContent request', {
|
||||||
model,
|
model,
|
||||||
projectId: account.projectId,
|
projectId,
|
||||||
apiKeyId: apiKeyData.id
|
apiKeyId: apiKeyData.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await geminiAccountService.generateContent(
|
const response =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.generateContentAntigravity(
|
||||||
client,
|
client,
|
||||||
{ model, request: geminiRequestBody },
|
{ model, request: geminiRequestBody },
|
||||||
null, // user_prompt_id
|
null, // user_prompt_id
|
||||||
account.projectId, // 使用有权限的项目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
|
apiKeyData.id, // 使用 API Key ID 作为 session ID
|
||||||
proxyConfig // 传递代理配置
|
proxyConfig // 传递代理配置
|
||||||
)
|
)
|
||||||
@@ -604,7 +658,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
logger.info(`OpenAI-Gemini request completed in ${duration}ms`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('OpenAI-Gemini request error:', error)
|
const statusForLog = error?.status || error?.response?.status
|
||||||
|
logger.error('OpenAI-Gemini request error', {
|
||||||
|
message: error?.message,
|
||||||
|
status: statusForLog,
|
||||||
|
code: error?.code,
|
||||||
|
requestUrl: error?.config?.url,
|
||||||
|
requestMethod: error?.config?.method,
|
||||||
|
upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id']
|
||||||
|
})
|
||||||
|
|
||||||
// 处理速率限制
|
// 处理速率限制
|
||||||
if (error.status === 429) {
|
if (error.status === 429) {
|
||||||
@@ -665,8 +727,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
let models = []
|
let models = []
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
// 获取实际的模型列表
|
// 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性)
|
||||||
models = await getAvailableModels(account.accessToken, account.proxy)
|
try {
|
||||||
|
const oauthProvider = account.oauthProvider || 'gemini-cli'
|
||||||
|
models =
|
||||||
|
oauthProvider === 'antigravity'
|
||||||
|
? await geminiAccountService.fetchAvailableModelsAntigravity(
|
||||||
|
account.accessToken,
|
||||||
|
account.proxy,
|
||||||
|
account.refreshToken
|
||||||
|
)
|
||||||
|
: await getAvailableModels(account.accessToken, account.proxy)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error)
|
||||||
|
models = []
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 返回默认模型列表
|
// 返回默认模型列表
|
||||||
models = [
|
models = [
|
||||||
@@ -679,6 +754,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.0-flash-exp',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'google'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// 如果启用了模型限制,过滤模型列表
|
// 如果启用了模型限制,过滤模型列表
|
||||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||||
|
|||||||
1888
src/services/anthropicGeminiBridgeService.js
Normal file
1888
src/services/anthropicGeminiBridgeService.js
Normal file
File diff suppressed because it is too large
Load Diff
559
src/services/antigravityClient.js
Normal file
559
src/services/antigravityClient.js
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const https = require('https')
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
|
||||||
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const {
|
||||||
|
mapAntigravityUpstreamModel,
|
||||||
|
normalizeAntigravityModelInput,
|
||||||
|
getAntigravityModelMetadata
|
||||||
|
} = require('../utils/antigravityModel')
|
||||||
|
const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner')
|
||||||
|
const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump')
|
||||||
|
|
||||||
|
const keepAliveAgent = new https.Agent({
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveMsecs: 30000,
|
||||||
|
timeout: 120000,
|
||||||
|
maxSockets: 100,
|
||||||
|
maxFreeSockets: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
function getAntigravityApiUrl() {
|
||||||
|
return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url) {
|
||||||
|
const str = String(url || '').trim()
|
||||||
|
return str.endsWith('/') ? str.slice(0, -1) : str
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityApiUrlCandidates() {
|
||||||
|
const configured = normalizeBaseUrl(getAntigravityApiUrl())
|
||||||
|
const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
const prod = 'https://cloudcode-pa.googleapis.com'
|
||||||
|
|
||||||
|
// 若显式配置了自定义 base url,则只使用该地址(不做 fallback,避免意外路由到别的环境)。
|
||||||
|
if (process.env.ANTIGRAVITY_API_URL) {
|
||||||
|
return [configured]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认行为:优先 daily(与旧逻辑一致),失败时再尝试 prod(对齐 CLIProxyAPI)。
|
||||||
|
if (configured === normalizeBaseUrl(daily)) {
|
||||||
|
return [configured, prod]
|
||||||
|
}
|
||||||
|
if (configured === normalizeBaseUrl(prod)) {
|
||||||
|
return [configured, daily]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [configured, prod, daily].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityHeaders(accessToken, baseUrl) {
|
||||||
|
const resolvedBaseUrl = baseUrl || getAntigravityApiUrl()
|
||||||
|
let host = 'daily-cloudcode-pa.sandbox.googleapis.com'
|
||||||
|
try {
|
||||||
|
host = new URL(resolvedBaseUrl).host || host
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Host: host,
|
||||||
|
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Encoding': 'gzip'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAntigravityProjectId() {
|
||||||
|
return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAntigravitySessionId() {
|
||||||
|
return `sess-${uuidv4()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAntigravityProjectId(projectId, requestData) {
|
||||||
|
const candidate = projectId || requestData?.project || requestData?.projectId || null
|
||||||
|
return candidate || generateAntigravityProjectId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAntigravitySessionId(sessionId, requestData) {
|
||||||
|
const candidate =
|
||||||
|
sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null
|
||||||
|
return candidate || generateAntigravitySessionId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) {
|
||||||
|
const model = mapAntigravityUpstreamModel(requestData?.model)
|
||||||
|
const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData)
|
||||||
|
const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData)
|
||||||
|
const requestPayload = {
|
||||||
|
...(requestData?.request || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPayload.session_id !== undefined) {
|
||||||
|
delete requestPayload.session_id
|
||||||
|
}
|
||||||
|
requestPayload.sessionId = resolvedSessionId
|
||||||
|
|
||||||
|
const envelope = {
|
||||||
|
project: resolvedProjectId,
|
||||||
|
requestId: `req-${uuidv4()}`,
|
||||||
|
model,
|
||||||
|
userAgent: 'antigravity',
|
||||||
|
request: {
|
||||||
|
...requestPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPromptId) {
|
||||||
|
envelope.user_prompt_id = userPromptId
|
||||||
|
envelope.userPromptId = userPromptId
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAntigravityEnvelope(envelope)
|
||||||
|
return { model, envelope }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityThinking(model, requestPayload) {
|
||||||
|
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { generationConfig } = requestPayload
|
||||||
|
if (!generationConfig || typeof generationConfig !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { thinkingConfig } = generationConfig
|
||||||
|
if (!thinkingConfig || typeof thinkingConfig !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedModel = normalizeAntigravityModelInput(model)
|
||||||
|
if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) {
|
||||||
|
delete thinkingConfig.thinkingLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = getAntigravityModelMetadata(normalizedModel)
|
||||||
|
if (metadata && !metadata.thinking) {
|
||||||
|
delete generationConfig.thinkingConfig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!metadata || !metadata.thinking) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetRaw = Number(thinkingConfig.thinkingBudget)
|
||||||
|
if (!Number.isFinite(budgetRaw)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let budget = Math.trunc(budgetRaw)
|
||||||
|
|
||||||
|
const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null
|
||||||
|
const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null
|
||||||
|
|
||||||
|
if (maxBudget !== null && budget > maxBudget) {
|
||||||
|
budget = maxBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens)
|
||||||
|
? generationConfig.maxOutputTokens
|
||||||
|
: null
|
||||||
|
let setDefaultMax = false
|
||||||
|
if (!effectiveMax && metadata.maxCompletionTokens) {
|
||||||
|
effectiveMax = metadata.maxCompletionTokens
|
||||||
|
setDefaultMax = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveMax && budget >= effectiveMax) {
|
||||||
|
budget = Math.max(0, effectiveMax - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minBudget !== null && budget >= 0 && budget < minBudget) {
|
||||||
|
delete generationConfig.thinkingConfig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thinkingConfig.thinkingBudget = budget
|
||||||
|
if (setDefaultMax) {
|
||||||
|
generationConfig.maxOutputTokens = effectiveMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityEnvelope(envelope) {
|
||||||
|
if (!envelope || typeof envelope !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const model = String(envelope.model || '')
|
||||||
|
const requestPayload = envelope.request
|
||||||
|
if (!requestPayload || typeof requestPayload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPayload.safetySettings !== undefined) {
|
||||||
|
delete requestPayload.safetySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对齐 CLIProxyAPI:有 tools 时默认启用 VALIDATED(除非显式 NONE)
|
||||||
|
if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {
|
||||||
|
const existing = requestPayload?.toolConfig?.functionCallingConfig || null
|
||||||
|
if (existing?.mode !== 'NONE') {
|
||||||
|
const nextCfg = { ...(existing || {}), mode: 'VALIDATED' }
|
||||||
|
requestPayload.toolConfig = { functionCallingConfig: nextCfg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对齐 CLIProxyAPI:非 Claude 模型移除 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
|
||||||
|
}
|
||||||
170
src/services/antigravityRelayService.js
Normal file
170
src/services/antigravityRelayService.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
|
||||||
|
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
|
||||||
|
const antigravityClient = require('./antigravityClient')
|
||||||
|
|
||||||
|
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
|
||||||
|
const requestedModel = normalizeAntigravityModelInput(model)
|
||||||
|
const { contents, systemInstruction } = convertMessagesToGemini(messages)
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
model: requestedModel,
|
||||||
|
request: {
|
||||||
|
contents,
|
||||||
|
generationConfig: {
|
||||||
|
temperature,
|
||||||
|
maxOutputTokens: maxTokens,
|
||||||
|
candidateCount: 1,
|
||||||
|
topP: 0.95,
|
||||||
|
topK: 40
|
||||||
|
},
|
||||||
|
...(sessionId ? { sessionId } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInstruction) {
|
||||||
|
requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestData
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* handleStreamResponse(response, model, apiKeyId, accountId) {
|
||||||
|
let buffer = ''
|
||||||
|
let totalUsage = {
|
||||||
|
promptTokenCount: 0,
|
||||||
|
candidatesTokenCount: 0,
|
||||||
|
totalTokenCount: 0
|
||||||
|
}
|
||||||
|
let usageRecorded = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of response.data) {
|
||||||
|
buffer += chunk.toString()
|
||||||
|
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonData = line
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonData = line.substring(6).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jsonData || jsonData === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonData)
|
||||||
|
const payload = data?.response || data
|
||||||
|
|
||||||
|
if (payload?.usageMetadata) {
|
||||||
|
totalUsage = payload.usageMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
const openaiChunk = convertGeminiResponse(payload, model, true)
|
||||||
|
if (openaiChunk) {
|
||||||
|
yield `data: ${JSON.stringify(openaiChunk)}\n\n`
|
||||||
|
const finishReason = openaiChunk.choices?.[0]?.finish_reason
|
||||||
|
if (finishReason === 'stop') {
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
|
||||||
|
if (apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
model,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
usageRecorded = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore chunk parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
model,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAntigravityRequest({
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
temperature = 0.7,
|
||||||
|
maxTokens = 4096,
|
||||||
|
stream = false,
|
||||||
|
accessToken,
|
||||||
|
proxy,
|
||||||
|
apiKeyId,
|
||||||
|
signal,
|
||||||
|
projectId,
|
||||||
|
accountId = null
|
||||||
|
}) {
|
||||||
|
const requestedModel = normalizeAntigravityModelInput(model)
|
||||||
|
|
||||||
|
const requestData = buildRequestData({
|
||||||
|
messages,
|
||||||
|
model: requestedModel,
|
||||||
|
temperature,
|
||||||
|
maxTokens,
|
||||||
|
sessionId: apiKeyId
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response } = await antigravityClient.request({
|
||||||
|
accessToken,
|
||||||
|
proxyConfig: proxy,
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId: apiKeyId,
|
||||||
|
stream,
|
||||||
|
signal,
|
||||||
|
params: { alt: 'sse' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
return handleStreamResponse(response, requestedModel, apiKeyId, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = response.data?.response || response.data
|
||||||
|
const openaiResponse = convertGeminiResponse(payload, requestedModel, false)
|
||||||
|
|
||||||
|
if (apiKeyId && openaiResponse?.usage) {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyId,
|
||||||
|
openaiResponse.usage.prompt_tokens || 0,
|
||||||
|
openaiResponse.usage.completion_tokens || 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
requestedModel,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return openaiResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendAntigravityRequest
|
||||||
|
}
|
||||||
@@ -16,11 +16,62 @@ const {
|
|||||||
} = require('../utils/tokenRefreshLogger')
|
} = require('../utils/tokenRefreshLogger')
|
||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
const antigravityClient = require('./antigravityClient')
|
||||||
|
|
||||||
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用
|
||||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||||
const OAUTH_CLIENT_SECRET = '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'
|
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
|
||||||
|
const OAUTH_PROVIDERS = {
|
||||||
|
[OAUTH_PROVIDER_GEMINI_CLI]: {
|
||||||
|
// Gemini CLI OAuth 配置(公开)
|
||||||
|
clientId:
|
||||||
|
process.env.GEMINI_OAUTH_CLIENT_ID ||
|
||||||
|
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
|
||||||
|
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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',
|
||||||
|
scopes: ['https://www.googleapis.com/auth/cloud-platform']
|
||||||
|
},
|
||||||
|
[OAUTH_PROVIDER_ANTIGRAVITY]: {
|
||||||
|
// Antigravity OAuth 配置(参考 gcli2api)
|
||||||
|
clientId:
|
||||||
|
process.env.ANTIGRAVITY_OAUTH_CLIENT_ID ||
|
||||||
|
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
|
||||||
|
clientSecret:
|
||||||
|
process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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',
|
||||||
|
scopes: [
|
||||||
|
'https://www.googleapis.com/auth/cloud-platform',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
'https://www.googleapis.com/auth/cclog',
|
||||||
|
'https://www.googleapis.com/auth/experimentsandconfigs'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) {
|
||||||
|
logger.warn(
|
||||||
|
'⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) {
|
||||||
|
logger.warn(
|
||||||
|
'⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOauthProvider(oauthProvider) {
|
||||||
|
if (!oauthProvider) {
|
||||||
|
return OAUTH_PROVIDER_GEMINI_CLI
|
||||||
|
}
|
||||||
|
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
? OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
: OAUTH_PROVIDER_GEMINI_CLI
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOauthProviderConfig(oauthProvider) {
|
||||||
|
const normalized = normalizeOauthProvider(oauthProvider)
|
||||||
|
return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI]
|
||||||
|
}
|
||||||
|
|
||||||
// 🌐 TCP Keep-Alive Agent 配置
|
// 🌐 TCP Keep-Alive Agent 配置
|
||||||
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
||||||
@@ -34,6 +85,117 @@ const keepAliveAgent = new https.Agent({
|
|||||||
|
|
||||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||||
|
|
||||||
|
async function fetchAvailableModelsAntigravity(
|
||||||
|
accessToken,
|
||||||
|
proxyConfig = null,
|
||||||
|
refreshToken = null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let effectiveToken = accessToken
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const client = await getOauthClient(
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
)
|
||||||
|
if (client && client.getAccessToken) {
|
||||||
|
const latest = await client.getAccessToken()
|
||||||
|
if (latest?.token) {
|
||||||
|
effectiveToken = latest.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to refresh Antigravity access token for models list:', {
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await antigravityClient.fetchAvailableModels({
|
||||||
|
accessToken: effectiveToken,
|
||||||
|
proxyConfig
|
||||||
|
})
|
||||||
|
const modelsDict = data?.models
|
||||||
|
const created = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
const models = []
|
||||||
|
const seen = new Set()
|
||||||
|
const {
|
||||||
|
getAntigravityModelAlias,
|
||||||
|
getAntigravityModelMetadata,
|
||||||
|
normalizeAntigravityModelInput
|
||||||
|
} = require('../utils/antigravityModel')
|
||||||
|
|
||||||
|
const pushModel = (modelId) => {
|
||||||
|
if (!modelId || seen.has(modelId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(modelId)
|
||||||
|
const metadata = getAntigravityModelMetadata(modelId)
|
||||||
|
const entry = {
|
||||||
|
id: modelId,
|
||||||
|
object: 'model',
|
||||||
|
created,
|
||||||
|
owned_by: 'antigravity'
|
||||||
|
}
|
||||||
|
if (metadata?.name) {
|
||||||
|
entry.name = metadata.name
|
||||||
|
}
|
||||||
|
if (metadata?.maxCompletionTokens) {
|
||||||
|
entry.max_completion_tokens = metadata.maxCompletionTokens
|
||||||
|
}
|
||||||
|
if (metadata?.thinking) {
|
||||||
|
entry.thinking = metadata.thinking
|
||||||
|
}
|
||||||
|
models.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelsDict && typeof modelsDict === 'object') {
|
||||||
|
for (const modelId of Object.keys(modelsDict)) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(modelId)
|
||||||
|
const alias = getAntigravityModelAlias(normalized)
|
||||||
|
if (!alias) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pushModel(alias)
|
||||||
|
|
||||||
|
if (alias.endsWith('-thinking')) {
|
||||||
|
pushModel(alias.replace(/-thinking$/, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alias.startsWith('gemini-claude-')) {
|
||||||
|
pushModel(alias.replace(/^gemini-/, ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'gemini-2.5-flash',
|
||||||
|
object: 'model',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
owned_by: 'antigravity'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countTokensAntigravity(client, contents, model, proxyConfig = null) {
|
||||||
|
const { token } = await client.getAccessToken()
|
||||||
|
const response = await antigravityClient.countTokens({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig,
|
||||||
|
contents,
|
||||||
|
model
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
const ALGORITHM = 'aes-256-cbc'
|
const ALGORITHM = 'aes-256-cbc'
|
||||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||||
@@ -124,14 +286,15 @@ setInterval(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 创建 OAuth2 客户端(支持代理配置)
|
// 创建 OAuth2 客户端(支持代理配置)
|
||||||
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) {
|
||||||
// 如果没有提供 redirectUri,使用默认值
|
// 如果没有提供 redirectUri,使用默认值
|
||||||
const uri = redirectUri || 'http://localhost:45462'
|
const uri = redirectUri || 'http://localhost:45462'
|
||||||
|
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||||
|
|
||||||
// 准备客户端选项
|
// 准备客户端选项
|
||||||
const clientOptions = {
|
const clientOptions = {
|
||||||
clientId: OAUTH_CLIENT_ID,
|
clientId: oauthConfig.clientId,
|
||||||
clientSecret: OAUTH_CLIENT_SECRET,
|
clientSecret: oauthConfig.clientSecret,
|
||||||
redirectUri: uri
|
redirectUri: uri
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +315,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成授权 URL (支持 PKCE 和代理)
|
// 生成授权 URL (支持 PKCE 和代理)
|
||||||
async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) {
|
async function generateAuthUrl(
|
||||||
|
state = null,
|
||||||
|
redirectUri = null,
|
||||||
|
proxyConfig = null,
|
||||||
|
oauthProvider = null
|
||||||
|
) {
|
||||||
// 使用新的 redirect URI
|
// 使用新的 redirect URI
|
||||||
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode'
|
||||||
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig)
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
|
const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -172,7 +342,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
|||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
redirect_uri: finalRedirectUri,
|
redirect_uri: finalRedirectUri,
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
scope: OAUTH_SCOPES,
|
scope: oauthConfig.scopes,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
code_challenge: codeVerifier.codeChallenge,
|
code_challenge: codeVerifier.codeChallenge,
|
||||||
state: stateValue,
|
state: stateValue,
|
||||||
@@ -183,7 +353,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n
|
|||||||
authUrl,
|
authUrl,
|
||||||
state: stateValue,
|
state: stateValue,
|
||||||
codeVerifier: codeVerifier.codeVerifier,
|
codeVerifier: codeVerifier.codeVerifier,
|
||||||
redirectUri: finalRedirectUri
|
redirectUri: finalRedirectUri,
|
||||||
|
oauthProvider: normalizedProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +415,14 @@ async function exchangeCodeForTokens(
|
|||||||
code,
|
code,
|
||||||
redirectUri = null,
|
redirectUri = null,
|
||||||
codeVerifier = null,
|
codeVerifier = null,
|
||||||
proxyConfig = null
|
proxyConfig = null,
|
||||||
|
oauthProvider = null
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
// 创建带代理配置的 OAuth2Client
|
// 创建带代理配置的 OAuth2Client
|
||||||
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
|
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -274,7 +448,7 @@ async function exchangeCodeForTokens(
|
|||||||
return {
|
return {
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token,
|
||||||
scope: tokens.scope || OAUTH_SCOPES.join(' '),
|
scope: tokens.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: tokens.token_type || 'Bearer',
|
token_type: tokens.token_type || 'Bearer',
|
||||||
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
|
expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000
|
||||||
}
|
}
|
||||||
@@ -285,9 +459,11 @@ async function exchangeCodeForTokens(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 刷新访问令牌
|
// 刷新访问令牌
|
||||||
async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||||
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
// 创建带代理配置的 OAuth2Client
|
// 创建带代理配置的 OAuth2Client
|
||||||
const oAuth2Client = createOAuth2Client(null, proxyConfig)
|
const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 设置 refresh_token
|
// 设置 refresh_token
|
||||||
@@ -319,7 +495,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
|||||||
return {
|
return {
|
||||||
access_token: credentials.access_token,
|
access_token: credentials.access_token,
|
||||||
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
|
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
|
||||||
scope: credentials.scope || OAUTH_SCOPES.join(' '),
|
scope: credentials.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: credentials.token_type || 'Bearer',
|
token_type: credentials.token_type || 'Bearer',
|
||||||
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期
|
||||||
}
|
}
|
||||||
@@ -339,6 +515,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) {
|
|||||||
async function createAccount(accountData) {
|
async function createAccount(accountData) {
|
||||||
const id = uuidv4()
|
const id = uuidv4()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
const oauthProvider = normalizeOauthProvider(accountData.oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(oauthProvider)
|
||||||
|
|
||||||
// 处理凭证数据
|
// 处理凭证数据
|
||||||
let geminiOauth = null
|
let geminiOauth = null
|
||||||
@@ -371,7 +549,7 @@ async function createAccount(accountData) {
|
|||||||
geminiOauth = JSON.stringify({
|
geminiOauth = JSON.stringify({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: accountData.scope || OAUTH_SCOPES.join(' '),
|
scope: accountData.scope || oauthConfig.scopes.join(' '),
|
||||||
token_type: accountData.tokenType || 'Bearer',
|
token_type: accountData.tokenType || 'Bearer',
|
||||||
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
|
||||||
})
|
})
|
||||||
@@ -399,7 +577,8 @@ async function createAccount(accountData) {
|
|||||||
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
refreshToken: refreshToken ? encrypt(refreshToken) : '',
|
||||||
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
expiresAt, // OAuth Token 过期时间(技术字段,自动刷新)
|
||||||
// 只有OAuth方式才有scopes,手动添加的没有
|
// 只有OAuth方式才有scopes,手动添加的没有
|
||||||
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
|
scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '',
|
||||||
|
oauthProvider,
|
||||||
|
|
||||||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||||||
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null,
|
||||||
@@ -508,6 +687,10 @@ async function updateAccount(accountId, updates) {
|
|||||||
updates.schedulable = updates.schedulable.toString()
|
updates.schedulable = updates.schedulable.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updates.oauthProvider !== undefined) {
|
||||||
|
updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider)
|
||||||
|
}
|
||||||
|
|
||||||
// 加密敏感字段
|
// 加密敏感字段
|
||||||
if (updates.geminiOauth) {
|
if (updates.geminiOauth) {
|
||||||
updates.geminiOauth = encrypt(
|
updates.geminiOauth = encrypt(
|
||||||
@@ -885,12 +1068,13 @@ async function refreshAccountToken(accountId) {
|
|||||||
// 重新获取账户数据(可能已被其他进程刷新)
|
// 重新获取账户数据(可能已被其他进程刷新)
|
||||||
const updatedAccount = await getAccount(accountId)
|
const updatedAccount = await getAccount(accountId)
|
||||||
if (updatedAccount && updatedAccount.accessToken) {
|
if (updatedAccount && updatedAccount.accessToken) {
|
||||||
|
const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider)
|
||||||
const accessToken = decrypt(updatedAccount.accessToken)
|
const accessToken = decrypt(updatedAccount.accessToken)
|
||||||
return {
|
return {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '',
|
||||||
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
|
expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0,
|
||||||
scope: updatedAccount.scope || OAUTH_SCOPES.join(' '),
|
scope: updatedAccount.scopes || oauthConfig.scopes.join(' '),
|
||||||
token_type: 'Bearer'
|
token_type: 'Bearer'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -904,7 +1088,11 @@ async function refreshAccountToken(accountId) {
|
|||||||
|
|
||||||
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
|
||||||
// 传入账户的代理配置
|
// 传入账户的代理配置
|
||||||
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
|
const newTokens = await refreshAccessToken(
|
||||||
|
account.refreshToken,
|
||||||
|
account.proxy,
|
||||||
|
account.oauthProvider
|
||||||
|
)
|
||||||
|
|
||||||
// 更新账户信息
|
// 更新账户信息
|
||||||
const updates = {
|
const updates = {
|
||||||
@@ -1036,14 +1224,15 @@ async function getAccountRateLimitInfo(accountId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理)
|
||||||
async function getOauthClient(accessToken, refreshToken, proxyConfig = null) {
|
async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) {
|
||||||
const client = createOAuth2Client(null, proxyConfig)
|
const normalizedProvider = normalizeOauthProvider(oauthProvider)
|
||||||
|
const oauthConfig = getOauthProviderConfig(normalizedProvider)
|
||||||
|
const client = createOAuth2Client(null, proxyConfig, normalizedProvider)
|
||||||
|
|
||||||
const creds = {
|
const creds = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope:
|
scope: oauthConfig.scopes.join(' '),
|
||||||
'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
|
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expiry_date: 1754269905646
|
expiry_date: 1754269905646
|
||||||
}
|
}
|
||||||
@@ -1509,6 +1698,43 @@ async function generateContent(
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调用 Antigravity 上游生成内容(非流式)
|
||||||
|
async function generateContentAntigravity(
|
||||||
|
client,
|
||||||
|
requestData,
|
||||||
|
userPromptId,
|
||||||
|
projectId = null,
|
||||||
|
sessionId = null,
|
||||||
|
proxyConfig = null
|
||||||
|
) {
|
||||||
|
const { token } = await client.getAccessToken()
|
||||||
|
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('🪐 Antigravity generateContent API调用开始', {
|
||||||
|
model,
|
||||||
|
userPromptId,
|
||||||
|
projectId,
|
||||||
|
sessionId
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response } = await antigravityClient.request({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig,
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
logger.info('✅ Antigravity generateContent API调用成功')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// 调用 Code Assist API 生成内容(流式)
|
// 调用 Code Assist API 生成内容(流式)
|
||||||
async function generateContentStream(
|
async function generateContentStream(
|
||||||
client,
|
client,
|
||||||
@@ -1593,6 +1819,46 @@ async function generateContentStream(
|
|||||||
return response.data // 返回流对象
|
return response.data // 返回流对象
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调用 Antigravity 上游生成内容(流式)
|
||||||
|
async function generateContentStreamAntigravity(
|
||||||
|
client,
|
||||||
|
requestData,
|
||||||
|
userPromptId,
|
||||||
|
projectId = null,
|
||||||
|
sessionId = null,
|
||||||
|
signal = null,
|
||||||
|
proxyConfig = null
|
||||||
|
) {
|
||||||
|
const { token } = await client.getAccessToken()
|
||||||
|
const { model } = antigravityClient.buildAntigravityEnvelope({
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('🌊 Antigravity streamGenerateContent API调用开始', {
|
||||||
|
model,
|
||||||
|
userPromptId,
|
||||||
|
projectId,
|
||||||
|
sessionId
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response } = await antigravityClient.request({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig,
|
||||||
|
requestData,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
userPromptId,
|
||||||
|
stream: true,
|
||||||
|
signal,
|
||||||
|
params: { alt: 'sse' }
|
||||||
|
})
|
||||||
|
logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// 更新账户的临时项目 ID
|
// 更新账户的临时项目 ID
|
||||||
async function updateTempProjectId(accountId, tempProjectId) {
|
async function updateTempProjectId(accountId, tempProjectId) {
|
||||||
if (!tempProjectId) {
|
if (!tempProjectId) {
|
||||||
@@ -1687,10 +1953,12 @@ module.exports = {
|
|||||||
generateEncryptionKey,
|
generateEncryptionKey,
|
||||||
decryptCache, // 暴露缓存对象以便测试和监控
|
decryptCache, // 暴露缓存对象以便测试和监控
|
||||||
countTokens,
|
countTokens,
|
||||||
|
countTokensAntigravity,
|
||||||
generateContent,
|
generateContent,
|
||||||
generateContentStream,
|
generateContentStream,
|
||||||
|
generateContentAntigravity,
|
||||||
|
generateContentStreamAntigravity,
|
||||||
|
fetchAvailableModelsAntigravity,
|
||||||
updateTempProjectId,
|
updateTempProjectId,
|
||||||
resetAccountStatus,
|
resetAccountStatus
|
||||||
OAUTH_CLIENT_ID,
|
|
||||||
OAUTH_SCOPES
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,35 @@ const accountGroupService = require('./accountGroupService')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||||
|
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||||
|
const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY]
|
||||||
|
|
||||||
|
function normalizeOauthProvider(oauthProvider) {
|
||||||
|
if (!oauthProvider) {
|
||||||
|
return OAUTH_PROVIDER_GEMINI_CLI
|
||||||
|
}
|
||||||
|
return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
? OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
: OAUTH_PROVIDER_GEMINI_CLI
|
||||||
|
}
|
||||||
|
|
||||||
class UnifiedGeminiScheduler {
|
class UnifiedGeminiScheduler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'
|
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getSessionMappingKey(sessionHash, oauthProvider = null) {
|
||||||
|
if (!sessionHash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!oauthProvider) {
|
||||||
|
return `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||||
|
}
|
||||||
|
const normalized = normalizeOauthProvider(oauthProvider)
|
||||||
|
return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}`
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||||
_isSchedulable(schedulable) {
|
_isSchedulable(schedulable) {
|
||||||
// 如果是 undefined 或 null,默认为可调度
|
// 如果是 undefined 或 null,默认为可调度
|
||||||
@@ -32,7 +56,8 @@ class UnifiedGeminiScheduler {
|
|||||||
requestedModel = null,
|
requestedModel = null,
|
||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
const { allowApiAccounts = false } = options
|
const { allowApiAccounts = false, oauthProvider = null } = options
|
||||||
|
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果API Key绑定了专属账户或分组,优先使用
|
// 如果API Key绑定了专属账户或分组,优先使用
|
||||||
@@ -83,6 +108,14 @@ class UnifiedGeminiScheduler {
|
|||||||
this._isActive(boundAccount.isActive) &&
|
this._isActive(boundAccount.isActive) &&
|
||||||
boundAccount.status !== 'error'
|
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(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
|
||||||
)
|
)
|
||||||
@@ -92,6 +125,7 @@ class UnifiedGeminiScheduler {
|
|||||||
accountId: apiKeyData.geminiAccountId,
|
accountId: apiKeyData.geminiAccountId,
|
||||||
accountType: 'gemini'
|
accountType: 'gemini'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool`
|
`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool`
|
||||||
@@ -102,7 +136,7 @@ class UnifiedGeminiScheduler {
|
|||||||
|
|
||||||
// 如果有会话哈希,检查是否有已映射的账户
|
// 如果有会话哈希,检查是否有已映射的账户
|
||||||
if (sessionHash) {
|
if (sessionHash) {
|
||||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider)
|
||||||
if (mappedAccount) {
|
if (mappedAccount) {
|
||||||
// 验证映射的账户是否仍然可用
|
// 验证映射的账户是否仍然可用
|
||||||
const isAvailable = await this._isAccountAvailable(
|
const isAvailable = await this._isAccountAvailable(
|
||||||
@@ -111,7 +145,7 @@ class UnifiedGeminiScheduler {
|
|||||||
)
|
)
|
||||||
if (isAvailable) {
|
if (isAvailable) {
|
||||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||||
await this._extendSessionMappingTTL(sessionHash)
|
await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider)
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||||
)
|
)
|
||||||
@@ -132,11 +166,10 @@ class UnifiedGeminiScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有可用账户
|
// 获取所有可用账户
|
||||||
const availableAccounts = await this._getAllAvailableAccounts(
|
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, {
|
||||||
apiKeyData,
|
allowApiAccounts,
|
||||||
requestedModel,
|
oauthProvider: normalizedOauthProvider
|
||||||
allowApiAccounts
|
})
|
||||||
)
|
|
||||||
|
|
||||||
if (availableAccounts.length === 0) {
|
if (availableAccounts.length === 0) {
|
||||||
// 提供更详细的错误信息
|
// 提供更详细的错误信息
|
||||||
@@ -160,7 +193,8 @@ class UnifiedGeminiScheduler {
|
|||||||
await this._setSessionMapping(
|
await this._setSessionMapping(
|
||||||
sessionHash,
|
sessionHash,
|
||||||
selectedAccount.accountId,
|
selectedAccount.accountId,
|
||||||
selectedAccount.accountType
|
selectedAccount.accountType,
|
||||||
|
normalizedOauthProvider
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
|
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
|
||||||
@@ -189,7 +223,18 @@ class UnifiedGeminiScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📋 获取所有可用账户
|
// 📋 获取所有可用账户
|
||||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) {
|
async _getAllAvailableAccounts(
|
||||||
|
apiKeyData,
|
||||||
|
requestedModel = null,
|
||||||
|
allowApiAccountsOrOptions = false
|
||||||
|
) {
|
||||||
|
const options =
|
||||||
|
allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object'
|
||||||
|
? allowApiAccountsOrOptions
|
||||||
|
: { allowApiAccounts: allowApiAccountsOrOptions }
|
||||||
|
const { allowApiAccounts = false, oauthProvider = null } = options
|
||||||
|
const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null
|
||||||
|
|
||||||
const availableAccounts = []
|
const availableAccounts = []
|
||||||
|
|
||||||
// 如果API Key绑定了专属账户,优先返回
|
// 如果API Key绑定了专属账户,优先返回
|
||||||
@@ -254,6 +299,12 @@ class UnifiedGeminiScheduler {
|
|||||||
this._isActive(boundAccount.isActive) &&
|
this._isActive(boundAccount.isActive) &&
|
||||||
boundAccount.status !== 'error'
|
boundAccount.status !== 'error'
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
normalizedOauthProvider &&
|
||||||
|
normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider
|
||||||
|
) {
|
||||||
|
return availableAccounts
|
||||||
|
}
|
||||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||||
if (!isRateLimited) {
|
if (!isRateLimited) {
|
||||||
// 检查模型支持
|
// 检查模型支持
|
||||||
@@ -303,6 +354,12 @@ class UnifiedGeminiScheduler {
|
|||||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||||
this._isSchedulable(account.schedulable)
|
this._isSchedulable(account.schedulable)
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
normalizedOauthProvider &&
|
||||||
|
normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// 检查是否可调度
|
// 检查是否可调度
|
||||||
|
|
||||||
// 检查token是否过期
|
// 检查token是否过期
|
||||||
@@ -437,9 +494,10 @@ class UnifiedGeminiScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔗 获取会话映射
|
// 🔗 获取会话映射
|
||||||
async _getSessionMapping(sessionHash) {
|
async _getSessionMapping(sessionHash, oauthProvider = null) {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||||
|
const mappingData = key ? await client.get(key) : null
|
||||||
|
|
||||||
if (mappingData) {
|
if (mappingData) {
|
||||||
try {
|
try {
|
||||||
@@ -454,27 +512,42 @@ class UnifiedGeminiScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 💾 设置会话映射
|
// 💾 设置会话映射
|
||||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
const mappingData = JSON.stringify({ accountId, accountType })
|
const mappingData = JSON.stringify({ accountId, accountType })
|
||||||
// 依据配置设置TTL(小时)
|
// 依据配置设置TTL(小时)
|
||||||
const appConfig = require('../../config/config')
|
const appConfig = require('../../config/config')
|
||||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||||
|
if (!key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await client.setex(key, ttlSeconds, mappingData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🗑️ 删除会话映射
|
// 🗑️ 删除会话映射
|
||||||
async _deleteSessionMapping(sessionHash) {
|
async _deleteSessionMapping(sessionHash) {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
if (!sessionHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [this._getSessionMappingKey(sessionHash)]
|
||||||
|
for (const provider of KNOWN_OAUTH_PROVIDERS) {
|
||||||
|
keys.push(this._getSessionMappingKey(sessionHash, provider))
|
||||||
|
}
|
||||||
|
await client.del(keys.filter(Boolean))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置
|
// 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置
|
||||||
async _extendSessionMappingTTL(sessionHash) {
|
async _extendSessionMappingTTL(sessionHash, oauthProvider = null) {
|
||||||
try {
|
try {
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||||
|
if (!key) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const remainingTTL = await client.ttl(key)
|
const remainingTTL = await client.ttl(key)
|
||||||
|
|
||||||
if (remainingTTL === -2) {
|
if (remainingTTL === -2) {
|
||||||
|
|||||||
126
src/utils/anthropicRequestDump.js
Normal file
126
src/utils/anthropicRequestDump.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
|
||||||
|
const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP'
|
||||||
|
const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES'
|
||||||
|
const REQUEST_DUMP_FILENAME = 'anthropic-requests-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[REQUEST_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return raw === '1' || raw.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[REQUEST_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const str = String(value)
|
||||||
|
if (str.length <= 8) {
|
||||||
|
return '***'
|
||||||
|
}
|
||||||
|
return `${str.slice(0, 4)}...${str.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHeaders(headers) {
|
||||||
|
const sensitive = new Set([
|
||||||
|
'authorization',
|
||||||
|
'proxy-authorization',
|
||||||
|
'x-api-key',
|
||||||
|
'cookie',
|
||||||
|
'set-cookie',
|
||||||
|
'x-forwarded-for',
|
||||||
|
'x-real-ip'
|
||||||
|
])
|
||||||
|
|
||||||
|
const out = {}
|
||||||
|
for (const [k, v] of Object.entries(headers || {})) {
|
||||||
|
const key = k.toLowerCase()
|
||||||
|
if (sensitive.has(key)) {
|
||||||
|
out[key] = maskSecret(v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_request_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_request_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicMessagesRequest(req, meta = {}) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), REQUEST_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
method: req?.method || null,
|
||||||
|
url: req?.originalUrl || req?.url || null,
|
||||||
|
ip: req?.ip || null,
|
||||||
|
meta,
|
||||||
|
headers: sanitizeHeaders(req?.headers || {}),
|
||||||
|
body: req?.body || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Anthropic request', {
|
||||||
|
filename,
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAnthropicMessagesRequest,
|
||||||
|
REQUEST_DUMP_ENV,
|
||||||
|
REQUEST_DUMP_MAX_BYTES_ENV,
|
||||||
|
REQUEST_DUMP_FILENAME
|
||||||
|
}
|
||||||
125
src/utils/anthropicResponseDump.js
Normal file
125
src/utils/anthropicResponseDump.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
|
||||||
|
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
|
||||||
|
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
|
||||||
|
const RESPONSE_DUMP_FILENAME = 'anthropic-responses-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[RESPONSE_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return raw === '1' || raw.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[RESPONSE_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_response_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'anthropic_response_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeAnthropicResponseBody(body) {
|
||||||
|
const content = Array.isArray(body?.content) ? body.content : []
|
||||||
|
const toolUses = content.filter((b) => b && b.type === 'tool_use')
|
||||||
|
const texts = content
|
||||||
|
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: body?.id || null,
|
||||||
|
model: body?.model || null,
|
||||||
|
stop_reason: body?.stop_reason || null,
|
||||||
|
usage: body?.usage || null,
|
||||||
|
content_blocks: content.map((b) => (b ? b.type : null)).filter(Boolean),
|
||||||
|
tool_use_names: toolUses.map((b) => b.name).filter(Boolean),
|
||||||
|
text_preview: texts ? texts.slice(0, 800) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
url: req?.originalUrl || req?.url || null,
|
||||||
|
meta,
|
||||||
|
response: responseInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Anthropic response', {
|
||||||
|
filename,
|
||||||
|
requestId: req?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicNonStreamResponse(req, statusCode, body, meta = {}) {
|
||||||
|
return dumpAnthropicResponse(
|
||||||
|
req,
|
||||||
|
{ kind: 'non-stream', statusCode, summary: summarizeAnthropicResponseBody(body), body },
|
||||||
|
meta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicStreamSummary(req, summary, meta = {}) {
|
||||||
|
return dumpAnthropicResponse(req, { kind: 'stream', summary }, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAnthropicStreamError(req, error, meta = {}) {
|
||||||
|
return dumpAnthropicResponse(req, { kind: 'stream-error', error }, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAnthropicNonStreamResponse,
|
||||||
|
dumpAnthropicStreamSummary,
|
||||||
|
dumpAnthropicStreamError,
|
||||||
|
RESPONSE_DUMP_ENV,
|
||||||
|
RESPONSE_DUMP_MAX_BYTES_ENV,
|
||||||
|
RESPONSE_DUMP_FILENAME
|
||||||
|
}
|
||||||
138
src/utils/antigravityModel.js
Normal file
138
src/utils/antigravityModel.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
const DEFAULT_ANTIGRAVITY_MODEL = 'gemini-2.5-flash'
|
||||||
|
|
||||||
|
const UPSTREAM_TO_ALIAS = {
|
||||||
|
'rev19-uic3-1p': 'gemini-2.5-computer-use-preview-10-2025',
|
||||||
|
'gemini-3-pro-image': 'gemini-3-pro-image-preview',
|
||||||
|
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
||||||
|
'gemini-3-flash': 'gemini-3-flash-preview',
|
||||||
|
'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5',
|
||||||
|
'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking',
|
||||||
|
'claude-opus-4-5-thinking': 'gemini-claude-opus-4-5-thinking',
|
||||||
|
chat_20706: '',
|
||||||
|
chat_23310: '',
|
||||||
|
'gemini-2.5-flash-thinking': '',
|
||||||
|
'gemini-3-pro-low': '',
|
||||||
|
'gemini-2.5-pro': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIAS_TO_UPSTREAM = {
|
||||||
|
'gemini-2.5-computer-use-preview-10-2025': 'rev19-uic3-1p',
|
||||||
|
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
|
||||||
|
'gemini-3-pro-preview': 'gemini-3-pro-high',
|
||||||
|
'gemini-3-flash-preview': 'gemini-3-flash',
|
||||||
|
'gemini-claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||||
|
'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||||
|
'gemini-claude-opus-4-5-thinking': 'claude-opus-4-5-thinking'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANTIGRAVITY_MODEL_METADATA = {
|
||||||
|
'gemini-2.5-flash': {
|
||||||
|
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
|
||||||
|
name: 'models/gemini-2.5-flash'
|
||||||
|
},
|
||||||
|
'gemini-2.5-flash-lite': {
|
||||||
|
thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true },
|
||||||
|
name: 'models/gemini-2.5-flash-lite'
|
||||||
|
},
|
||||||
|
'gemini-2.5-computer-use-preview-10-2025': {
|
||||||
|
name: 'models/gemini-2.5-computer-use-preview-10-2025'
|
||||||
|
},
|
||||||
|
'gemini-3-pro-preview': {
|
||||||
|
thinking: {
|
||||||
|
min: 128,
|
||||||
|
max: 32768,
|
||||||
|
zeroAllowed: false,
|
||||||
|
dynamicAllowed: true,
|
||||||
|
levels: ['low', 'high']
|
||||||
|
},
|
||||||
|
name: 'models/gemini-3-pro-preview'
|
||||||
|
},
|
||||||
|
'gemini-3-pro-image-preview': {
|
||||||
|
thinking: {
|
||||||
|
min: 128,
|
||||||
|
max: 32768,
|
||||||
|
zeroAllowed: false,
|
||||||
|
dynamicAllowed: true,
|
||||||
|
levels: ['low', 'high']
|
||||||
|
},
|
||||||
|
name: 'models/gemini-3-pro-image-preview'
|
||||||
|
},
|
||||||
|
'gemini-3-flash-preview': {
|
||||||
|
thinking: {
|
||||||
|
min: 128,
|
||||||
|
max: 32768,
|
||||||
|
zeroAllowed: false,
|
||||||
|
dynamicAllowed: true,
|
||||||
|
levels: ['minimal', 'low', 'medium', 'high']
|
||||||
|
},
|
||||||
|
name: 'models/gemini-3-flash-preview'
|
||||||
|
},
|
||||||
|
'gemini-claude-sonnet-4-5-thinking': {
|
||||||
|
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
|
||||||
|
maxCompletionTokens: 64000
|
||||||
|
},
|
||||||
|
'gemini-claude-opus-4-5-thinking': {
|
||||||
|
thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true },
|
||||||
|
maxCompletionTokens: 64000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAntigravityModelInput(model, defaultModel = DEFAULT_ANTIGRAVITY_MODEL) {
|
||||||
|
if (!model) {
|
||||||
|
return defaultModel
|
||||||
|
}
|
||||||
|
return model.startsWith('models/') ? model.slice('models/'.length) : model
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityModelAlias(modelName) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(modelName)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(UPSTREAM_TO_ALIAS, normalized)) {
|
||||||
|
return UPSTREAM_TO_ALIAS[normalized]
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityModelMetadata(modelName) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(modelName)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, normalized)) {
|
||||||
|
return ANTIGRAVITY_MODEL_METADATA[normalized]
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('claude-')) {
|
||||||
|
const prefixed = `gemini-${normalized}`
|
||||||
|
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, prefixed)) {
|
||||||
|
return ANTIGRAVITY_MODEL_METADATA[prefixed]
|
||||||
|
}
|
||||||
|
const thinkingAlias = `${prefixed}-thinking`
|
||||||
|
if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, thinkingAlias)) {
|
||||||
|
return ANTIGRAVITY_MODEL_METADATA[thinkingAlias]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAntigravityUpstreamModel(model) {
|
||||||
|
const normalized = normalizeAntigravityModelInput(model)
|
||||||
|
let upstream = Object.prototype.hasOwnProperty.call(ALIAS_TO_UPSTREAM, normalized)
|
||||||
|
? ALIAS_TO_UPSTREAM[normalized]
|
||||||
|
: normalized
|
||||||
|
|
||||||
|
if (upstream.startsWith('gemini-claude-')) {
|
||||||
|
upstream = upstream.replace(/^gemini-/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = {
|
||||||
|
// Opus:上游更常见的是 thinking 变体(CLIProxyAPI 也按此处理)
|
||||||
|
'claude-opus-4-5': 'claude-opus-4-5-thinking',
|
||||||
|
// Gemini thinking 变体回退
|
||||||
|
'gemini-2.5-flash-thinking': 'gemini-2.5-flash'
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping[upstream] || upstream
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeAntigravityModelInput,
|
||||||
|
getAntigravityModelAlias,
|
||||||
|
getAntigravityModelMetadata,
|
||||||
|
mapAntigravityUpstreamModel
|
||||||
|
}
|
||||||
121
src/utils/antigravityUpstreamDump.js
Normal file
121
src/utils/antigravityUpstreamDump.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
|
||||||
|
const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP'
|
||||||
|
const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES'
|
||||||
|
const UPSTREAM_REQUEST_DUMP_FILENAME = 'antigravity-upstream-requests-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[UPSTREAM_REQUEST_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const normalized = String(raw).trim().toLowerCase()
|
||||||
|
return normalized === '1' || normalized === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(value) {
|
||||||
|
if (!value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const s = String(value)
|
||||||
|
if (s.length <= 10) {
|
||||||
|
return '***'
|
||||||
|
}
|
||||||
|
return `${s.slice(0, 3)}...${s.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpAntigravityUpstreamRequest(requestInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_REQUEST_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_upstream_request',
|
||||||
|
requestId: requestInfo?.requestId || null,
|
||||||
|
model: requestInfo?.model || null,
|
||||||
|
stream: Boolean(requestInfo?.stream),
|
||||||
|
url: requestInfo?.url || null,
|
||||||
|
baseUrl: requestInfo?.baseUrl || null,
|
||||||
|
params: requestInfo?.params || null,
|
||||||
|
headers: requestInfo?.headers
|
||||||
|
? {
|
||||||
|
Host: requestInfo.headers.Host || requestInfo.headers.host || null,
|
||||||
|
'User-Agent':
|
||||||
|
requestInfo.headers['User-Agent'] || requestInfo.headers['user-agent'] || null,
|
||||||
|
Authorization: (() => {
|
||||||
|
const raw = requestInfo.headers.Authorization || requestInfo.headers.authorization
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const value = String(raw)
|
||||||
|
const m = value.match(/^Bearer\\s+(.+)$/i)
|
||||||
|
const token = m ? m[1] : value
|
||||||
|
return `Bearer ${redact(token)}`
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
envelope: requestInfo?.envelope || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Antigravity upstream request', {
|
||||||
|
filename,
|
||||||
|
requestId: requestInfo?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAntigravityUpstreamRequest,
|
||||||
|
UPSTREAM_REQUEST_DUMP_ENV,
|
||||||
|
UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV,
|
||||||
|
UPSTREAM_REQUEST_DUMP_FILENAME
|
||||||
|
}
|
||||||
@@ -55,16 +55,69 @@ function sanitizeUpstreamError(errorData) {
|
|||||||
return errorData
|
return errorData
|
||||||
}
|
}
|
||||||
|
|
||||||
// 深拷贝避免修改原始对象
|
// AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息
|
||||||
const sanitized = JSON.parse(JSON.stringify(errorData))
|
const looksLikeAxiosError =
|
||||||
|
errorData.isAxiosError ||
|
||||||
|
(errorData.name === 'AxiosError' && (errorData.config || errorData.response))
|
||||||
|
const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string'
|
||||||
|
|
||||||
|
if (looksLikeAxiosError || looksLikeError) {
|
||||||
|
const statusCode = errorData.response?.status
|
||||||
|
const upstreamBody = errorData.response?.data
|
||||||
|
const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: errorData.name || 'Error',
|
||||||
|
code: errorData.code,
|
||||||
|
statusCode,
|
||||||
|
message: sanitizeErrorMessage(errorData.message || ''),
|
||||||
|
upstreamMessage: upstreamMessage || undefined,
|
||||||
|
upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 递归清理嵌套的错误对象
|
// 递归清理嵌套的错误对象
|
||||||
|
const visited = new WeakSet()
|
||||||
|
|
||||||
|
const shouldRedactKey = (key) => {
|
||||||
|
if (!key) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const lowerKey = String(key).toLowerCase()
|
||||||
|
return (
|
||||||
|
lowerKey === 'authorization' ||
|
||||||
|
lowerKey === 'cookie' ||
|
||||||
|
lowerKey.includes('api_key') ||
|
||||||
|
lowerKey.includes('apikey') ||
|
||||||
|
lowerKey.includes('access_token') ||
|
||||||
|
lowerKey.includes('refresh_token') ||
|
||||||
|
lowerKey.endsWith('token') ||
|
||||||
|
lowerKey.includes('secret') ||
|
||||||
|
lowerKey.includes('password')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizeObject = (obj) => {
|
const sanitizeObject = (obj) => {
|
||||||
if (!obj || typeof obj !== 'object') {
|
if (!obj || typeof obj !== 'object') {
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (visited.has(obj)) {
|
||||||
|
return '[Circular]'
|
||||||
|
}
|
||||||
|
visited.add(obj)
|
||||||
|
|
||||||
|
// 主动剔除常见“超大且敏感”的字段
|
||||||
|
if (obj.config || obj.request || obj.response) {
|
||||||
|
return '[Redacted]'
|
||||||
|
}
|
||||||
|
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
|
if (shouldRedactKey(key)) {
|
||||||
|
obj[key] = '[REDACTED]'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 清理所有字符串字段,不仅仅是 message
|
// 清理所有字符串字段,不仅仅是 message
|
||||||
if (typeof obj[key] === 'string') {
|
if (typeof obj[key] === 'string') {
|
||||||
obj[key] = sanitizeErrorMessage(obj[key])
|
obj[key] = sanitizeErrorMessage(obj[key])
|
||||||
@@ -76,7 +129,9 @@ function sanitizeUpstreamError(errorData) {
|
|||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitizeObject(sanitized)
|
// 尽量不修改原对象:浅拷贝后递归清理
|
||||||
|
const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData }
|
||||||
|
return sanitizeObject(clone)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
265
src/utils/geminiSchemaCleaner.js
Normal file
265
src/utils/geminiSchemaCleaner.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
function appendHint(description, hint) {
|
||||||
|
if (!hint) {
|
||||||
|
return description || ''
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
return `${description} (${hint})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefHint(refValue) {
|
||||||
|
const ref = String(refValue || '')
|
||||||
|
if (!ref) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const idx = ref.lastIndexOf('/')
|
||||||
|
const name = idx >= 0 ? ref.slice(idx + 1) : ref
|
||||||
|
return name ? `See: ${name}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(typeValue) {
|
||||||
|
if (typeof typeValue === 'string' && typeValue) {
|
||||||
|
return { type: typeValue, hint: '' }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(typeValue) || typeValue.length === 0) {
|
||||||
|
return { type: '', hint: '' }
|
||||||
|
}
|
||||||
|
const raw = typeValue.map((t) => (t === null || t === undefined ? '' : String(t))).filter(Boolean)
|
||||||
|
const hasNull = raw.includes('null')
|
||||||
|
const nonNull = raw.filter((t) => t !== 'null')
|
||||||
|
const primary = nonNull[0] || 'string'
|
||||||
|
const hintParts = []
|
||||||
|
if (nonNull.length > 1) {
|
||||||
|
hintParts.push(`Accepts: ${nonNull.join(' | ')}`)
|
||||||
|
}
|
||||||
|
if (hasNull) {
|
||||||
|
hintParts.push('nullable')
|
||||||
|
}
|
||||||
|
return { type: primary, hint: hintParts.join('; ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSTRAINT_KEYS = [
|
||||||
|
'minLength',
|
||||||
|
'maxLength',
|
||||||
|
'exclusiveMinimum',
|
||||||
|
'exclusiveMaximum',
|
||||||
|
'pattern',
|
||||||
|
'minItems',
|
||||||
|
'maxItems'
|
||||||
|
]
|
||||||
|
|
||||||
|
function scoreSchema(schema) {
|
||||||
|
if (!schema || typeof schema !== 'object') {
|
||||||
|
return { score: 0, type: '' }
|
||||||
|
}
|
||||||
|
const t = typeof schema.type === 'string' ? schema.type : ''
|
||||||
|
if (t === 'object' || (schema.properties && typeof schema.properties === 'object')) {
|
||||||
|
return { score: 3, type: t || 'object' }
|
||||||
|
}
|
||||||
|
if (t === 'array' || schema.items) {
|
||||||
|
return { score: 2, type: t || 'array' }
|
||||||
|
}
|
||||||
|
if (t && t !== 'null') {
|
||||||
|
return { score: 1, type: t }
|
||||||
|
}
|
||||||
|
return { score: 0, type: t || 'null' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestFromAlternatives(alternatives) {
|
||||||
|
let bestIndex = 0
|
||||||
|
let bestScore = -1
|
||||||
|
const types = []
|
||||||
|
for (let i = 0; i < alternatives.length; i += 1) {
|
||||||
|
const alt = alternatives[i]
|
||||||
|
const { score, type } = scoreSchema(alt)
|
||||||
|
if (type) {
|
||||||
|
types.push(type)
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { best: alternatives[bestIndex], types: Array.from(new Set(types)).filter(Boolean) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanJsonSchemaForGemini(schema) {
|
||||||
|
if (schema === null || schema === undefined) {
|
||||||
|
return { type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
if (typeof schema !== 'object') {
|
||||||
|
return { type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
if (Array.isArray(schema)) {
|
||||||
|
return { type: 'object', properties: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// $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,6 +5,10 @@
|
|||||||
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 仅保留原仓库既有的模型前缀:CCR 路由
|
||||||
|
// Gemini/Antigravity 采用“路径分流”,避免在 model 字段里混入 vendor 前缀造成混乱
|
||||||
|
const SUPPORTED_VENDOR_PREFIXES = ['ccr']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse vendor-prefixed model string
|
* Parse vendor-prefixed model string
|
||||||
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
||||||
@@ -19,18 +23,23 @@ function parseVendorPrefixedModel(modelStr) {
|
|||||||
const trimmed = modelStr.trim()
|
const trimmed = modelStr.trim()
|
||||||
const lowerTrimmed = trimmed.toLowerCase()
|
const lowerTrimmed = trimmed.toLowerCase()
|
||||||
|
|
||||||
// Check for ccr prefix (case insensitive)
|
for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) {
|
||||||
if (lowerTrimmed.startsWith('ccr,')) {
|
if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const parts = trimmed.split(',')
|
const parts = trimmed.split(',')
|
||||||
if (parts.length >= 2) {
|
if (parts.length < 2) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||||
const baseModel = parts.slice(1).join(',').trim()
|
const baseModel = parts.slice(1).join(',').trim()
|
||||||
return {
|
return {
|
||||||
vendor: 'ccr',
|
vendor: vendorPrefix,
|
||||||
baseModel
|
baseModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No recognized vendor prefix found
|
// No recognized vendor prefix found
|
||||||
return {
|
return {
|
||||||
|
|||||||
10
src/utils/projectPaths.js
Normal file
10
src/utils/projectPaths.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// 该文件位于 src/utils 下,向上两级即项目根目录。
|
||||||
|
function getProjectRoot() {
|
||||||
|
return path.resolve(__dirname, '..', '..')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getProjectRoot
|
||||||
|
}
|
||||||
@@ -62,12 +62,17 @@ class ClaudeCodeValidator {
|
|||||||
|
|
||||||
for (const entry of systemEntries) {
|
for (const entry of systemEntries) {
|
||||||
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)
|
||||||
if (bestScore < threshold) {
|
if (bestScore < threshold) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||||
)
|
)
|
||||||
logger.warn(`Claude system prompt detail: ${rawText}`)
|
const preview = typeof maskedRaw === 'string' ? maskedRaw.slice(0, 200) : ''
|
||||||
|
logger.warn(
|
||||||
|
`Claude system prompt detail: templateId=${templateId || 'unknown'}, preview=${preview}${
|
||||||
|
maskedRaw && maskedRaw.length > 200 ? '…' : ''
|
||||||
|
}`
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,8 +125,12 @@ class CodexCliValidator {
|
|||||||
const part1 = parts1[i] || 0
|
const part1 = parts1[i] || 0
|
||||||
const part2 = parts2[i] || 0
|
const part2 = parts2[i] || 0
|
||||||
|
|
||||||
if (part1 < part2) return -1
|
if (part1 < part2) {
|
||||||
if (part1 > part2) return 1
|
return -1
|
||||||
|
}
|
||||||
|
if (part1 > part2) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class GeminiCliValidator {
|
|||||||
// 2. 对于 /gemini 路径,检查是否包含 generateContent
|
// 2. 对于 /gemini 路径,检查是否包含 generateContent
|
||||||
if (path.includes('generateContent')) {
|
if (path.includes('generateContent')) {
|
||||||
// 包含 generateContent 的路径需要验证 User-Agent
|
// 包含 generateContent 的路径需要验证 User-Agent
|
||||||
const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i
|
const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i
|
||||||
if (!geminiCliPattern.test(userAgent)) {
|
if (!geminiCliPattern.test(userAgent)) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
|
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
|
||||||
@@ -84,8 +84,12 @@ class GeminiCliValidator {
|
|||||||
const part1 = parts1[i] || 0
|
const part1 = parts1[i] || 0
|
||||||
const part2 = parts2[i] || 0
|
const part2 = parts2[i] || 0
|
||||||
|
|
||||||
if (part1 < part2) return -1
|
if (part1 < part2) {
|
||||||
if (part1 > part2) return 1
|
return -1
|
||||||
|
}
|
||||||
|
if (part1 > part2) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -477,6 +477,36 @@
|
|||||||
<i class="fas fa-check text-xs text-white"></i>
|
<i class="fas fa-check text-xs text-white"></i>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label
|
||||||
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||||
|
:class="[
|
||||||
|
form.platform === 'gemini-antigravity'
|
||||||
|
? 'border-purple-500 bg-purple-50 dark:border-purple-400 dark:bg-purple-900/30'
|
||||||
|
: 'border-gray-300 bg-white hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-purple-500 dark:hover:bg-purple-900/20'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.platform"
|
||||||
|
class="sr-only"
|
||||||
|
type="radio"
|
||||||
|
value="gemini-antigravity"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-rocket text-sm text-purple-600 dark:text-purple-400"></i>
|
||||||
|
<div>
|
||||||
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>Antigravity</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">OAuth</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="form.platform === 'gemini-antigravity'"
|
||||||
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check text-xs text-white"></i>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||||
@@ -772,7 +802,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gemini 项目 ID 字段 -->
|
<!-- Gemini 项目 ID 字段 -->
|
||||||
<div v-if="form.platform === 'gemini'">
|
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>项目 ID (可选)</label
|
>项目 ID (可选)</label
|
||||||
>
|
>
|
||||||
@@ -1783,7 +1813,7 @@
|
|||||||
Token,建议也一并填写以支持自动刷新。
|
Token,建议也一并填写以支持自动刷新。
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-else-if="form.platform === 'gemini'"
|
v-else-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'"
|
||||||
class="mb-2 text-sm text-blue-800 dark:text-blue-300"
|
class="mb-2 text-sm text-blue-800 dark:text-blue-300"
|
||||||
>
|
>
|
||||||
请输入有效的 Gemini Access Token。如果您有 Refresh
|
请输入有效的 Gemini Access Token。如果您有 Refresh
|
||||||
@@ -1820,7 +1850,9 @@
|
|||||||
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
|
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-else-if="form.platform === 'gemini'"
|
v-else-if="
|
||||||
|
form.platform === 'gemini' || form.platform === 'gemini-antigravity'
|
||||||
|
"
|
||||||
class="text-xs text-blue-800 dark:text-blue-300"
|
class="text-xs text-blue-800 dark:text-blue-300"
|
||||||
>
|
>
|
||||||
请从已登录 Gemini CLI 的机器上获取
|
请从已登录 Gemini CLI 的机器上获取
|
||||||
@@ -2550,7 +2582,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gemini 项目 ID 字段 -->
|
<!-- Gemini 项目 ID 字段 -->
|
||||||
<div v-if="form.platform === 'gemini'">
|
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>项目 ID (可选)</label
|
>项目 ID (可选)</label
|
||||||
>
|
>
|
||||||
@@ -3801,7 +3833,7 @@ const determinePlatformGroup = (platform) => {
|
|||||||
return 'claude'
|
return 'claude'
|
||||||
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
|
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
|
||||||
return 'openai'
|
return 'openai'
|
||||||
} else if (['gemini', 'gemini-api'].includes(platform)) {
|
} else if (['gemini', 'gemini-antigravity', 'gemini-api'].includes(platform)) {
|
||||||
return 'gemini'
|
return 'gemini'
|
||||||
} else if (platform === 'droid') {
|
} else if (platform === 'droid') {
|
||||||
return 'droid'
|
return 'droid'
|
||||||
@@ -3936,7 +3968,8 @@ const form = ref({
|
|||||||
platform: props.account?.platform || 'claude',
|
platform: props.account?.platform || 'claude',
|
||||||
addType: (() => {
|
addType: (() => {
|
||||||
const platform = props.account?.platform || 'claude'
|
const platform = props.account?.platform || 'claude'
|
||||||
if (platform === 'gemini' || platform === 'openai') return 'oauth'
|
if (platform === 'gemini' || platform === 'gemini-antigravity' || platform === 'openai')
|
||||||
|
return 'oauth'
|
||||||
if (platform === 'claude') return 'oauth'
|
if (platform === 'claude') return 'oauth'
|
||||||
return 'manual'
|
return 'manual'
|
||||||
})(),
|
})(),
|
||||||
@@ -4275,7 +4308,7 @@ const selectPlatformGroup = (group) => {
|
|||||||
} else if (group === 'openai') {
|
} else if (group === 'openai') {
|
||||||
form.value.platform = 'openai'
|
form.value.platform = 'openai'
|
||||||
} else if (group === 'gemini') {
|
} else if (group === 'gemini') {
|
||||||
form.value.platform = 'gemini'
|
form.value.platform = 'gemini' // Default to Gemini CLI, user can select Antigravity
|
||||||
} else if (group === 'droid') {
|
} else if (group === 'droid') {
|
||||||
form.value.platform = 'droid'
|
form.value.platform = 'droid'
|
||||||
}
|
}
|
||||||
@@ -4312,7 +4345,11 @@ const nextStep = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 对于Gemini账户,检查项目 ID
|
// 对于Gemini账户,检查项目 ID
|
||||||
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
|
if (
|
||||||
|
(form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') &&
|
||||||
|
oauthStep.value === 1 &&
|
||||||
|
form.value.addType === 'oauth'
|
||||||
|
) {
|
||||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||||
// 使用自定义确认弹窗
|
// 使用自定义确认弹窗
|
||||||
const confirmed = await showConfirm(
|
const confirmed = await showConfirm(
|
||||||
@@ -4682,9 +4719,14 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
|
|||||||
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||||
manuallySet: true // 标记为手动设置
|
manuallySet: true // 标记为手动设置
|
||||||
}
|
}
|
||||||
} else if (currentPlatform === 'gemini') {
|
} else if (currentPlatform === 'gemini' || currentPlatform === 'gemini-antigravity') {
|
||||||
// Gemini使用geminiOauth字段
|
// Gemini/Antigravity使用geminiOauth字段
|
||||||
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
||||||
|
// 根据 platform 设置 oauthProvider
|
||||||
|
data.oauthProvider =
|
||||||
|
currentPlatform === 'gemini-antigravity'
|
||||||
|
? 'antigravity'
|
||||||
|
: tokenInfo.oauthProvider || 'gemini-cli'
|
||||||
if (form.value.projectId) {
|
if (form.value.projectId) {
|
||||||
data.projectId = form.value.projectId
|
data.projectId = form.value.projectId
|
||||||
}
|
}
|
||||||
@@ -5104,6 +5146,10 @@ const createAccount = async () => {
|
|||||||
data.rateLimitDuration = 60 // 默认值60,不从用户输入获取
|
data.rateLimitDuration = 60 // 默认值60,不从用户输入获取
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
|
} else if (form.value.platform === 'gemini-antigravity') {
|
||||||
|
// Antigravity OAuth - set oauthProvider, submission happens below
|
||||||
|
data.oauthProvider = 'antigravity'
|
||||||
|
data.priority = form.value.priority || 50
|
||||||
} else if (form.value.platform === 'gemini-api') {
|
} else if (form.value.platform === 'gemini-api') {
|
||||||
// Gemini API 账户特定数据
|
// Gemini API 账户特定数据
|
||||||
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
|
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
|
||||||
@@ -5155,7 +5201,7 @@ const createAccount = async () => {
|
|||||||
result = await accountsStore.createOpenAIAccount(data)
|
result = await accountsStore.createOpenAIAccount(data)
|
||||||
} else if (form.value.platform === 'azure_openai') {
|
} else if (form.value.platform === 'azure_openai') {
|
||||||
result = await accountsStore.createAzureOpenAIAccount(data)
|
result = await accountsStore.createAzureOpenAIAccount(data)
|
||||||
} else if (form.value.platform === 'gemini') {
|
} else if (form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') {
|
||||||
result = await accountsStore.createGeminiAccount(data)
|
result = await accountsStore.createGeminiAccount(data)
|
||||||
} else if (form.value.platform === 'gemini-api') {
|
} else if (form.value.platform === 'gemini-api') {
|
||||||
result = await accountsStore.createGeminiApiAccount(data)
|
result = await accountsStore.createGeminiApiAccount(data)
|
||||||
|
|||||||
@@ -303,6 +303,16 @@
|
|||||||
请按照以下步骤完成 Gemini 账户的授权:
|
请按照以下步骤完成 Gemini 账户的授权:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- 授权来源显示(由平台类型决定) -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-sm text-green-800 dark:text-green-300">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
授权类型:<span class="font-semibold">{{
|
||||||
|
platform === 'gemini-antigravity' ? 'Antigravity OAuth' : 'Gemini CLI OAuth'
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- 步骤1: 生成授权链接 -->
|
<!-- 步骤1: 生成授权链接 -->
|
||||||
<div
|
<div
|
||||||
@@ -818,6 +828,13 @@ const exchanging = ref(false)
|
|||||||
const authUrl = ref('')
|
const authUrl = ref('')
|
||||||
const authCode = ref('')
|
const authCode = ref('')
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
// oauthProvider is now derived from platform prop
|
||||||
|
const geminiOauthProvider = computed(() => {
|
||||||
|
if (props.platform === 'gemini-antigravity') {
|
||||||
|
return 'antigravity'
|
||||||
|
}
|
||||||
|
return 'gemini-cli'
|
||||||
|
})
|
||||||
const sessionId = ref('') // 保存sessionId用于后续交换
|
const sessionId = ref('') // 保存sessionId用于后续交换
|
||||||
const userCode = ref('')
|
const userCode = ref('')
|
||||||
const verificationUri = ref('')
|
const verificationUri = ref('')
|
||||||
@@ -921,7 +938,11 @@ watch(authCode, (newValue) => {
|
|||||||
console.error('Failed to parse URL:', error)
|
console.error('Failed to parse URL:', error)
|
||||||
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
|
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
|
||||||
}
|
}
|
||||||
} else if (props.platform === 'gemini' || props.platform === 'openai') {
|
} else if (
|
||||||
|
props.platform === 'gemini' ||
|
||||||
|
props.platform === 'gemini-antigravity' ||
|
||||||
|
props.platform === 'openai'
|
||||||
|
) {
|
||||||
// Gemini 和 OpenAI 平台可能使用不同的回调URL
|
// Gemini 和 OpenAI 平台可能使用不同的回调URL
|
||||||
// 尝试从任何URL中提取code参数
|
// 尝试从任何URL中提取code参数
|
||||||
try {
|
try {
|
||||||
@@ -972,8 +993,11 @@ const generateAuthUrl = async () => {
|
|||||||
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
|
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
|
||||||
authUrl.value = result.authUrl
|
authUrl.value = result.authUrl
|
||||||
sessionId.value = result.sessionId
|
sessionId.value = result.sessionId
|
||||||
} else if (props.platform === 'gemini') {
|
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||||
const result = await accountsStore.generateGeminiAuthUrl(proxyConfig)
|
const result = await accountsStore.generateGeminiAuthUrl({
|
||||||
|
...proxyConfig,
|
||||||
|
oauthProvider: geminiOauthProvider.value
|
||||||
|
})
|
||||||
authUrl.value = result.authUrl
|
authUrl.value = result.authUrl
|
||||||
sessionId.value = result.sessionId
|
sessionId.value = result.sessionId
|
||||||
} else if (props.platform === 'openai') {
|
} else if (props.platform === 'openai') {
|
||||||
@@ -996,6 +1020,8 @@ const generateAuthUrl = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onGeminiOauthProviderChange removed - oauthProvider is now computed from platform
|
||||||
|
|
||||||
// 重新生成授权URL
|
// 重新生成授权URL
|
||||||
const regenerateAuthUrl = () => {
|
const regenerateAuthUrl = () => {
|
||||||
stopCountdown()
|
stopCountdown()
|
||||||
@@ -1079,11 +1105,12 @@ const exchangeCode = async () => {
|
|||||||
sessionId: sessionId.value,
|
sessionId: sessionId.value,
|
||||||
callbackUrl: authCode.value.trim()
|
callbackUrl: authCode.value.trim()
|
||||||
}
|
}
|
||||||
} else if (props.platform === 'gemini') {
|
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||||
// Gemini使用code和sessionId
|
// Gemini/Antigravity使用code和sessionId
|
||||||
data = {
|
data = {
|
||||||
code: authCode.value.trim(),
|
code: authCode.value.trim(),
|
||||||
sessionId: sessionId.value
|
sessionId: sessionId.value,
|
||||||
|
oauthProvider: geminiOauthProvider.value
|
||||||
}
|
}
|
||||||
} else if (props.platform === 'openai') {
|
} else if (props.platform === 'openai') {
|
||||||
// OpenAI使用code和sessionId
|
// OpenAI使用code和sessionId
|
||||||
@@ -1111,8 +1138,12 @@ const exchangeCode = async () => {
|
|||||||
let tokenInfo
|
let tokenInfo
|
||||||
if (props.platform === 'claude') {
|
if (props.platform === 'claude') {
|
||||||
tokenInfo = await accountsStore.exchangeClaudeCode(data)
|
tokenInfo = await accountsStore.exchangeClaudeCode(data)
|
||||||
} else if (props.platform === 'gemini') {
|
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
|
||||||
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
||||||
|
// 附加 oauthProvider 信息到 tokenInfo
|
||||||
|
if (tokenInfo) {
|
||||||
|
tokenInfo.oauthProvider = geminiOauthProvider.value
|
||||||
|
}
|
||||||
} else if (props.platform === 'openai') {
|
} else if (props.platform === 'openai') {
|
||||||
tokenInfo = await accountsStore.exchangeOpenAICode(data)
|
tokenInfo = await accountsStore.exchangeOpenAICode(data)
|
||||||
} else if (props.platform === 'droid') {
|
} else if (props.platform === 'droid') {
|
||||||
|
|||||||
Reference in New Issue
Block a user