diff --git a/.env.example b/.env.example index eeb10de0..014271fb 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,41 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages CLAUDE_API_VERSION=2023-06-01 CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14 +# 🤖 Gemini OAuth / Antigravity 配置(可选) +# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖 +# GEMINI_OAUTH_CLIENT_ID= +# GEMINI_OAUTH_CLIENT_SECRET= +# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode) +# GEMINI_OAUTH_REDIRECT_URI= +# ANTIGRAVITY_OAUTH_CLIENT_ID= +# ANTIGRAVITY_OAUTH_CLIENT_SECRET= +# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch) +# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462 +# Antigravity 上游地址(可选,默认 sandbox) +# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com +# Antigravity User-Agent(可选) +# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64 + +# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量): +# - /api -> Claude 账号池(默认) +# - /antigravity/api -> Antigravity OAuth +# - /gemini-cli/api -> Gemini CLI OAuth + +# (可选)Claude Code 调试 Dump:会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题 +# - anthropic-requests-dump.jsonl +# - anthropic-responses-dump.jsonl +# - anthropic-tools-dump.jsonl +# ANTHROPIC_DEBUG_REQUEST_DUMP=true +# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152 +# ANTHROPIC_DEBUG_RESPONSE_DUMP=true +# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152 +# ANTHROPIC_DEBUG_TOOLS_DUMP=true +# +# (可选)Antigravity 上游请求 Dump:会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload(含 tools/schema 清洗后的结果) +# - antigravity-upstream-requests-dump.jsonl +# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true +# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152 + # 🚫 529错误处理配置 # 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟) CLAUDE_OVERLOAD_HANDLING_MINUTES=0 diff --git a/README.md b/README.md index b2a318e9..7ddd8134 100644 --- a/README.md +++ b/README.md @@ -389,13 +389,31 @@ docker-compose.yml 已包含: **Claude Code 设置环境变量:** -默认使用标准 Claude 账号池: +默认使用标准 Claude 账号池(Claude/Console/Bedrock/CCR): ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" ``` +如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀): + +Antigravity OAuth(支持 `claude-opus-4-5` 等 Antigravity 模型): + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/" +export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)" +export ANTHROPIC_MODEL="claude-opus-4-5" +``` + +Gemini CLI OAuth(使用 Gemini 模型): + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/" +export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)" +export ANTHROPIC_MODEL="gemini-2.5-pro" +``` + **VSCode Claude 插件配置:** 如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置: diff --git a/README_EN.md b/README_EN.md index 477c2f52..f9a0e1c5 100644 --- a/README_EN.md +++ b/README_EN.md @@ -238,13 +238,31 @@ Now you can replace the official API with your own service: **Claude Code Set Environment Variables:** -Default uses standard Claude account pool: +Default uses standard Claude account pool (Claude/Console/Bedrock/CCR): ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain export ANTHROPIC_AUTH_TOKEN="API key created in the backend" ``` +If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`): + +Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models): + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/" +export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)" +export ANTHROPIC_MODEL="claude-opus-4-5" +``` + +Gemini CLI OAuth (Gemini models): + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/" +export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)" +export ANTHROPIC_MODEL="gemini-2.5-pro" +``` + **VSCode Claude Plugin Configuration:** If using VSCode Claude plugin, configure in `~/.claude/config.json`: @@ -604,4 +622,4 @@ This project uses the [MIT License](LICENSE). **🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions** - \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json index c6dccd11..4fa299a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -891,7 +891,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3000,7 +2999,6 @@ "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3082,7 +3080,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3538,7 +3535,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "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.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4483,7 +4478,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7582,7 +7576,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9101,7 +9094,6 @@ "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", diff --git a/src/app.js b/src/app.js index 7af1e7e9..1ea2f325 100644 --- a/src/app.js +++ b/src/app.js @@ -264,6 +264,25 @@ class Application { this.app.use('/api', apiRoutes) this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等) this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 + // Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户 + // - /antigravity/api/v1/messages -> Antigravity OAuth + // - /gemini-cli/api/v1/messages -> Gemini CLI OAuth + this.app.use( + '/antigravity/api', + (req, res, next) => { + req._anthropicVendor = 'antigravity' + next() + }, + apiRoutes + ) + this.app.use( + '/gemini-cli/api', + (req, res, next) => { + req._anthropicVendor = 'gemini-cli' + next() + }, + apiRoutes + ) this.app.use('/admin', adminRoutes) this.app.use('/users', userRoutes) // 使用 web 路由(包含 auth 和页面重定向) diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js index 87295d31..dc7dc676 100644 --- a/src/handlers/geminiHandlers.js +++ b/src/handlers/geminiHandlers.js @@ -9,6 +9,7 @@ const logger = require('../utils/logger') const geminiAccountService = require('../services/geminiAccountService') const geminiApiAccountService = require('../services/geminiApiAccountService') const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService') +const { sendAntigravityRequest } = require('../services/antigravityRelayService') const crypto = require('crypto') const sessionHelper = require('../utils/sessionHelper') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') @@ -508,20 +509,37 @@ async function handleMessages(req, res) { // OAuth 账户:使用现有的 sendGeminiRequest // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId const effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' - geminiResponse = await sendGeminiRequest({ - messages, - model, - temperature, - maxTokens: max_tokens, - stream, - accessToken: account.accessToken, - proxy: account.proxy, - apiKeyId: apiKeyData.id, - signal: abortController.signal, - projectId: effectiveProjectId, - accountId: account.id - }) + if (oauthProvider === 'antigravity') { + geminiResponse = await sendAntigravityRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id + }) + } else { + geminiResponse = await sendGeminiRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id + }) + } } if (stream) { @@ -754,8 +772,16 @@ async function handleModels(req, res) { ] } } else { - // OAuth 账户:使用 OAuth token 获取模型列表 - models = await getAvailableModels(account.accessToken, account.proxy) + // OAuth 账户:根据 OAuth provider 选择上游 + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) } res.json({ @@ -927,7 +953,8 @@ function handleSimpleEndpoint(apiMethod) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) // 直接转发请求体,不做特殊处理 @@ -1006,7 +1033,12 @@ async function handleLoadCodeAssist(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID const effectiveProjectId = projectId || cloudaicompanionProject || null @@ -1104,7 +1136,12 @@ async function handleOnboardUser(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID const effectiveProjectId = projectId || cloudaicompanionProject || null @@ -1256,7 +1293,8 @@ async function handleCountTokens(req, res) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) } @@ -1366,13 +1404,20 @@ async function handleGenerateContent(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId let effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 - if (!effectiveProjectId) { + if (!effectiveProjectId && oauthProvider !== 'antigravity') { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -1388,6 +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,返回错误 if (!effectiveProjectId) { return res.status(403).json({ @@ -1410,14 +1461,24 @@ async function handleGenerateContent(req, res) { : '从loadCodeAssist获取' }) - const response = await geminiAccountService.generateContent( - client, - { model, request: actualRequestData }, - user_prompt_id, - effectiveProjectId, - req.apiKey?.id, - proxyConfig - ) + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + : await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) // 记录使用统计 if (response?.response?.usageMetadata) { @@ -1578,13 +1639,20 @@ async function handleStreamGenerateContent(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId let effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 - if (!effectiveProjectId) { + if (!effectiveProjectId && oauthProvider !== 'antigravity') { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -1600,6 +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,返回错误 if (!effectiveProjectId) { return res.status(403).json({ @@ -1622,15 +1695,26 @@ async function handleStreamGenerateContent(req, res) { : '从loadCodeAssist获取' }) - const streamResponse = await geminiAccountService.generateContentStream( - client, - { model, request: actualRequestData }, - user_prompt_id, - effectiveProjectId, - req.apiKey?.id, - abortController.signal, - proxyConfig - ) + const streamResponse = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + : await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) // 设置 SSE 响应头 res.setHeader('Content-Type', 'text/event-stream') @@ -1978,15 +2062,23 @@ async function handleStandardGenerateContent(req, res) { } else { // OAuth 账户 const { accessToken, refreshToken } = account + const oauthProvider = account.oauthProvider || 'gemini-cli' const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + oauthProvider ) let effectiveProjectId = account.projectId || account.tempProjectId || null - if (!effectiveProjectId) { + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + // Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存 + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -2024,14 +2116,25 @@ async function handleStandardGenerateContent(req, res) { const userPromptId = `${crypto.randomUUID()}########0` - response = await geminiAccountService.generateContent( - client, - { model, request: actualRequestData }, - userPromptId, - effectiveProjectId, - req.apiKey?.id, - proxyConfig - ) + if (oauthProvider === 'antigravity') { + response = await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } else { + response = await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } } // 记录使用统计 @@ -2263,12 +2366,20 @@ async function handleStandardStreamGenerateContent(req, res) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) let effectiveProjectId = account.projectId || account.tempProjectId || null - if (!effectiveProjectId) { + const oauthProvider = account.oauthProvider || 'gemini-cli' + + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -2306,15 +2417,27 @@ async function handleStandardStreamGenerateContent(req, res) { const userPromptId = `${crypto.randomUUID()}########0` - streamResponse = await geminiAccountService.generateContentStream( - client, - { model, request: actualRequestData }, - userPromptId, - effectiveProjectId, - req.apiKey?.id, - abortController.signal, - proxyConfig - ) + if (oauthProvider === 'antigravity') { + streamResponse = await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + } else { + streamResponse = await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + } } // 设置 SSE 响应头 diff --git a/src/routes/admin/claudeAccounts.js b/src/routes/admin/claudeAccounts.js index 13dd1a63..3443d394 100644 --- a/src/routes/admin/claudeAccounts.js +++ b/src/routes/admin/claudeAccounts.js @@ -277,7 +277,7 @@ router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, logger.info('🍪 Starting Cookie-based OAuth authorization', { sessionKeyLength: trimmedSessionKey.length, - sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...', + sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`, hasProxy: !!proxy }) @@ -326,7 +326,7 @@ router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async logger.info('🍪 Starting Cookie-based Setup Token authorization', { sessionKeyLength: trimmedSessionKey.length, - sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...', + sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`, hasProxy: !!proxy }) diff --git a/src/routes/admin/dashboard.js b/src/routes/admin/dashboard.js index fe2cb440..d98ed761 100644 --- a/src/routes/admin/dashboard.js +++ b/src/routes/admin/dashboard.js @@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService') const ccrAccountService = require('../../services/ccrAccountService') const geminiAccountService = require('../../services/geminiAccountService') const droidAccountService = require('../../services/droidAccountService') -const openaiAccountService = require('../../services/openaiAccountService') const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const redis = require('../../models/redis') const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') const CostCalculator = require('../../utils/costCalculator') -const pricingService = require('../../services/pricingService') const config = require('../../../config/config') const router = express.Router() diff --git a/src/routes/admin/geminiAccounts.js b/src/routes/admin/geminiAccounts.js index 35419ef8..ce4d6fb9 100644 --- a/src/routes/admin/geminiAccounts.js +++ b/src/routes/admin/geminiAccounts.js @@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils') const router = express.Router() // 🤖 Gemini OAuth 账户管理 +function getDefaultRedirectUri(oauthProvider) { + if (oauthProvider === 'antigravity') { + return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462' + } + return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode' +} // 生成 Gemini OAuth 授权 URL router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { try { - const { state, proxy } = req.body // 接收代理配置 + const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider - // 使用新的 codeassist.google.com 回调地址 - const redirectUri = 'https://codeassist.google.com/authcode' + const redirectUri = getDefaultRedirectUri(oauthProvider) logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`) @@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { authUrl, state: authState, codeVerifier, - redirectUri: finalRedirectUri - } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy) + redirectUri: finalRedirectUri, + oauthProvider: resolvedOauthProvider + } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider) // 创建 OAuth 会话,包含 codeVerifier 和代理配置 const sessionId = authState @@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { redirectUri: finalRedirectUri, codeVerifier, // 保存 PKCE code verifier proxy: proxy || null, // 保存代理配置 + oauthProvider: resolvedOauthProvider, createdAt: new Date().toISOString() }) @@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { success: true, data: { authUrl, - sessionId + sessionId, + oauthProvider: resolvedOauthProvider } }) } catch (error) { @@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => { // 交换 Gemini 授权码 router.post('/exchange-code', authenticateAdmin, async (req, res) => { try { - const { code, sessionId, proxy: requestProxy } = req.body + const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body + let resolvedOauthProvider = oauthProvider if (!code) { return res.status(400).json({ error: 'Authorization code is required' }) } - let redirectUri = 'https://codeassist.google.com/authcode' + let redirectUri = getDefaultRedirectUri(resolvedOauthProvider) let codeVerifier = null let proxyConfig = null @@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => { const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier, - proxy + proxy, + oauthProvider: sessionOauthProvider } = sessionData redirectUri = sessionRedirectUri || redirectUri codeVerifier = sessionCodeVerifier proxyConfig = proxy // 获取代理配置 + if (!resolvedOauthProvider && sessionOauthProvider) { + // 会话里保存的 provider 仅作为兜底 + resolvedOauthProvider = sessionOauthProvider + } logger.info( `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}` ) @@ -120,7 +134,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => { code, redirectUri, codeVerifier, - proxyConfig // 传递代理配置 + proxyConfig, // 传递代理配置 + resolvedOauthProvider ) // 清理 OAuth 会话 @@ -129,7 +144,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => { } logger.success('✅ Successfully exchanged Gemini authorization code') - return res.json({ success: true, data: { tokens } }) + return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } }) } catch (error) { logger.error('❌ Failed to exchange Gemini authorization code:', error) return res.status(500).json({ error: 'Failed to exchange code', message: error.message }) diff --git a/src/routes/api.js b/src/routes/api.js index 3defdc19..adc49cae 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -13,6 +13,11 @@ const sessionHelper = require('../utils/sessionHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const claudeRelayConfigService = require('../services/claudeRelayConfigService') const { sanitizeUpstreamError } = require('../utils/errorSanitizer') +const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump') +const { + handleAnthropicMessagesToGemini, + handleAnthropicCountTokensToGemini +} = require('../services/anthropicGeminiBridgeService') const router = express.Router() function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { @@ -110,20 +115,6 @@ async function handleMessagesRequest(req, res) { try { const startTime = Date.now() - // Claude 服务权限校验,阻止未授权的 Key - if ( - req.apiKey.permissions && - req.apiKey.permissions !== 'all' && - req.apiKey.permissions !== 'claude' - ) { - return res.status(403).json({ - error: { - type: 'permission_error', - message: '此 API Key 无权访问 Claude 服务' - } - }) - } - // 🔄 并发满额重试标志:最多重试一次(使用req对象存储状态) if (req._concurrencyRetryAttempted === undefined) { req._concurrencyRetryAttempted = false @@ -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 @@ -985,8 +1020,8 @@ async function handleMessagesRequest(req, res) { const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 // Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro") const rawModel = jsonData.model || req.body.model || 'unknown' - const { baseModel } = parseVendorPrefixedModel(rawModel) - const model = baseModel || rawModel + const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel) + const model = usageBaseModel || rawModel // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) const { accountId: responseAccountId } = response @@ -1162,6 +1197,66 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) // 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini router.get('/v1/models', authenticateApiKey, async (req, res) => { try { + // Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表 + //(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。 + const forcedVendor = req._anthropicVendor || null + if (forcedVendor === 'antigravity') { + const permissions = req.apiKey?.permissions || 'all' + if (permissions !== 'all' && permissions !== 'gemini') { + return res.status(403).json({ + error: { + type: 'permission_error', + message: '此 API Key 无权访问 Gemini 服务' + } + }) + } + + const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') + const geminiAccountService = require('../services/geminiAccountService') + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + null, + null, + { oauthProvider: 'antigravity' } + ) + } catch (error) { + logger.error('Failed to select Gemini OAuth account (antigravity models):', error) + return res.status(503).json({ error: 'No available Gemini OAuth accounts' }) + } + + const account = await geminiAccountService.getAccount(accountSelection.accountId) + if (!account) { + return res.status(503).json({ error: 'Gemini OAuth account not found' }) + } + + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = + typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const models = await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + proxyConfig, + account.refreshToken + ) + + // 可选:根据 API Key 的模型限制过滤(黑名单语义) + let filteredModels = models + if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) { + filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id)) + } + + return res.json({ object: 'list', data: filteredModels }) + } + const modelService = require('../services/modelService') // 从 modelService 获取所有支持的模型 @@ -1298,6 +1393,22 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re // 🔢 Token计数端点 - count_tokens beta API router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { + // 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) + const forcedVendor = req._anthropicVendor || null + if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') { + const permissions = req.apiKey?.permissions || 'all' + if (permissions !== 'all' && permissions !== 'gemini') { + return res.status(403).json({ + error: { + type: 'permission_error', + message: 'This API key does not have permission to access Gemini' + } + }) + } + + return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor }) + } + // 检查权限 if ( req.apiKey.permissions && diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index fd74ad86..458aaadb 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -19,6 +19,16 @@ function generateSessionHash(req) { return crypto.createHash('sha256').update(sessionData).digest('hex') } +function ensureAntigravityProjectId(account) { + if (account.projectId) { + return account.projectId + } + if (account.tempProjectId) { + return account.tempProjectId + } + return `ag-${crypto.randomBytes(8).toString('hex')}` +} + // 检查 API Key 权限 function checkPermissions(apiKeyData, requiredPermission = 'gemini') { const permissions = apiKeyData.permissions || 'all' @@ -335,25 +345,48 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { const client = await geminiAccountService.getOauthClient( account.accessToken, account.refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) if (actualStream) { // 流式响应 + const oauthProvider = account.oauthProvider || 'gemini-cli' + let { projectId } = account + + if (oauthProvider === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + logger.info('StreamGenerateContent request', { model, - projectId: account.projectId, + projectId, apiKeyId: apiKeyData.id }) - const streamResponse = await geminiAccountService.generateContentStream( - client, - { model, request: geminiRequestBody }, - null, // user_prompt_id - account.projectId, // 使用有权限的项目ID - apiKeyData.id, // 使用 API Key ID 作为 session ID - abortController.signal, // 传递中止信号 - proxyConfig // 传递代理配置 - ) + const streamResponse = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, + apiKeyData.id, // 使用 API Key ID 作为 session ID + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 + ) + : await geminiAccountService.generateContentStream( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, // 使用有权限的项目ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 + ) // 设置流式响应头 res.setHeader('Content-Type', 'text/event-stream') @@ -559,20 +592,41 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { }) } else { // 非流式响应 + const oauthProvider = account.oauthProvider || 'gemini-cli' + let { projectId } = account + + if (oauthProvider === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + logger.info('GenerateContent request', { model, - projectId: account.projectId, + projectId, apiKeyId: apiKeyData.id }) - const response = await geminiAccountService.generateContent( - client, - { model, request: geminiRequestBody }, - null, // user_prompt_id - account.projectId, // 使用有权限的项目ID - apiKeyData.id, // 使用 API Key ID 作为 session ID - proxyConfig // 传递代理配置 - ) + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) + : await geminiAccountService.generateContent( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, // 使用有权限的项目ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) // 转换为 OpenAI 格式并返回 const openaiResponse = convertGeminiResponseToOpenAI(response, model, false) @@ -604,7 +658,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { const duration = Date.now() - startTime logger.info(`OpenAI-Gemini request completed in ${duration}ms`) } catch (error) { - logger.error('OpenAI-Gemini request error:', error) + const statusForLog = error?.status || error?.response?.status + logger.error('OpenAI-Gemini request error', { + message: error?.message, + status: statusForLog, + code: error?.code, + requestUrl: error?.config?.url, + requestMethod: error?.config?.method, + upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id'] + }) // 处理速率限制 if (error.status === 429) { @@ -665,8 +727,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { let models = [] if (account) { - // 获取实际的模型列表 - models = await getAvailableModels(account.accessToken, account.proxy) + // 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性) + try { + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) + } catch (error) { + logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error) + models = [] + } } else { // 返回默认模型列表 models = [ @@ -679,6 +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) { models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) diff --git a/src/services/anthropicGeminiBridgeService.js b/src/services/anthropicGeminiBridgeService.js new file mode 100644 index 00000000..5ae77006 --- /dev/null +++ b/src/services/anthropicGeminiBridgeService.js @@ -0,0 +1,1888 @@ +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const logger = require('../utils/logger') +const { getProjectRoot } = require('../utils/projectPaths') +const geminiAccountService = require('./geminiAccountService') +const unifiedGeminiScheduler = require('./unifiedGeminiScheduler') +const sessionHelper = require('../utils/sessionHelper') +const apiKeyService = require('./apiKeyService') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const { parseSSELine } = require('../utils/sseParser') +const { sanitizeUpstreamError } = require('../utils/errorSanitizer') +const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner') +const { + dumpAnthropicNonStreamResponse, + dumpAnthropicStreamSummary +} = require('../utils/anthropicResponseDump') + +const SUPPORTED_VENDORS = new Set(['gemini-cli', 'antigravity']) +const SYSTEM_REMINDER_PREFIX = '' +const TOOLS_DUMP_ENV = 'ANTHROPIC_DEBUG_TOOLS_DUMP' +const TOOLS_DUMP_FILENAME = 'anthropic-tools-dump.jsonl' +const TEXT_TOOL_FALLBACK_ENV = 'ANTHROPIC_TEXT_TOOL_FALLBACK' +const THOUGHT_SIGNATURE_FALLBACK = 'skip_thought_signature_validator' + +function ensureAntigravityProjectId(account) { + if (account.projectId) { + return account.projectId + } + if (account.tempProjectId) { + return account.tempProjectId + } + return `ag-${crypto.randomBytes(8).toString('hex')}` +} + +function extractAnthropicText(content) { + if (content === null || content === undefined) { + return '' + } + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return '' + } + return content + .filter((part) => part && part.type === 'text') + .map((part) => part.text || '') + .join('') +} + +function shouldSkipText(text) { + if (!text || typeof text !== 'string') { + return true + } + return text.trimStart().startsWith(SYSTEM_REMINDER_PREFIX) +} + +function buildSystemParts(system) { + const parts = [] + if (!system) { + return parts + } + if (Array.isArray(system)) { + for (const part of system) { + if (!part || part.type !== 'text') { + continue + } + const text = extractAnthropicText(part.text || '') + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + } + return parts + } + const text = extractAnthropicText(system) + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + return parts +} + +function buildToolUseIdToNameMap(messages) { + const toolUseIdToName = new Map() + + for (const message of messages || []) { + if (message?.role !== 'assistant') { + continue + } + const content = message?.content + if (!Array.isArray(content)) { + continue + } + for (const part of content) { + if (!part || part.type !== 'tool_use') { + continue + } + if (part.id && part.name) { + toolUseIdToName.set(part.id, part.name) + } + } + } + + return toolUseIdToName +} + +function normalizeToolUseInput(input) { + if (input === null || input === undefined) { + return {} + } + if (typeof input === 'object') { + return input + } + if (typeof input === 'string') { + const trimmed = input.trim() + if (!trimmed) { + return {} + } + try { + const parsed = JSON.parse(trimmed) + if (parsed && typeof parsed === 'object') { + return parsed + } + } catch (_) { + return {} + } + } + return {} +} + +const MAX_ANTIGRAVITY_TOOL_RESULT_CHARS = 200000 + +function truncateText(text, maxChars) { + if (!text || typeof text !== 'string') { + return '' + } + if (text.length <= maxChars) { + return text + } + return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]` +} + +function sanitizeToolResultBlocksForAntigravity(blocks) { + const cleaned = [] + let usedChars = 0 + let removedImage = false + + for (const block of blocks) { + if (!block || typeof block !== 'object') { + continue + } + + if ( + block.type === 'image' && + block.source?.type === 'base64' && + typeof block.source?.data === 'string' + ) { + removedImage = true + continue + } + + if (block.type === 'text' && typeof block.text === 'string') { + const remaining = MAX_ANTIGRAVITY_TOOL_RESULT_CHARS - usedChars + if (remaining <= 0) { + break + } + const text = truncateText(block.text, remaining) + cleaned.push({ ...block, text }) + usedChars += text.length + continue + } + + cleaned.push(block) + usedChars += 100 + if (usedChars >= MAX_ANTIGRAVITY_TOOL_RESULT_CHARS) { + break + } + } + + if (removedImage) { + cleaned.push({ + type: 'text', + text: '[image omitted to fit Antigravity prompt limits; use the file path in the previous text block]' + }) + } + + return cleaned +} + +function normalizeToolResultContent(content, { vendor = null } = {}) { + if (content === null || content === undefined) { + return '' + } + if (typeof content === 'string') { + if (vendor === 'antigravity') { + return truncateText(content, MAX_ANTIGRAVITY_TOOL_RESULT_CHARS) + } + return content + } + // Claude Code 的 tool_result.content 通常是 content blocks 数组(例如 [{type:"text",text:"..."}])。 + // 为对齐 CLIProxyAPI/Antigravity 的行为,这里优先保留原始 JSON 结构(数组/对象), + // 避免上游将其视为“无效 tool_result”从而触发 tool_use concurrency 400。 + if (Array.isArray(content) || (content && typeof content === 'object')) { + if (vendor === 'antigravity' && Array.isArray(content)) { + return sanitizeToolResultBlocksForAntigravity(content) + } + return content + } + return '' +} + +function normalizeAnthropicMessages(messages, { vendor = null } = {}) { + if (!Array.isArray(messages) || messages.length === 0) { + return messages + } + + const pendingToolUseIds = [] + const isIgnorableTrailingText = (part) => { + if (!part || part.type !== 'text') { + return false + } + if (typeof part.text !== 'string') { + return false + } + const trimmed = part.text.trim() + if (trimmed === '' || trimmed === '(no content)') { + return true + } + if (part.cache_control?.type === 'ephemeral' && trimmed === '(no content)') { + return true + } + return false + } + + const normalizeAssistantThinkingOrderForVendor = (parts) => { + if (vendor !== 'antigravity') { + return parts + } + const thinkingBlocks = [] + const otherBlocks = [] + for (const part of parts) { + if (!part) { + continue + } + if (part.type === 'thinking' || part.type === 'redacted_thinking') { + thinkingBlocks.push(part) + continue + } + if (isIgnorableTrailingText(part)) { + continue + } + otherBlocks.push(part) + } + if (thinkingBlocks.length === 0) { + return otherBlocks + } + return [...thinkingBlocks, ...otherBlocks] + } + + const stripNonToolPartsAfterToolUse = (parts) => { + let seenToolUse = false + const cleaned = [] + for (const part of parts) { + if (!part) { + continue + } + if (part.type === 'tool_use') { + seenToolUse = true + cleaned.push(part) + continue + } + if (!seenToolUse) { + cleaned.push(part) + continue + } + if (isIgnorableTrailingText(part)) { + continue + } + } + return cleaned + } + + const normalized = [] + + for (const message of messages) { + if (!message || !Array.isArray(message.content)) { + normalized.push(message) + continue + } + + let parts = message.content.filter(Boolean) + if (message.role === 'assistant') { + parts = normalizeAssistantThinkingOrderForVendor(parts) + } + + if (vendor === 'antigravity' && message.role === 'assistant') { + if (pendingToolUseIds.length > 0) { + normalized.push({ + role: 'user', + content: pendingToolUseIds.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + }) + pendingToolUseIds.length = 0 + } + + const stripped = stripNonToolPartsAfterToolUse(parts) + const toolUseIds = stripped + .filter((part) => part?.type === 'tool_use' && typeof part.id === 'string') + .map((part) => part.id) + if (toolUseIds.length > 0) { + pendingToolUseIds.push(...toolUseIds) + } + + normalized.push({ ...message, content: stripped }) + continue + } + + if (vendor === 'antigravity' && message.role === 'user' && pendingToolUseIds.length > 0) { + const toolResults = parts.filter((p) => p.type === 'tool_result') + const toolResultIds = new Set( + toolResults.map((p) => p.tool_use_id).filter((id) => typeof id === 'string') + ) + const missing = pendingToolUseIds.filter((id) => !toolResultIds.has(id)) + if (missing.length > 0) { + const synthetic = missing.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + parts = [...toolResults, ...synthetic, ...parts.filter((p) => p.type !== 'tool_result')] + } + pendingToolUseIds.length = 0 + } + + if (message.role !== 'user') { + normalized.push({ ...message, content: parts }) + continue + } + + const toolResults = parts.filter((p) => p.type === 'tool_result') + if (toolResults.length === 0) { + normalized.push({ ...message, content: parts }) + continue + } + + const nonToolResults = parts.filter((p) => p.type !== 'tool_result') + if (nonToolResults.length === 0) { + normalized.push({ ...message, content: toolResults }) + continue + } + + // Claude Code 可能把 tool_result 和下一条用户文本合并在同一个 user message 中。 + // 但上游(Antigravity/Claude)会按 Anthropic 规则校验:tool_use 后的下一条 message + // 必须只包含 tool_result blocks。这里做兼容拆分,避免 400 tool-use concurrency。 + normalized.push({ ...message, content: toolResults }) + normalized.push({ ...message, content: nonToolResults }) + } + + if (vendor === 'antigravity' && pendingToolUseIds.length > 0) { + normalized.push({ + role: 'user', + content: pendingToolUseIds.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + }) + pendingToolUseIds.length = 0 + } + + return normalized +} + +function convertAnthropicToolsToGeminiTools(tools, { vendor = null } = {}) { + if (!Array.isArray(tools) || tools.length === 0) { + return null + } + + // 说明:Gemini / Antigravity 对工具 schema 的接受程度不同;这里做“尽可能兼容”的最小清洗,降低 400 概率。 + const sanitizeSchemaForFunctionDeclarations = (schema) => { + const allowedKeys = new Set([ + 'type', + 'properties', + 'required', + 'description', + 'enum', + 'items', + 'anyOf', + 'oneOf', + 'allOf', + 'additionalProperties', + 'minimum', + 'maximum', + 'minItems', + 'maxItems', + 'minLength', + 'maxLength' + ]) + + if (schema === null || schema === undefined) { + return null + } + + // primitives: keep as-is (e.g. type/description/nullable/minimum...) + if (typeof schema !== 'object') { + return schema + } + + if (Array.isArray(schema)) { + return schema + .map((item) => sanitizeSchemaForFunctionDeclarations(item)) + .filter((item) => item !== null && item !== undefined) + } + + const sanitized = {} + for (const [key, value] of Object.entries(schema)) { + // Antigravity/Cloud Code 的 function_declarations.parameters 不接受 $schema / $id 等元字段 + if (key === '$schema' || key === '$id') { + continue + } + // 去除常见的非必要字段,减少上游 schema 校验失败概率 + if (key === 'title' || key === 'default' || key === 'examples' || key === 'example') { + continue + } + // 上游对 JSON Schema "format" 支持不稳定(特别是 format=uri),直接移除以降低 400 概率 + if (key === 'format') { + continue + } + if (!allowedKeys.has(key)) { + continue + } + + if (key === 'properties') { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const props = {} + for (const [propName, propSchema] of Object.entries(value)) { + const sanitizedProp = sanitizeSchemaForFunctionDeclarations(propSchema) + if (sanitizedProp && typeof sanitizedProp === 'object') { + props[propName] = sanitizedProp + } + } + sanitized.properties = props + } + continue + } + + if (key === 'required') { + if (Array.isArray(value)) { + const req = value.filter((item) => typeof item === 'string') + if (req.length > 0) { + sanitized.required = req + } + } + continue + } + + if (key === 'enum') { + if (Array.isArray(value)) { + const en = value.filter( + (item) => + typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean' + ) + if (en.length > 0) { + sanitized.enum = en + } + } + continue + } + + if (key === 'additionalProperties') { + if (typeof value === 'boolean') { + sanitized.additionalProperties = value + } else if (value && typeof value === 'object') { + const ap = sanitizeSchemaForFunctionDeclarations(value) + if (ap && typeof ap === 'object') { + sanitized.additionalProperties = ap + } + } + continue + } + + const sanitizedValue = sanitizeSchemaForFunctionDeclarations(value) + if (sanitizedValue === null || sanitizedValue === undefined) { + continue + } + sanitized[key] = sanitizedValue + } + + // 兜底:确保 schema 至少是一个 object schema + if (!sanitized.type) { + if (sanitized.properties || sanitized.required || sanitized.additionalProperties) { + sanitized.type = 'object' + } else if (sanitized.enum) { + sanitized.type = 'string' + } else { + sanitized.type = 'object' + sanitized.properties = {} + } + } + + if (sanitized.type === 'object' && !sanitized.properties) { + sanitized.properties = {} + } + + return sanitized + } + + const functionDeclarations = tools + .map((tool) => { + const toolDef = tool?.custom && typeof tool.custom === 'object' ? tool.custom : tool + if (!toolDef || !toolDef.name) { + return null + } + + const schema = + vendor === 'antigravity' + ? cleanJsonSchemaForGemini(toolDef.input_schema) + : sanitizeSchemaForFunctionDeclarations(toolDef.input_schema) || { + type: 'object', + properties: {} + } + + const baseDecl = { + name: toolDef.name, + description: toolDef.description || '' + } + + // CLIProxyAPI/Antigravity 侧使用 parametersJsonSchema(而不是 parameters)。 + if (vendor === 'antigravity') { + return { ...baseDecl, parametersJsonSchema: schema } + } + return { ...baseDecl, parameters: schema } + }) + .filter(Boolean) + + if (functionDeclarations.length === 0) { + return null + } + + return [ + { + functionDeclarations + } + ] +} + +function convertAnthropicToolChoiceToGeminiToolConfig(toolChoice) { + if (!toolChoice || typeof toolChoice !== 'object') { + return null + } + + const { type } = toolChoice + if (!type) { + return null + } + + if (type === 'auto') { + return { functionCallingConfig: { mode: 'AUTO' } } + } + + if (type === 'any') { + return { functionCallingConfig: { mode: 'ANY' } } + } + + if (type === 'tool') { + const { name } = toolChoice + if (!name) { + return { functionCallingConfig: { mode: 'ANY' } } + } + return { + functionCallingConfig: { + mode: 'ANY', + allowedFunctionNames: [name] + } + } + } + + if (type === 'none') { + return { functionCallingConfig: { mode: 'NONE' } } + } + + return null +} + +function convertAnthropicMessagesToGeminiContents( + messages, + toolUseIdToName, + { vendor = null } = {} +) { + const contents = [] + for (const message of messages || []) { + const role = message?.role === 'assistant' ? 'model' : 'user' + + const content = message?.content + const parts = [] + + if (typeof content === 'string') { + const text = extractAnthropicText(content) + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + } else if (Array.isArray(content)) { + for (const part of content) { + if (!part || !part.type) { + continue + } + + if (part.type === 'text') { + const text = extractAnthropicText(part.text || '') + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + continue + } + + if (part.type === 'thinking') { + const thinkingText = extractAnthropicText(part.thinking || part.text || '') + if (vendor === 'antigravity') { + const hasThinkingText = thinkingText && !shouldSkipText(thinkingText) + const hasSignature = typeof part.signature === 'string' && part.signature + + // Claude Code 有时会发送空的 thinking block(无 thinking / 无 signature)。 + // 传给 Antigravity 会变成仅含 thoughtSignature 的 part,容易触发 INVALID_ARGUMENT。 + if (!hasThinkingText && !hasSignature) { + continue + } + + const signature = hasSignature ? part.signature : THOUGHT_SIGNATURE_FALLBACK + const thoughtPart = { thought: true } + if (hasThinkingText) { + thoughtPart.text = thinkingText + } + if (signature) { + thoughtPart.thoughtSignature = signature + } + parts.push(thoughtPart) + } else if (thinkingText && !shouldSkipText(thinkingText)) { + parts.push({ text: thinkingText }) + } + continue + } + + if (part.type === 'image') { + const source = part.source || {} + if (source.type === 'base64' && source.data) { + const mediaType = source.media_type || source.mediaType || 'application/octet-stream' + const inlineData = + vendor === 'antigravity' + ? { mime_type: mediaType, data: source.data } + : { mimeType: mediaType, data: source.data } + parts.push({ inlineData }) + } + continue + } + + if (part.type === 'tool_use') { + if (part.name) { + const toolCallId = typeof part.id === 'string' && part.id ? part.id : undefined + const args = normalizeToolUseInput(part.input) + parts.push({ + functionCall: { + ...(vendor === 'antigravity' && toolCallId ? { id: toolCallId } : {}), + name: part.name, + args + } + }) + } + continue + } + + if (part.type === 'tool_result') { + const toolUseId = part.tool_use_id + const toolName = toolUseId ? toolUseIdToName.get(toolUseId) : null + if (!toolName) { + continue + } + + const raw = normalizeToolResultContent(part.content, { vendor }) + + let parsedResponse = null + if (raw && typeof raw === 'string') { + try { + parsedResponse = JSON.parse(raw) + } catch (_) { + parsedResponse = null + } + } + + if (vendor === 'antigravity') { + const toolCallId = typeof toolUseId === 'string' && toolUseId ? toolUseId : undefined + const result = parsedResponse !== null ? parsedResponse : raw || '' + + parts.push({ + functionResponse: { + ...(toolCallId ? { id: toolCallId } : {}), + name: toolName, + response: { result } + } + }) + } else { + const response = + parsedResponse !== null + ? parsedResponse + : { + content: raw || '', + is_error: part.is_error === true + } + + parts.push({ + functionResponse: { + name: toolName, + response + } + }) + } + } + } + } + + if (parts.length === 0) { + continue + } + contents.push({ + role, + parts + }) + } + return contents +} + +function buildGeminiRequestFromAnthropic(body, baseModel, { vendor = null } = {}) { + const normalizedMessages = normalizeAnthropicMessages(body.messages || [], { vendor }) + const toolUseIdToName = buildToolUseIdToNameMap(normalizedMessages || []) + const contents = convertAnthropicMessagesToGeminiContents( + normalizedMessages || [], + toolUseIdToName, + { + vendor + } + ) + const systemParts = buildSystemParts(body.system) + + const temperature = typeof body.temperature === 'number' ? body.temperature : 1 + const maxTokens = Number.isFinite(body.max_tokens) ? body.max_tokens : 4096 + + const generationConfig = { + temperature, + maxOutputTokens: maxTokens, + candidateCount: 1 + } + + if (typeof body.top_p === 'number') { + generationConfig.topP = body.top_p + } + if (typeof body.top_k === 'number') { + generationConfig.topK = body.top_k + } + + if (vendor === 'antigravity' && body?.thinking && typeof body.thinking === 'object') { + if (body.thinking.type === 'enabled') { + const budgetRaw = Number(body.thinking.budget_tokens) + if (Number.isFinite(budgetRaw)) { + generationConfig.thinkingConfig = { + thinkingBudget: Math.trunc(budgetRaw), + include_thoughts: true + } + } + } + } + + const geminiRequestBody = { + contents, + generationConfig + } + + if (systemParts.length > 0) { + geminiRequestBody.systemInstruction = + vendor === 'antigravity' ? { role: 'user', parts: systemParts } : { parts: systemParts } + } + + const geminiTools = convertAnthropicToolsToGeminiTools(body.tools, { vendor }) + if (geminiTools) { + geminiRequestBody.tools = geminiTools + } + + const toolConfig = convertAnthropicToolChoiceToGeminiToolConfig(body.tool_choice) + if (toolConfig) { + geminiRequestBody.toolConfig = toolConfig + } else if (geminiTools) { + // Anthropic 的默认语义是 tools 存在且未设置 tool_choice 时为 auto。 + // Gemini/Antigravity 的 function calling 默认可能不会启用,因此显式设置为 AUTO,避免“永远不产出 tool_use”。 + geminiRequestBody.toolConfig = { functionCallingConfig: { mode: 'AUTO' } } + } + + return { model: baseModel, request: geminiRequestBody } +} + +function extractGeminiText(payload, { includeThought = false } = {}) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + return parts + .filter( + (part) => typeof part?.text === 'string' && part.text && (includeThought || !part.thought) + ) + .map((part) => part.text) + .filter(Boolean) + .join('') +} + +function extractGeminiThoughtText(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + return parts + .filter((part) => part?.thought && typeof part?.text === 'string' && part.text) + .map((part) => part.text) + .filter(Boolean) + .join('') +} + +function extractGeminiThoughtSignature(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + for (const part of parts) { + if (!part || !part.thought) { + continue + } + const signature = part.thoughtSignature || part.thought_signature || part.signature || '' + if (signature) { + return signature + } + } + return '' +} + +function resolveUsageOutputTokens(usageMetadata) { + if (!usageMetadata || typeof usageMetadata !== 'object') { + return 0 + } + const promptTokens = usageMetadata.promptTokenCount || 0 + const candidateTokens = usageMetadata.candidatesTokenCount || 0 + const thoughtTokens = usageMetadata.thoughtsTokenCount || 0 + const totalTokens = usageMetadata.totalTokenCount || 0 + + let outputTokens = candidateTokens + thoughtTokens + if (outputTokens === 0 && totalTokens > 0) { + outputTokens = totalTokens - promptTokens + if (outputTokens < 0) { + outputTokens = 0 + } + } + return outputTokens +} + +function isEnvEnabled(value) { + if (!value) { + return false + } + const normalized = String(value).trim().toLowerCase() + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on' +} + +function tryExtractWriteToolFromText(text, fallbackCwd) { + if (!text || typeof text !== 'string') { + return null + } + + const lines = text.split(/\r?\n/) + const index = lines.findIndex((line) => /^\s*Write\s*:\s*/i.test(line)) + if (index < 0) { + return null + } + + const header = lines[index] + const rawPath = header.replace(/^\s*Write\s*:\s*/i, '').trim() + if (!rawPath) { + return null + } + + const content = lines.slice(index + 1).join('\n') + const prefixText = lines.slice(0, index).join('\n').trim() + + // Claude Code 的 Write 工具要求绝对路径。若模型给的是相对路径,仅在本地运行代理时可用; + // 这里提供一个可选回退:使用服务端 cwd 解析。 + let filePath = rawPath + if (!path.isAbsolute(filePath) && fallbackCwd) { + filePath = path.resolve(fallbackCwd, filePath) + } + + return { + prefixText: prefixText || '', + tool: { + name: 'Write', + input: { + file_path: filePath, + content: content || '' + } + } + } +} + +function mapGeminiFinishReasonToAnthropicStopReason(finishReason) { + const normalized = (finishReason || '').toString().toUpperCase() + if (normalized === 'MAX_TOKENS') { + return 'max_tokens' + } + return 'end_turn' +} + +function buildToolUseId() { + return `toolu_${crypto.randomBytes(10).toString('hex')}` +} + +function stableJsonStringify(value) { + if (value === null || value === undefined) { + return 'null' + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJsonStringify(item)).join(',')}]` + } + if (typeof value === 'object') { + const keys = Object.keys(value).sort() + const pairs = keys.map((key) => `${JSON.stringify(key)}:${stableJsonStringify(value[key])}`) + return `{${pairs.join(',')}}` + } + return JSON.stringify(value) +} + +function extractGeminiParts(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return [] + } + return parts +} + +function convertGeminiPayloadToAnthropicContent(payload) { + const parts = extractGeminiParts(payload) + const content = [] + let currentText = '' + let currentThinking = '' + let thinkingSignature = '' + + const flushText = () => { + if (!currentText) { + return + } + content.push({ type: 'text', text: currentText }) + currentText = '' + } + + const flushThinking = () => { + if (!currentThinking && !thinkingSignature) { + return + } + const block = { type: 'thinking', thinking: currentThinking } + if (thinkingSignature) { + block.signature = thinkingSignature + } + content.push(block) + currentThinking = '' + thinkingSignature = '' + } + + for (const part of parts) { + const isThought = part?.thought === true + if (isThought) { + flushText() + const signature = part.thoughtSignature || part.thought_signature || part.signature || '' + if (signature) { + thinkingSignature = signature + } + if (typeof part?.text === 'string' && part.text) { + currentThinking += part.text + } + continue + } + + if (typeof part?.text === 'string' && part.text) { + flushThinking() + currentText += part.text + continue + } + + const functionCall = part?.functionCall + if (functionCall?.name) { + flushThinking() + flushText() + const toolUseId = + typeof functionCall.id === 'string' && functionCall.id ? functionCall.id : buildToolUseId() + content.push({ + type: 'tool_use', + id: toolUseId, + name: functionCall.name, + input: functionCall.args || {} + }) + } + } + + flushThinking() + flushText() + const thinkingBlocks = content.filter( + (b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking') + ) + if (thinkingBlocks.length > 0) { + const firstType = content?.[0]?.type + if (firstType !== 'thinking' && firstType !== 'redacted_thinking') { + const others = content.filter( + (b) => b && b.type !== 'thinking' && b.type !== 'redacted_thinking' + ) + return [...thinkingBlocks, ...others] + } + } + return content +} + +function buildAnthropicError(message) { + return { + type: 'error', + error: { + type: 'api_error', + message: message || 'Upstream error' + } + } +} + +function shouldRetryWithoutTools(sanitizedError) { + const message = (sanitizedError?.upstreamMessage || sanitizedError?.message || '').toLowerCase() + if (!message) { + return false + } + return ( + message.includes('json schema is invalid') || + message.includes('invalid json payload') || + message.includes('tools.') || + message.includes('function_declarations') + ) +} + +function stripToolsFromRequest(requestData) { + if (!requestData || !requestData.request) { + return requestData + } + const nextRequest = { + ...requestData, + request: { + ...requestData.request + } + } + delete nextRequest.request.tools + delete nextRequest.request.toolConfig + return nextRequest +} + +function writeAnthropicSseEvent(res, event, data) { + res.write(`event: ${event}\n`) + res.write(`data: ${JSON.stringify(data)}\n\n`) +} + +function dumpToolsPayload({ vendor, model, tools, toolChoice }) { + if (!isEnvEnabled(process.env[TOOLS_DUMP_ENV])) { + return + } + if (!Array.isArray(tools) || tools.length === 0) { + return + } + if (vendor !== 'antigravity') { + return + } + + const filePath = path.join(getProjectRoot(), TOOLS_DUMP_FILENAME) + const payload = { + timestamp: new Date().toISOString(), + vendor, + model, + tool_choice: toolChoice || null, + tools + } + + try { + fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, 'utf8') + logger.warn(`🧾 Tools payload dumped to ${filePath}`) + } catch (error) { + logger.warn('Failed to dump tools payload:', error.message) + } +} + +async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') { + if (!rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model + ) + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + } +} + +async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel }) { + if (!SUPPORTED_VENDORS.has(vendor)) { + return res.status(400).json(buildAnthropicError(`Unsupported vendor: ${vendor}`)) + } + + dumpToolsPayload({ + vendor, + model: baseModel, + tools: req.body?.tools || null, + toolChoice: req.body?.tool_choice || null + }) + + const pickFallbackModel = (account, requestedModel) => { + const supportedModels = Array.isArray(account?.supportedModels) ? account.supportedModels : [] + if (supportedModels.length === 0) { + return requestedModel + } + + const normalize = (m) => String(m || '').replace(/^models\//, '') + const requested = normalize(requestedModel) + const normalizedSupported = supportedModels.map(normalize) + + if (normalizedSupported.includes(requested)) { + return requestedModel + } + + // Claude Code 常见探测模型:优先回退到 Opus 4.5(如果账号支持) + const preferred = ['claude-opus-4-5', 'claude-sonnet-4-5-thinking', 'claude-sonnet-4-5'] + for (const candidate of preferred) { + if (normalizedSupported.includes(candidate)) { + return candidate + } + } + + return normalizedSupported[0] + } + + const isStream = req.body?.stream === true + const sessionHash = sessionHelper.generateSessionHash(req.body) + const upstreamSessionId = sessionHash || req.apiKey?.id || null + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + baseModel, + { oauthProvider: vendor } + ) + } catch (error) { + logger.error('Failed to select Gemini account (via /v1/messages):', error) + return res + .status(503) + .json(buildAnthropicError(error.message || 'No available Gemini accounts')) + } + + const { accountId, accountType } = accountSelection + if (accountType !== 'gemini') { + return res + .status(400) + .json(buildAnthropicError('Only Gemini OAuth accounts are supported for this vendor')) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json(buildAnthropicError('Gemini OAuth account not found')) + } + + await geminiAccountService.markAccountUsed(account.id) + + 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 client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig, + account.oauthProvider + ) + + let { projectId } = account + if (vendor === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + + const effectiveModel = pickFallbackModel(account, baseModel) + if (effectiveModel !== baseModel) { + logger.warn('⚠️ Requested model not supported by account, falling back', { + requestedModel: baseModel, + effectiveModel, + vendor, + accountId + }) + } + + let requestData = buildGeminiRequestFromAnthropic(req.body, effectiveModel, { vendor }) + + // Antigravity 上游对 function calling 的启用/校验更严格:参考实现普遍使用 VALIDATED。 + // 这里仅在 tools 存在且未显式禁用(tool_choice=none)时应用,避免破坏原始语义。 + if ( + vendor === 'antigravity' && + Array.isArray(requestData?.request?.tools) && + requestData.request.tools.length > 0 + ) { + const existingCfg = requestData?.request?.toolConfig?.functionCallingConfig || null + const mode = existingCfg?.mode + if (mode !== 'NONE') { + const nextCfg = { ...(existingCfg || {}), mode: 'VALIDATED' } + requestData = { + ...requestData, + request: { + ...requestData.request, + toolConfig: { functionCallingConfig: nextCfg } + } + } + } + } + + // Antigravity 默认启用 tools(对齐 CLIProxyAPI)。若上游拒绝 schema,会在下方自动重试去掉 tools/toolConfig。 + + const abortController = new AbortController() + req.on('close', () => { + if (!abortController.signal.aborted) { + abortController.abort() + } + }) + + if (!isStream) { + try { + const attemptRequest = async (payload) => { + if (vendor === 'antigravity') { + return await geminiAccountService.generateContentAntigravity( + client, + payload, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + } + return await geminiAccountService.generateContent( + client, + payload, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + } + + let rawResponse + try { + rawResponse = await attemptRequest(requestData) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + if (shouldRetryWithoutTools(sanitized) && requestData.request?.tools) { + logger.warn('⚠️ Tool schema rejected by upstream, retrying without tools', { + vendor, + accountId + }) + rawResponse = await attemptRequest(stripToolsFromRequest(requestData)) + } else { + throw error + } + } + + const payload = rawResponse?.response || rawResponse + let content = convertGeminiPayloadToAnthropicContent(payload) + let hasToolUse = content.some((block) => block.type === 'tool_use') + + // Antigravity 某些模型可能不会返回 functionCall(导致永远没有 tool_use),但会把 “Write: xxx” 以纯文本形式输出。 + // 可选回退:解析该文本并合成标准 tool_use,交给 claude-cli 去执行。 + if (!hasToolUse && isEnvEnabled(process.env[TEXT_TOOL_FALLBACK_ENV])) { + const fullText = extractGeminiText(payload) + const extracted = tryExtractWriteToolFromText(fullText, process.cwd()) + if (extracted?.tool) { + const toolUseId = buildToolUseId() + const blocks = [] + if (extracted.prefixText) { + blocks.push({ type: 'text', text: extracted.prefixText }) + } + blocks.push({ + type: 'tool_use', + id: toolUseId, + name: extracted.tool.name, + input: extracted.tool.input + }) + content = blocks + hasToolUse = true + logger.warn('⚠️ Synthesized tool_use from plain text Write directive', { + vendor, + accountId, + tool: extracted.tool.name + }) + } + } + + const usageMetadata = payload?.usageMetadata || {} + const inputTokens = usageMetadata.promptTokenCount || 0 + const outputTokens = resolveUsageOutputTokens(usageMetadata) + const finishReason = payload?.candidates?.[0]?.finishReason + + const stopReason = hasToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason) + + if (req.apiKey?.id && (inputTokens > 0 || outputTokens > 0)) { + await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + 0, + 0, + effectiveModel, + accountId + ) + await applyRateLimitTracking( + req.rateLimitInfo, + { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, + effectiveModel, + 'anthropic-messages' + ) + } + + const responseBody = { + id: `msg_${crypto.randomBytes(12).toString('hex')}`, + type: 'message', + role: 'assistant', + model: req.body.model || effectiveModel, + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens + } + } + + dumpAnthropicNonStreamResponse(req, 200, responseBody, { + vendor, + accountId, + effectiveModel, + forcedVendor: vendor + }) + + return res.status(200).json(responseBody) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream Gemini error (via /v1/messages):', sanitized) + dumpAnthropicNonStreamResponse( + req, + sanitized.statusCode || 502, + buildAnthropicError(sanitized.upstreamMessage || sanitized.message), + { vendor, accountId, effectiveModel, forcedVendor: vendor, upstreamError: sanitized } + ) + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } + } + + const messageId = `msg_${crypto.randomBytes(12).toString('hex')}` + const responseModel = req.body.model || effectiveModel + + try { + const startStream = async (payload) => { + if (vendor === 'antigravity') { + return await geminiAccountService.generateContentStreamAntigravity( + client, + payload, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + } + return await geminiAccountService.generateContentStream( + client, + payload, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + } + + let streamResponse + try { + streamResponse = await startStream(requestData) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + if (shouldRetryWithoutTools(sanitized) && requestData.request?.tools) { + logger.warn('⚠️ Tool schema rejected by upstream, retrying stream without tools', { + vendor, + accountId + }) + streamResponse = await startStream(stripToolsFromRequest(requestData)) + } else { + throw error + } + } + + // 仅在上游流成功建立后再开始向客户端发送 SSE。 + // 这样如果上游在握手阶段直接返回 4xx/5xx(例如 schema 400 或配额 429), + // 我们可以返回真实 HTTP 状态码,而不是先 200 再在 SSE 内发 error 事件。 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + writeAnthropicSseEvent(res, 'message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: responseModel, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0 + } + } + }) + + const wantsThinkingBlockFirst = + vendor === 'antigravity' && + req.body?.thinking && + typeof req.body.thinking === 'object' && + req.body.thinking.type === 'enabled' + + let buffer = '' + let emittedText = '' + let emittedThinking = '' + let emittedThoughtSignature = '' + let finished = false + let usageMetadata = null + let finishReason = null + let emittedAnyToolUse = false + const emittedToolCallKeys = new Set() + + let currentIndex = wantsThinkingBlockFirst ? 0 : -1 + let currentBlockType = wantsThinkingBlockFirst ? 'thinking' : null + + const startTextBlock = (index) => { + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'text', text: '' } + }) + } + + const stopCurrentBlock = () => { + writeAnthropicSseEvent(res, 'content_block_stop', { + type: 'content_block_stop', + index: currentIndex + }) + } + + const startThinkingBlock = (index) => { + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '' } + }) + } + + if (wantsThinkingBlockFirst) { + startThinkingBlock(0) + } + + const switchBlockType = (nextType) => { + if (currentBlockType === nextType) { + return + } + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentIndex += 1 + currentBlockType = nextType + if (nextType === 'text') { + startTextBlock(currentIndex) + } else if (nextType === 'thinking') { + startThinkingBlock(currentIndex) + } + } + + const canStartThinkingBlock = () => { + if (currentIndex < 0) { + return true + } + if (currentBlockType === 'thinking') { + return true + } + if (emittedThinking || emittedThoughtSignature) { + return true + } + return false + } + + const emitToolUseBlock = (name, args, id = null) => { + const toolUseId = typeof id === 'string' && id ? id : buildToolUseId() + const jsonArgs = stableJsonStringify(args || {}) + + currentIndex += 1 + const toolIndex = currentIndex + + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index: toolIndex, + content_block: { type: 'tool_use', id: toolUseId, name, input: {} } + }) + + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: toolIndex, + delta: { type: 'input_json_delta', partial_json: jsonArgs } + }) + + writeAnthropicSseEvent(res, 'content_block_stop', { + type: 'content_block_stop', + index: toolIndex + }) + emittedAnyToolUse = true + currentBlockType = null + } + + const finalize = async () => { + if (finished) { + return + } + finished = true + + const inputTokens = usageMetadata?.promptTokenCount || 0 + const outputTokens = resolveUsageOutputTokens(usageMetadata) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + + writeAnthropicSseEvent(res, 'message_delta', { + type: 'message_delta', + delta: { + stop_reason: emittedAnyToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason), + stop_sequence: null + }, + usage: { + output_tokens: outputTokens + } + }) + + writeAnthropicSseEvent(res, 'message_stop', { type: 'message_stop' }) + res.end() + + dumpAnthropicStreamSummary(req, { + vendor, + accountId, + effectiveModel, + responseModel, + stop_reason: emittedAnyToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason), + tool_use_names: Array.from(emittedToolCallKeys) + .map((key) => key.split(':')[0]) + .filter(Boolean), + text_preview: emittedText ? emittedText.slice(0, 800) : '', + usage: { input_tokens: inputTokens, output_tokens: outputTokens } + }) + + if (req.apiKey?.id && (inputTokens > 0 || outputTokens > 0)) { + await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + 0, + 0, + effectiveModel, + accountId + ) + await applyRateLimitTracking( + req.rateLimitInfo, + { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, + effectiveModel, + 'anthropic-messages-stream' + ) + } + } + + streamResponse.on('data', (chunk) => { + if (finished) { + return + } + + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) { + continue + } + + const parsed = parseSSELine(line) + if (parsed.type === 'control') { + continue + } + if (parsed.type !== 'data' || !parsed.data) { + continue + } + + const payload = parsed.data?.response || parsed.data + const { usageMetadata: currentUsageMetadata, candidates } = payload || {} + if (currentUsageMetadata) { + usageMetadata = currentUsageMetadata + } + + const [candidate] = Array.isArray(candidates) ? candidates : [] + const { finishReason: currentFinishReason } = candidate || {} + if (currentFinishReason) { + finishReason = currentFinishReason + } + + const parts = extractGeminiParts(payload) + const thoughtSignature = extractGeminiThoughtSignature(payload) + for (const part of parts) { + const functionCall = part?.functionCall + if (!functionCall?.name) { + continue + } + + const toolKey = + typeof functionCall.id === 'string' && functionCall.id + ? `id:${functionCall.id}` + : `${functionCall.name}:${stableJsonStringify(functionCall.args || {})}` + if (emittedToolCallKeys.has(toolKey)) { + continue + } + emittedToolCallKeys.add(toolKey) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentBlockType = 'tool_use' + emitToolUseBlock(functionCall.name, functionCall.args || {}, functionCall.id || null) + } + + if ( + thoughtSignature && + thoughtSignature !== emittedThoughtSignature && + canStartThinkingBlock() + ) { + switchBlockType('thinking') + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'signature_delta', signature: thoughtSignature } + }) + emittedThoughtSignature = thoughtSignature + } + + const fullThought = extractGeminiThoughtText(payload) + if (fullThought && canStartThinkingBlock()) { + let delta = '' + if (fullThought.startsWith(emittedThinking)) { + delta = fullThought.slice(emittedThinking.length) + } else { + delta = fullThought + } + if (delta) { + switchBlockType('thinking') + emittedThinking = fullThought + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'thinking_delta', thinking: delta } + }) + } + } + + const fullText = extractGeminiText(payload) + if (fullText) { + let delta = '' + if (fullText.startsWith(emittedText)) { + delta = fullText.slice(emittedText.length) + } else { + delta = fullText + } + if (delta) { + switchBlockType('text') + emittedText = fullText + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'text_delta', text: delta } + }) + } + } + } + }) + + streamResponse.on('end', () => { + finalize().catch((e) => logger.error('Failed to finalize Anthropic SSE response:', e)) + }) + + streamResponse.on('error', (error) => { + if (finished) { + return + } + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream Gemini stream error (via /v1/messages):', sanitized) + writeAnthropicSseEvent( + res, + 'error', + buildAnthropicError(sanitized.upstreamMessage || sanitized.message) + ) + res.end() + }) + + return undefined + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + logger.error('Failed to start Gemini stream (via /v1/messages):', sanitized) + + // 上游尚未建立 SSE(未写入任何事件)时,优先返回真实 HTTP 错误码,避免 200 + SSE error 的混淆。 + if (!res.headersSent) { + dumpAnthropicNonStreamResponse( + req, + sanitized.statusCode || 502, + buildAnthropicError(sanitized.upstreamMessage || sanitized.message), + { vendor, accountId, effectiveModel, forcedVendor: vendor, upstreamError: sanitized } + ) + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } + + writeAnthropicSseEvent( + res, + 'error', + buildAnthropicError(sanitized.upstreamMessage || sanitized.message) + ) + res.end() + return undefined + } +} + +async function handleAnthropicCountTokensToGemini(req, res, { vendor }) { + if (!SUPPORTED_VENDORS.has(vendor)) { + return res.status(400).json(buildAnthropicError(`Unsupported vendor: ${vendor}`)) + } + + const sessionHash = sessionHelper.generateSessionHash(req.body) + + const model = (req.body?.model || '').trim() + if (!model) { + return res.status(400).json(buildAnthropicError('Missing model')) + } + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model, + { oauthProvider: vendor } + ) + } catch (error) { + logger.error('Failed to select Gemini account (count_tokens):', error) + return res + .status(503) + .json(buildAnthropicError(error.message || 'No available Gemini accounts')) + } + + const { accountId, accountType } = accountSelection + if (accountType !== 'gemini') { + return res + .status(400) + .json(buildAnthropicError('Only Gemini OAuth accounts are supported for this vendor')) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json(buildAnthropicError('Gemini OAuth account not found')) + } + + await geminiAccountService.markAccountUsed(account.id) + + 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 client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig, + account.oauthProvider + ) + + const toolUseIdToName = buildToolUseIdToNameMap(req.body.messages || []) + const contents = convertAnthropicMessagesToGeminiContents( + req.body.messages || [], + toolUseIdToName, + { vendor } + ) + + try { + const countResult = + vendor === 'antigravity' + ? await geminiAccountService.countTokensAntigravity(client, contents, model, proxyConfig) + : await geminiAccountService.countTokens(client, contents, model, proxyConfig) + + const totalTokens = countResult?.totalTokens || 0 + return res.status(200).json({ input_tokens: totalTokens }) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream token count error (via /v1/messages/count_tokens):', sanitized) + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } +} + +module.exports = { + handleAnthropicMessagesToGemini, + handleAnthropicCountTokensToGemini +} diff --git a/src/services/antigravityClient.js b/src/services/antigravityClient.js new file mode 100644 index 00000000..66d68085 --- /dev/null +++ b/src/services/antigravityClient.js @@ -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 +} diff --git a/src/services/antigravityRelayService.js b/src/services/antigravityRelayService.js new file mode 100644 index 00000000..437e18c5 --- /dev/null +++ b/src/services/antigravityRelayService.js @@ -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 +} diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index a23d81b3..50c3e922 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -16,11 +16,62 @@ const { } = require('../utils/tokenRefreshLogger') const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') +const antigravityClient = require('./antigravityClient') -// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据 -const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' -const OAUTH_CLIENT_SECRET = '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' -const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] +// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用 +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' + +const OAUTH_PROVIDERS = { + [OAUTH_PROVIDER_GEMINI_CLI]: { + // Gemini CLI OAuth 配置(公开) + clientId: + process.env.GEMINI_OAUTH_CLIENT_ID || + '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com', + clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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', + scopes: ['https://www.googleapis.com/auth/cloud-platform'] + }, + [OAUTH_PROVIDER_ANTIGRAVITY]: { + // Antigravity OAuth 配置(参考 gcli2api) + clientId: + process.env.ANTIGRAVITY_OAUTH_CLIENT_ID || + '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', + clientSecret: + process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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', + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' + ] + } +} + +if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} +if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + +function getOauthProviderConfig(oauthProvider) { + const normalized = normalizeOauthProvider(oauthProvider) + return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI] +} // 🌐 TCP Keep-Alive Agent 配置 // 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题 @@ -34,6 +85,117 @@ const keepAliveAgent = new https.Agent({ logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support') +async function fetchAvailableModelsAntigravity( + accessToken, + proxyConfig = null, + refreshToken = null +) { + try { + let effectiveToken = accessToken + if (refreshToken) { + try { + const client = await getOauthClient( + accessToken, + refreshToken, + proxyConfig, + OAUTH_PROVIDER_ANTIGRAVITY + ) + if (client && client.getAccessToken) { + const latest = await client.getAccessToken() + if (latest?.token) { + effectiveToken = latest.token + } + } + } catch (error) { + logger.warn('Failed to refresh Antigravity access token for models list:', { + message: error.message + }) + } + } + + const data = await antigravityClient.fetchAvailableModels({ + accessToken: effectiveToken, + proxyConfig + }) + const modelsDict = data?.models + const created = Math.floor(Date.now() / 1000) + + const models = [] + const seen = new Set() + const { + getAntigravityModelAlias, + getAntigravityModelMetadata, + normalizeAntigravityModelInput + } = require('../utils/antigravityModel') + + const pushModel = (modelId) => { + if (!modelId || seen.has(modelId)) { + return + } + seen.add(modelId) + const metadata = getAntigravityModelMetadata(modelId) + const entry = { + id: modelId, + object: 'model', + created, + owned_by: 'antigravity' + } + if (metadata?.name) { + entry.name = metadata.name + } + if (metadata?.maxCompletionTokens) { + entry.max_completion_tokens = metadata.maxCompletionTokens + } + if (metadata?.thinking) { + entry.thinking = metadata.thinking + } + models.push(entry) + } + + if (modelsDict && typeof modelsDict === 'object') { + for (const modelId of Object.keys(modelsDict)) { + const normalized = normalizeAntigravityModelInput(modelId) + const alias = getAntigravityModelAlias(normalized) + if (!alias) { + continue + } + pushModel(alias) + + if (alias.endsWith('-thinking')) { + pushModel(alias.replace(/-thinking$/, '')) + } + + if (alias.startsWith('gemini-claude-')) { + pushModel(alias.replace(/^gemini-/, '')) + } + } + } + + return models + } catch (error) { + logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message) + return [ + { + id: 'gemini-2.5-flash', + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'antigravity' + } + ] + } +} + +async function countTokensAntigravity(client, contents, model, proxyConfig = null) { + const { token } = await client.getAccessToken() + const response = await antigravityClient.countTokens({ + accessToken: token, + proxyConfig, + contents, + model + }) + return response +} + // 加密相关常量 const ALGORITHM = 'aes-256-cbc' const ENCRYPTION_SALT = 'gemini-account-salt' @@ -124,14 +286,15 @@ setInterval( ) // 创建 OAuth2 客户端(支持代理配置) -function createOAuth2Client(redirectUri = null, proxyConfig = null) { +function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) { // 如果没有提供 redirectUri,使用默认值 const uri = redirectUri || 'http://localhost:45462' + const oauthConfig = getOauthProviderConfig(oauthProvider) // 准备客户端选项 const clientOptions = { - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, redirectUri: uri } @@ -152,10 +315,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) { } // 生成授权 URL (支持 PKCE 和代理) -async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) { +async function generateAuthUrl( + state = null, + redirectUri = null, + proxyConfig = null, + oauthProvider = null +) { // 使用新的 redirect URI const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' - const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig) + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider) if (proxyConfig) { logger.info( @@ -172,7 +342,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n const authUrl = oAuth2Client.generateAuthUrl({ redirect_uri: finalRedirectUri, access_type: 'offline', - scope: OAUTH_SCOPES, + scope: oauthConfig.scopes, code_challenge_method: 'S256', code_challenge: codeVerifier.codeChallenge, state: stateValue, @@ -183,7 +353,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n authUrl, state: stateValue, codeVerifier: codeVerifier.codeVerifier, - redirectUri: finalRedirectUri + redirectUri: finalRedirectUri, + oauthProvider: normalizedProvider } } @@ -244,11 +415,14 @@ async function exchangeCodeForTokens( code, redirectUri = null, codeVerifier = null, - proxyConfig = null + proxyConfig = null, + oauthProvider = null ) { try { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) // 创建带代理配置的 OAuth2Client - const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig) + const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider) if (proxyConfig) { logger.info( @@ -274,7 +448,7 @@ async function exchangeCodeForTokens( return { access_token: tokens.access_token, refresh_token: tokens.refresh_token, - scope: tokens.scope || OAUTH_SCOPES.join(' '), + scope: tokens.scope || oauthConfig.scopes.join(' '), token_type: tokens.token_type || 'Bearer', expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000 } @@ -285,9 +459,11 @@ async function exchangeCodeForTokens( } // 刷新访问令牌 -async function refreshAccessToken(refreshToken, proxyConfig = null) { +async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) // 创建带代理配置的 OAuth2Client - const oAuth2Client = createOAuth2Client(null, proxyConfig) + const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider) try { // 设置 refresh_token @@ -319,7 +495,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) { return { access_token: credentials.access_token, refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的 - scope: credentials.scope || OAUTH_SCOPES.join(' '), + scope: credentials.scope || oauthConfig.scopes.join(' '), token_type: credentials.token_type || 'Bearer', expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期 } @@ -339,6 +515,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) { async function createAccount(accountData) { const id = uuidv4() const now = new Date().toISOString() + const oauthProvider = normalizeOauthProvider(accountData.oauthProvider) + const oauthConfig = getOauthProviderConfig(oauthProvider) // 处理凭证数据 let geminiOauth = null @@ -371,7 +549,7 @@ async function createAccount(accountData) { geminiOauth = JSON.stringify({ access_token: accessToken, refresh_token: refreshToken, - scope: accountData.scope || OAUTH_SCOPES.join(' '), + scope: accountData.scope || oauthConfig.scopes.join(' '), token_type: accountData.tokenType || 'Bearer', expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时 }) @@ -399,7 +577,8 @@ async function createAccount(accountData) { refreshToken: refreshToken ? encrypt(refreshToken) : '', expiresAt, // OAuth Token 过期时间(技术字段,自动刷新) // 只有OAuth方式才有scopes,手动添加的没有 - scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '', + scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '', + oauthProvider, // ✅ 新增:账户订阅到期时间(业务字段,手动管理) subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, @@ -508,6 +687,10 @@ async function updateAccount(accountId, updates) { updates.schedulable = updates.schedulable.toString() } + if (updates.oauthProvider !== undefined) { + updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider) + } + // 加密敏感字段 if (updates.geminiOauth) { updates.geminiOauth = encrypt( @@ -885,12 +1068,13 @@ async function refreshAccountToken(accountId) { // 重新获取账户数据(可能已被其他进程刷新) const updatedAccount = await getAccount(accountId) if (updatedAccount && updatedAccount.accessToken) { + const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider) const accessToken = decrypt(updatedAccount.accessToken) return { access_token: accessToken, refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '', expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0, - scope: updatedAccount.scope || OAUTH_SCOPES.join(' '), + scope: updatedAccount.scopes || oauthConfig.scopes.join(' '), token_type: 'Bearer' } } @@ -904,7 +1088,11 @@ async function refreshAccountToken(accountId) { // account.refreshToken 已经是解密后的值(从 getAccount 返回) // 传入账户的代理配置 - const newTokens = await refreshAccessToken(account.refreshToken, account.proxy) + const newTokens = await refreshAccessToken( + account.refreshToken, + account.proxy, + account.oauthProvider + ) // 更新账户信息 const updates = { @@ -1036,14 +1224,15 @@ async function getAccountRateLimitInfo(accountId) { } // 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) -async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { - const client = createOAuth2Client(null, proxyConfig) +async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const client = createOAuth2Client(null, proxyConfig, normalizedProvider) const creds = { access_token: accessToken, refresh_token: refreshToken, - scope: - 'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email', + scope: oauthConfig.scopes.join(' '), token_type: 'Bearer', expiry_date: 1754269905646 } @@ -1509,6 +1698,43 @@ async function generateContent( return response.data } +// 调用 Antigravity 上游生成内容(非流式) +async function generateContentAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🪐 Antigravity generateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: false + }) + logger.info('✅ Antigravity generateContent API调用成功') + return response.data +} + // 调用 Code Assist API 生成内容(流式) async function generateContentStream( client, @@ -1593,6 +1819,46 @@ async function generateContentStream( return response.data // 返回流对象 } +// 调用 Antigravity 上游生成内容(流式) +async function generateContentStreamAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + signal = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🌊 Antigravity streamGenerateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: true, + signal, + params: { alt: 'sse' } + }) + logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输') + return response.data +} + // 更新账户的临时项目 ID async function updateTempProjectId(accountId, tempProjectId) { if (!tempProjectId) { @@ -1687,10 +1953,12 @@ module.exports = { generateEncryptionKey, decryptCache, // 暴露缓存对象以便测试和监控 countTokens, + countTokensAntigravity, generateContent, generateContentStream, + generateContentAntigravity, + generateContentStreamAntigravity, + fetchAvailableModelsAntigravity, updateTempProjectId, - resetAccountStatus, - OAUTH_CLIENT_ID, - OAUTH_SCOPES + resetAccountStatus } diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js index 33193501..7082e7bb 100644 --- a/src/services/unifiedGeminiScheduler.js +++ b/src/services/unifiedGeminiScheduler.js @@ -4,11 +4,35 @@ const accountGroupService = require('./accountGroupService') const redis = require('../models/redis') const logger = require('../utils/logger') +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' +const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY] + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + class UnifiedGeminiScheduler { constructor() { this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:' } + _getSessionMappingKey(sessionHash, oauthProvider = null) { + if (!sessionHash) { + return null + } + if (!oauthProvider) { + return `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + } + const normalized = normalizeOauthProvider(oauthProvider) + return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}` + } + // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) _isSchedulable(schedulable) { // 如果是 undefined 或 null,默认为可调度 @@ -32,7 +56,8 @@ class UnifiedGeminiScheduler { requestedModel = null, options = {} ) { - const { allowApiAccounts = false } = options + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null try { // 如果API Key绑定了专属账户或分组,优先使用 @@ -83,14 +108,23 @@ class UnifiedGeminiScheduler { this._isActive(boundAccount.isActive) && boundAccount.status !== 'error' ) { - logger.info( - `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` - ) - // 更新账户的最后使用时间 - await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId) - return { - accountId: apiKeyData.geminiAccountId, - accountType: 'gemini' + if ( + normalizedOauthProvider && + normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider + ) { + logger.warn( + `⚠️ Bound Gemini OAuth account ${boundAccount.name} oauthProvider=${normalizeOauthProvider(boundAccount.oauthProvider)} does not match requested oauthProvider=${normalizedOauthProvider}, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` + ) + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId) + return { + accountId: apiKeyData.geminiAccountId, + accountType: 'gemini' + } } } else { logger.warn( @@ -102,7 +136,7 @@ class UnifiedGeminiScheduler { // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccount = await this._getSessionMapping(sessionHash) + const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider) if (mappedAccount) { // 验证映射的账户是否仍然可用 const isAvailable = await this._isAccountAvailable( @@ -111,7 +145,7 @@ class UnifiedGeminiScheduler { ) if (isAvailable) { // 🚀 智能会话续期(续期 unified 映射键,按配置) - await this._extendSessionMappingTTL(sessionHash) + await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -132,11 +166,10 @@ class UnifiedGeminiScheduler { } // 获取所有可用账户 - const availableAccounts = await this._getAllAvailableAccounts( - apiKeyData, - requestedModel, - allowApiAccounts - ) + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, { + allowApiAccounts, + oauthProvider: normalizedOauthProvider + }) if (availableAccounts.length === 0) { // 提供更详细的错误信息 @@ -160,7 +193,8 @@ class UnifiedGeminiScheduler { await this._setSessionMapping( sessionHash, selectedAccount.accountId, - selectedAccount.accountType + selectedAccount.accountType, + normalizedOauthProvider ) logger.info( `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` @@ -189,7 +223,18 @@ class UnifiedGeminiScheduler { } // 📋 获取所有可用账户 - async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) { + async _getAllAvailableAccounts( + apiKeyData, + requestedModel = null, + allowApiAccountsOrOptions = false + ) { + const options = + allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object' + ? allowApiAccountsOrOptions + : { allowApiAccounts: allowApiAccountsOrOptions } + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null + const availableAccounts = [] // 如果API Key绑定了专属账户,优先返回 @@ -254,6 +299,12 @@ class UnifiedGeminiScheduler { this._isActive(boundAccount.isActive) && boundAccount.status !== 'error' ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider + ) { + return availableAccounts + } const isRateLimited = await this.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { // 检查模型支持 @@ -303,6 +354,12 @@ class UnifiedGeminiScheduler { (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 this._isSchedulable(account.schedulable) ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider + ) { + continue + } // 检查是否可调度 // 检查token是否过期 @@ -437,9 +494,10 @@ class UnifiedGeminiScheduler { } // 🔗 获取会话映射 - async _getSessionMapping(sessionHash) { + async _getSessionMapping(sessionHash, oauthProvider = null) { const client = redis.getClientSafe() - const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + const mappingData = key ? await client.get(key) : null if (mappingData) { try { @@ -454,27 +512,42 @@ class UnifiedGeminiScheduler { } // 💾 设置会话映射 - async _setSessionMapping(sessionHash, accountId, accountType) { + async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) { const client = redis.getClientSafe() const mappingData = JSON.stringify({ accountId, accountType }) // 依据配置设置TTL(小时) const appConfig = require('../../config/config') const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) - await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData) + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return + } + await client.setex(key, ttlSeconds, mappingData) } // 🗑️ 删除会话映射 async _deleteSessionMapping(sessionHash) { const client = redis.getClientSafe() - await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + if (!sessionHash) { + return + } + + const keys = [this._getSessionMappingKey(sessionHash)] + for (const provider of KNOWN_OAUTH_PROVIDERS) { + keys.push(this._getSessionMappingKey(sessionHash, provider)) + } + await client.del(keys.filter(Boolean)) } // 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置 - async _extendSessionMappingTTL(sessionHash) { + async _extendSessionMappingTTL(sessionHash, oauthProvider = null) { try { const client = redis.getClientSafe() - const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return false + } const remainingTTL = await client.ttl(key) if (remainingTTL === -2) { diff --git a/src/utils/anthropicRequestDump.js b/src/utils/anthropicRequestDump.js new file mode 100644 index 00000000..8a1a7510 --- /dev/null +++ b/src/utils/anthropicRequestDump.js @@ -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 +} diff --git a/src/utils/anthropicResponseDump.js b/src/utils/anthropicResponseDump.js new file mode 100644 index 00000000..7107556c --- /dev/null +++ b/src/utils/anthropicResponseDump.js @@ -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 +} diff --git a/src/utils/antigravityModel.js b/src/utils/antigravityModel.js new file mode 100644 index 00000000..53811b4b --- /dev/null +++ b/src/utils/antigravityModel.js @@ -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 +} diff --git a/src/utils/antigravityUpstreamDump.js b/src/utils/antigravityUpstreamDump.js new file mode 100644 index 00000000..4c1be446 --- /dev/null +++ b/src/utils/antigravityUpstreamDump.js @@ -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 +} diff --git a/src/utils/errorSanitizer.js b/src/utils/errorSanitizer.js index 44c17cd5..683008fd 100644 --- a/src/utils/errorSanitizer.js +++ b/src/utils/errorSanitizer.js @@ -55,16 +55,69 @@ function sanitizeUpstreamError(errorData) { return errorData } - // 深拷贝避免修改原始对象 - const sanitized = JSON.parse(JSON.stringify(errorData)) + // AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息 + const looksLikeAxiosError = + errorData.isAxiosError || + (errorData.name === 'AxiosError' && (errorData.config || errorData.response)) + const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string' + + if (looksLikeAxiosError || looksLikeError) { + const statusCode = errorData.response?.status + const upstreamBody = errorData.response?.data + const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '') + + return { + name: errorData.name || 'Error', + code: errorData.code, + statusCode, + message: sanitizeErrorMessage(errorData.message || ''), + upstreamMessage: upstreamMessage || undefined, + upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined + } + } // 递归清理嵌套的错误对象 + const visited = new WeakSet() + + const shouldRedactKey = (key) => { + if (!key) { + return false + } + const lowerKey = String(key).toLowerCase() + return ( + lowerKey === 'authorization' || + lowerKey === 'cookie' || + lowerKey.includes('api_key') || + lowerKey.includes('apikey') || + lowerKey.includes('access_token') || + lowerKey.includes('refresh_token') || + lowerKey.endsWith('token') || + lowerKey.includes('secret') || + lowerKey.includes('password') + ) + } + const sanitizeObject = (obj) => { if (!obj || typeof obj !== 'object') { return obj } + if (visited.has(obj)) { + return '[Circular]' + } + visited.add(obj) + + // 主动剔除常见“超大且敏感”的字段 + if (obj.config || obj.request || obj.response) { + return '[Redacted]' + } + for (const key in obj) { + if (shouldRedactKey(key)) { + obj[key] = '[REDACTED]' + continue + } + // 清理所有字符串字段,不仅仅是 message if (typeof obj[key] === 'string') { obj[key] = sanitizeErrorMessage(obj[key]) @@ -76,7 +129,9 @@ function sanitizeUpstreamError(errorData) { return obj } - return sanitizeObject(sanitized) + // 尽量不修改原对象:浅拷贝后递归清理 + const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData } + return sanitizeObject(clone) } /** diff --git a/src/utils/geminiSchemaCleaner.js b/src/utils/geminiSchemaCleaner.js new file mode 100644 index 00000000..fa4d1b33 --- /dev/null +++ b/src/utils/geminiSchemaCleaner.js @@ -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 +} diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js index a42ee317..591c1974 100644 --- a/src/utils/modelHelper.js +++ b/src/utils/modelHelper.js @@ -5,6 +5,10 @@ * Supports parsing model strings like "ccr,model_name" to extract vendor type and base model. */ +// 仅保留原仓库既有的模型前缀:CCR 路由 +// Gemini/Antigravity 采用“路径分流”,避免在 model 字段里混入 vendor 前缀造成混乱 +const SUPPORTED_VENDOR_PREFIXES = ['ccr'] + /** * Parse vendor-prefixed model string * @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro") @@ -19,16 +23,21 @@ function parseVendorPrefixedModel(modelStr) { const trimmed = modelStr.trim() const lowerTrimmed = trimmed.toLowerCase() - // Check for ccr prefix (case insensitive) - if (lowerTrimmed.startsWith('ccr,')) { + for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) { + if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) { + continue + } + const parts = trimmed.split(',') - if (parts.length >= 2) { - // Extract base model (everything after the first comma, rejoined in case model name contains commas) - const baseModel = parts.slice(1).join(',').trim() - return { - vendor: 'ccr', - baseModel - } + if (parts.length < 2) { + break + } + + // Extract base model (everything after the first comma, rejoined in case model name contains commas) + const baseModel = parts.slice(1).join(',').trim() + return { + vendor: vendorPrefix, + baseModel } } diff --git a/src/utils/projectPaths.js b/src/utils/projectPaths.js new file mode 100644 index 00000000..c2f30762 --- /dev/null +++ b/src/utils/projectPaths.js @@ -0,0 +1,10 @@ +const path = require('path') + +// 该文件位于 src/utils 下,向上两级即项目根目录。 +function getProjectRoot() { + return path.resolve(__dirname, '..', '..') +} + +module.exports = { + getProjectRoot +} diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js index 2a49fca2..4788f178 100644 --- a/src/validators/clients/claudeCodeValidator.js +++ b/src/validators/clients/claudeCodeValidator.js @@ -62,12 +62,17 @@ class ClaudeCodeValidator { for (const entry of systemEntries) { const rawText = typeof entry?.text === 'string' ? entry.text : '' - const { bestScore } = bestSimilarityByTemplates(rawText) + const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText) if (bestScore < threshold) { logger.error( `Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}` ) - 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 } } diff --git a/src/validators/clients/codexCliValidator.js b/src/validators/clients/codexCliValidator.js index 4d94d98f..d8922bd2 100644 --- a/src/validators/clients/codexCliValidator.js +++ b/src/validators/clients/codexCliValidator.js @@ -125,8 +125,12 @@ class CodexCliValidator { const part1 = parts1[i] || 0 const part2 = parts2[i] || 0 - if (part1 < part2) return -1 - if (part1 > part2) return 1 + if (part1 < part2) { + return -1 + } + if (part1 > part2) { + return 1 + } } return 0 diff --git a/src/validators/clients/geminiCliValidator.js b/src/validators/clients/geminiCliValidator.js index 8e9ed0de..0d438384 100644 --- a/src/validators/clients/geminiCliValidator.js +++ b/src/validators/clients/geminiCliValidator.js @@ -53,7 +53,7 @@ class GeminiCliValidator { // 2. 对于 /gemini 路径,检查是否包含 generateContent if (path.includes('generateContent')) { // 包含 generateContent 的路径需要验证 User-Agent - const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i + const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i if (!geminiCliPattern.test(userAgent)) { logger.debug( `Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}` @@ -84,8 +84,12 @@ class GeminiCliValidator { const part1 = parts1[i] || 0 const part2 = parts2[i] || 0 - if (part1 < part2) return -1 - if (part1 > part2) return 1 + if (part1 < part2) { + return -1 + } + if (part1 > part2) { + return 1 + } } return 0 diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 1a36f4c3..7bd4b883 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -477,6 +477,36 @@ +