From 41999f56b4b1f3d01dd0746e288c434b5d590b44 Mon Sep 17 00:00:00 2001 From: 52227 <522270094@qq.com> Date: Sat, 3 Jan 2026 10:15:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=82=E9=85=8D=20Antigravity=20?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E4=BD=99=E9=A2=9D=E6=9F=A5=E8=AF=A2=E4=B8=8E?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Antigravity 账户适配: - 新增 GeminiBalanceProvider,支持 Antigravity 账户的额度查询(API 模式) - AccountBalanceService 增加 queryMode 逻辑与安全限制 - 前端 BalanceDisplay 适配 Antigravity 配额显示 2. 流式响应增强: - 优化 thoughtSignature 捕获与回填,支持思维链透传 - 修复工具调用签名校验 3. 其他: - 请求体大小限制提升至 100MB - .gitignore 更新 --- .gitignore | 3 + src/app.js | 4 +- src/middleware/auth.js | 4 +- src/routes/openaiGeminiRoutes.js | 13 +- src/services/accountBalanceService.js | 54 ++-- src/services/anthropicGeminiBridgeService.js | 63 ++++- src/services/antigravityClient.js | 5 + .../balanceProviders/geminiBalanceProvider.js | 250 ++++++++++++++++++ src/services/balanceProviders/index.js | 3 +- .../components/accounts/BalanceDisplay.vue | 112 +++++++- web/admin-spa/src/views/AccountsView.vue | 14 + 11 files changed, 484 insertions(+), 41 deletions(-) create mode 100644 src/services/balanceProviders/geminiBalanceProvider.js diff --git a/.gitignore b/.gitignore index e4c9e9c1..ad751429 100644 --- a/.gitignore +++ b/.gitignore @@ -247,3 +247,6 @@ web/apiStats/ # Admin SPA build files web/admin-spa/dist/ + +.cunzhi-memory/ +*.jsonl \ No newline at end of file diff --git a/src/app.js b/src/app.js index f83be464..d17ea295 100644 --- a/src/app.js +++ b/src/app.js @@ -179,7 +179,7 @@ class Application { // 🔧 基础中间件 this.app.use( express.json({ - limit: '10mb', + limit: '100mb', verify: (req, res, buf, encoding) => { // 验证JSON格式 if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { @@ -188,7 +188,7 @@ class Application { } }) ) - this.app.use(express.urlencoded({ extended: true, limit: '10mb' })) + this.app.use(express.urlencoded({ extended: true, limit: '100mb' })) this.app.use(securityMiddleware) // 🎯 信任代理 diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 44e3cb37..8cccee0c 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2043,7 +2043,7 @@ const globalRateLimit = async (req, res, next) => // 📊 请求大小限制中间件 const requestSizeLimit = (req, res, next) => { - const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10) + const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10) const maxSize = MAX_SIZE_MB * 1024 * 1024 const contentLength = parseInt(req.headers['content-length'] || '0') @@ -2052,7 +2052,7 @@ const requestSizeLimit = (req, res, next) => { return res.status(413).json({ error: 'Payload Too Large', message: 'Request body size exceeds limit', - limit: '10MB' + limit: `${MAX_SIZE_MB}MB` }) } diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index 511ca248..a2678e3f 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -693,8 +693,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { return undefined }) -// OpenAI 兼容的模型列表端点 -router.get('/v1/models', authenticateApiKey, async (req, res) => { +// 获取可用模型列表的共享处理器 +async function handleGetModels(req, res) { try { const apiKeyData = req.apiKey @@ -782,8 +782,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { } }) } - return undefined -}) +} + +// OpenAI 兼容的模型列表端点 (带 v1 版) +router.get('/v1/models', authenticateApiKey, handleGetModels) + +// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载) +router.get('/models', authenticateApiKey, handleGetModels) // OpenAI 兼容的模型详情端点 router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js index 3265c4b8..8c136e80 100644 --- a/src/services/accountBalanceService.js +++ b/src/services/accountBalanceService.js @@ -270,7 +270,7 @@ class AccountBalanceService { } async _getAccountBalanceForAccount(account, platform, options = {}) { - const queryApi = this._parseBoolean(options.queryApi) || false + const queryMode = this._parseQueryMode(options.queryApi) const useCache = options.useCache !== false const accountId = account?.id @@ -297,8 +297,14 @@ class AccountBalanceService { const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics) - // 非强制查询:优先读缓存 - if (!queryApi) { + // 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户 + const effectiveQueryMode = + queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity') + ? 'local' + : queryMode + + // local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果) + if (effectiveQueryMode !== 'api') { if (useCache) { const cached = await this.redis.getAccountBalance(platform, accountId) if (cached && cached.status === 'success') { @@ -321,22 +327,24 @@ class AccountBalanceService { } } - return this._buildResponse( - { - status: 'success', - errorMessage: null, - balance: quotaFromLocal.balance, - currency: quotaFromLocal.currency || 'USD', - quota: quotaFromLocal.quota, - statistics: localStatistics, - lastRefreshAt: localBalance.lastCalculated - }, - accountId, - platform, - 'local', - null, - scriptMeta - ) + if (effectiveQueryMode === 'local') { + return this._buildResponse( + { + status: 'success', + errorMessage: null, + balance: quotaFromLocal.balance, + currency: quotaFromLocal.currency || 'USD', + quota: quotaFromLocal.quota, + statistics: localStatistics, + lastRefreshAt: localBalance.lastCalculated + }, + accountId, + platform, + 'local', + null, + scriptMeta + ) + } } // 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计 @@ -723,6 +731,14 @@ class AccountBalanceService { return null } + _parseQueryMode(value) { + if (value === 'auto') { + return 'auto' + } + const parsed = this._parseBoolean(value) + return parsed ? 'api' : 'local' + } + async _mapWithConcurrency(items, limit, mapper) { const concurrency = Math.max(1, Number(limit) || 1) const list = Array.isArray(items) ? items : [] diff --git a/src/services/anthropicGeminiBridgeService.js b/src/services/anthropicGeminiBridgeService.js index dcf0e7b7..5984cf58 100644 --- a/src/services/anthropicGeminiBridgeService.js +++ b/src/services/anthropicGeminiBridgeService.js @@ -941,6 +941,7 @@ function convertAnthropicMessagesToGeminiContents( const content = message?.content const parts = [] + let lastAntigravityThoughtSignature = '' if (typeof content === 'string') { const text = extractAnthropicText(content) @@ -985,6 +986,7 @@ function convertAnthropicMessagesToGeminiContents( continue } + lastAntigravityThoughtSignature = signature const thoughtPart = { thought: true, thoughtSignature: signature } if (hasThinkingText) { thoughtPart.text = thinkingText @@ -1013,13 +1015,19 @@ function convertAnthropicMessagesToGeminiContents( 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 - } - }) + const functionCall = { + ...(vendor === 'antigravity' && toolCallId ? { id: toolCallId } : {}), + name: part.name, + args + } + + // Antigravity 对历史工具调用的 functionCall 会校验 thoughtSignature; + // Claude Code 侧的签名存放在 thinking block(part.signature),这里需要回填到 functionCall part 上。 + if (vendor === 'antigravity' && lastAntigravityThoughtSignature) { + parts.push({ thoughtSignature: lastAntigravityThoughtSignature, functionCall }) + } else { + parts.push({ functionCall }) + } } continue } @@ -2435,6 +2443,47 @@ async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel }) const parts = extractGeminiParts(payload) const thoughtSignature = extractGeminiThoughtSignature(payload) + const fullThoughtForToolOrdering = extractGeminiThoughtText(payload) + + if (wantsThinkingBlockFirst) { + // 关键:确保 thinking/signature 在 tool_use 之前输出,避免出现 tool_use 后紧跟 thinking(signature) + // 导致下一轮请求的 thinking 校验/工具调用校验失败(Antigravity 会返回 400)。 + if (thoughtSignature && canStartThinkingBlock()) { + let delta = '' + if (thoughtSignature.startsWith(emittedThoughtSignature)) { + delta = thoughtSignature.slice(emittedThoughtSignature.length) + } else if (thoughtSignature !== emittedThoughtSignature) { + delta = thoughtSignature + } + if (delta) { + switchBlockType('thinking') + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'signature_delta', signature: delta } + }) + emittedThoughtSignature = thoughtSignature + } + } + + if (fullThoughtForToolOrdering && canStartThinkingBlock()) { + let delta = '' + if (fullThoughtForToolOrdering.startsWith(emittedThinking)) { + delta = fullThoughtForToolOrdering.slice(emittedThinking.length) + } else { + delta = fullThoughtForToolOrdering + } + if (delta) { + switchBlockType('thinking') + emittedThinking = fullThoughtForToolOrdering + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'thinking_delta', thinking: delta } + }) + } + } + } for (const part of parts) { const functionCall = part?.functionCall if (!functionCall?.name) { diff --git a/src/services/antigravityClient.js b/src/services/antigravityClient.js index 66d68085..76997e72 100644 --- a/src/services/antigravityClient.js +++ b/src/services/antigravityClient.js @@ -304,6 +304,11 @@ async function request({ } const isRetryable = (error) => { + // 处理网络层面的连接重置或超时(常见于长请求被中间节点切断) + if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') { + return true + } + const status = error?.response?.status if (status === 429) { return true diff --git a/src/services/balanceProviders/geminiBalanceProvider.js b/src/services/balanceProviders/geminiBalanceProvider.js new file mode 100644 index 00000000..0f7fb783 --- /dev/null +++ b/src/services/balanceProviders/geminiBalanceProvider.js @@ -0,0 +1,250 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') +const antigravityClient = require('../antigravityClient') +const geminiAccountService = require('../geminiAccountService') + +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' + +function clamp01(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + if (value < 0) { + return 0 + } + if (value > 1) { + return 1 + } + return value +} + +function round2(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + return Math.round(value * 100) / 100 +} + +function normalizeQuotaCategory(displayName, modelId) { + const name = String(displayName || '') + const id = String(modelId || '') + + if (name.includes('Gemini') && name.includes('Pro')) { + return 'Gemini Pro' + } + if (name.includes('Gemini') && name.includes('Flash')) { + return 'Gemini Flash' + } + if (name.includes('Gemini') && name.toLowerCase().includes('image')) { + return 'Gemini Image' + } + + if (name.includes('Claude') || name.includes('GPT-OSS')) { + return 'Claude' + } + + if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) { + return 'Gemini Pro' + } + if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) { + return 'Gemini Flash' + } + if (id.includes('image')) { + return 'Gemini Image' + } + if (id.includes('claude') || id.includes('gpt-oss')) { + return 'Claude' + } + + return name || id || 'Unknown' +} + +function buildAntigravityQuota(modelsResponse) { + const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null + + if (!models || typeof models !== 'object') { + return null + } + + const parseRemainingFraction = (quotaInfo) => { + if (!quotaInfo || typeof quotaInfo !== 'object') { + return null + } + + const raw = + quotaInfo.remainingFraction ?? + quotaInfo.remaining_fraction ?? + quotaInfo.remaining ?? + undefined + + const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN + if (!Number.isFinite(num)) { + return null + } + + return clamp01(num) + } + + const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']) + const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'] + + const categoryMap = new Map() + + for (const [modelId, modelDataRaw] of Object.entries(models)) { + if (!modelDataRaw || typeof modelDataRaw !== 'object') { + continue + } + + const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId + const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null + + const remainingFraction = parseRemainingFraction(quotaInfo) + if (remainingFraction === null) { + continue + } + + const remainingPercent = round2(remainingFraction * 100) + const usedPercent = round2(100 - remainingPercent) + const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null + + const category = normalizeQuotaCategory(displayName, modelId) + if (!allowedCategories.has(category)) { + continue + } + const entry = { + category, + modelId, + displayName: String(displayName || modelId || category), + remainingPercent, + usedPercent, + resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null + } + + const existing = categoryMap.get(category) + if (!existing || entry.remainingPercent < existing.remainingPercent) { + categoryMap.set(category, entry) + } + } + + const buckets = fixedOrder.map((category) => { + const existing = categoryMap.get(category) || null + if (existing) { + return existing + } + return { + category, + modelId: '', + displayName: category, + remainingPercent: null, + usedPercent: null, + resetAt: null + } + }) + + if (buckets.length === 0) { + return null + } + + const critical = buckets + .filter((item) => item.remainingPercent !== null) + .reduce((min, item) => { + if (!min) { + return item + } + return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min + }, null) + + if (!critical) { + return null + } + + return { + balance: null, + currency: 'USD', + quota: { + type: 'antigravity', + total: 100, + used: critical.usedPercent, + remaining: critical.remainingPercent, + percentage: critical.usedPercent, + resetAt: critical.resetAt, + buckets: buckets.map((item) => ({ + category: item.category, + remaining: item.remainingPercent, + used: item.usedPercent, + percentage: item.usedPercent, + resetAt: item.resetAt + })) + }, + queryMethod: 'api', + rawData: { + modelsCount: Object.keys(models).length, + bucketCount: buckets.length + } + } +} + +class GeminiBalanceProvider extends BaseBalanceProvider { + constructor() { + super('gemini') + } + + async queryBalance(account) { + const oauthProvider = account?.oauthProvider + if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) { + if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) { + return this.readQuotaFromFields(account) + } + return { balance: null, currency: 'USD', queryMethod: 'local' } + } + + const accessToken = String(account?.accessToken || '').trim() + const refreshToken = String(account?.refreshToken || '').trim() + const proxyConfig = account?.proxyConfig || account?.proxy || null + + if (!accessToken) { + throw new Error('Antigravity 账户缺少 accessToken') + } + + const fetch = async (token) => + await antigravityClient.fetchAvailableModels({ + accessToken: token, + proxyConfig + }) + + let data + try { + data = await fetch(accessToken) + } catch (error) { + const status = error?.response?.status + if ((status === 401 || status === 403) && refreshToken) { + const refreshed = await geminiAccountService.refreshAccessToken( + refreshToken, + proxyConfig, + OAUTH_PROVIDER_ANTIGRAVITY + ) + const nextToken = String(refreshed?.access_token || '').trim() + if (!nextToken) { + throw error + } + data = await fetch(nextToken) + } else { + throw error + } + } + + const mapped = buildAntigravityQuota(data) + if (!mapped) { + return { + balance: null, + currency: 'USD', + quota: null, + queryMethod: 'api', + rawData: data || null + } + } + + return mapped + } +} + +module.exports = GeminiBalanceProvider diff --git a/src/services/balanceProviders/index.js b/src/services/balanceProviders/index.js index d55fda5b..47806f1d 100644 --- a/src/services/balanceProviders/index.js +++ b/src/services/balanceProviders/index.js @@ -2,6 +2,7 @@ const ClaudeBalanceProvider = require('./claudeBalanceProvider') const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider') const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider') const GenericBalanceProvider = require('./genericBalanceProvider') +const GeminiBalanceProvider = require('./geminiBalanceProvider') function registerAllProviders(balanceService) { // Claude @@ -14,7 +15,7 @@ function registerAllProviders(balanceService) { balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai')) // 其他平台(降级) - balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini')) + balanceService.registerProvider('gemini', new GeminiBalanceProvider()) balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api')) balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock')) balanceService.registerProvider('droid', new GenericBalanceProvider('droid')) diff --git a/web/admin-spa/src/components/accounts/BalanceDisplay.vue b/web/admin-spa/src/components/accounts/BalanceDisplay.vue index dc8985b2..562e163b 100644 --- a/web/admin-spa/src/components/accounts/BalanceDisplay.vue +++ b/web/admin-spa/src/components/accounts/BalanceDisplay.vue @@ -52,10 +52,51 @@ -