feat: 实现 Antigravity OAuth 账户支持与路径分流

This commit is contained in:
52227
2025-12-25 14:15:18 +08:00
parent 53cda0fd18
commit 9960f237b8
29 changed files with 4528 additions and 216 deletions

View File

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

View File

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

View File

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

View File

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

View File

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