From baffd02b02d0805243b2ece7362cb509f10808c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Wed, 12 Nov 2025 14:10:15 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8DloadCodeAssist=E4=B8=AD?= =?UTF-8?q?=E7=A7=BB=E9=99=A4tokeninfo=E5=92=8Cuserinfo=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决使用GOOGLE_CLOUD_ACCESS_TOKEN时401错误,提升接口响应速度 --- src/services/geminiAccountService.js | 50 ++-------------------------- 1 file changed, 3 insertions(+), 47 deletions(-) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 2e35966e..169df32e 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -1069,54 +1069,10 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) { const { token } = await client.getAccessToken() const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) - const tokenInfoConfig = { - url: 'https://oauth2.googleapis.com/tokeninfo', - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - data: new URLSearchParams({ access_token: token }).toString(), - timeout: 15000 - } - - if (proxyAgent) { - tokenInfoConfig.httpAgent = proxyAgent - tokenInfoConfig.httpsAgent = proxyAgent - tokenInfoConfig.proxy = false - } - - try { - await axios(tokenInfoConfig) - logger.info('📋 tokeninfo 接口验证成功') - } catch (error) { - logger.info('tokeninfo 接口获取失败', error) - } - - const userInfoConfig = { - url: 'https://www.googleapis.com/oauth2/v2/userinfo', - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: '*/*' - }, - timeout: 15000 - } - - if (proxyAgent) { - userInfoConfig.httpAgent = proxyAgent - userInfoConfig.httpsAgent = proxyAgent - userInfoConfig.proxy = false - } - - try { - await axios(userInfoConfig) - logger.info('📋 userinfo 接口获取成功') - } catch (error) { - logger.info('userinfo 接口获取失败', error) - } - // 创建ClientMetadata + // Note: 移除了 tokeninfo 和 userinfo 验证调用 + // 这些调用在原生 gemini-cli 的 CodeAssistServer.loadCodeAssist 中不存在 + // 且会导致使用特殊 token 时出现 401 错误 const clientMetadata = { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', From 91ad0658a9cbe7dff235ba00f2e1cff33c729082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Wed, 12 Nov 2025 14:32:45 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E5=AE=9E=E7=8E=B0listExperiments=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E5=92=8C=E9=80=9A=E7=94=A8=E8=BD=AC=E5=8F=91=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加forwardToCodeAssist通用转发函数支持简单端点 - 添加handleSimpleEndpoint通用路由处理函数 - 注册listExperiments路由(v1internal和v1beta) - 解决gemini-cli启动时404 Not Found错误 --- src/routes/geminiRoutes.js | 65 ++++++++++++++++++++++++++++ src/services/geminiAccountService.js | 42 ++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 8ece60fd..78a44bd3 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -349,6 +349,65 @@ router.get('/key-info', authenticateApiKey, async (req, res) => { } }) +// 通用的简单端点处理函数(用于直接转发的端点) +// 适用于:listExperiments 等不需要特殊业务逻辑的端点 +async function handleSimpleEndpoint(apiMethod) { + return async (req, res) => { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`${apiMethod} request (${version})`, { + apiKeyId: req.apiKey?.id || 'unknown', + requestBody: req.body + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + + // 直接转发请求体,不做特殊处理 + const response = await geminiAccountService.forwardToCodeAssist( + client, + apiMethod, + req.body, + proxyConfig + ) + + res.json(response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in ${apiMethod} endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: 'Internal server error', + message: error.message + }) + } + } +} + // 共用的 loadCodeAssist 处理函数 async function handleLoadCodeAssist(req, res) { try { @@ -1040,6 +1099,7 @@ router.post('/v1internal\\:onboardUser', authenticateApiKey, handleOnboardUser) router.post('/v1internal\\:countTokens', authenticateApiKey, handleCountTokens) router.post('/v1internal\\:generateContent', authenticateApiKey, handleGenerateContent) router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, handleStreamGenerateContent) +router.post('/v1internal\\:listExperiments', authenticateApiKey, handleSimpleEndpoint('listExperiments')) // v1beta 版本的端点 - 支持动态模型名称 router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist) @@ -1055,6 +1115,11 @@ router.post( authenticateApiKey, handleStreamGenerateContent ) +router.post( + '/v1beta/models/:modelName\\:listExperiments', + authenticateApiKey, + handleSimpleEndpoint('listExperiments') +) // 导出处理函数供标准路由使用 module.exports = router diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 169df32e..e50d1744 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -1060,6 +1060,47 @@ async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { return client } +// 通用的 Code Assist API 转发函数(用于简单的请求/响应端点) +// 适用于:loadCodeAssist, onboardUser, countTokens, listExperiments 等不需要特殊处理的端点 +async function forwardToCodeAssist(client, apiMethod, requestBody, proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + + logger.info(`📡 ${apiMethod} API调用开始`) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${apiMethod}`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: requestBody, + timeout: 30000 + } + + // 添加代理配置 + if (proxyAgent) { + axiosConfig.httpAgent = proxyAgent + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info( + `🌐 Using proxy for ${apiMethod}: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug(`🌐 No proxy configured for ${apiMethod}`) + } + + const response = await axios(axiosConfig) + + logger.info(`✅ ${apiMethod} API调用成功`) + return response.data +} + // 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理) async function loadCodeAssist(client, projectId = null, proxyConfig = null) { const axios = require('axios') @@ -1529,6 +1570,7 @@ module.exports = { getAccountRateLimitInfo, isTokenExpired, getOauthClient, + forwardToCodeAssist, // 通用转发函数 loadCodeAssist, getOnboardTier, onboardUser, From cc82812732ff177d12a96efb19957d7100057723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Wed, 12 Nov 2025 16:47:57 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=97=B6=E5=BC=BA=E5=88=B6=E6=89=A7=E8=A1=8C=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=92=8C=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/auto-release-pipeline.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-release-pipeline.yml b/.github/workflows/auto-release-pipeline.yml index ad5f2e01..26397f47 100644 --- a/.github/workflows/auto-release-pipeline.yml +++ b/.github/workflows/auto-release-pipeline.yml @@ -67,8 +67,12 @@ jobs: break fi done <<< "$CHANGED_FILES" - - if [ "$SIGNIFICANT_CHANGES" = true ]; then + + # 检查是否是手动触发 + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "Manual workflow trigger detected, forcing version bump" + echo "needs_bump=true" >> $GITHUB_OUTPUT + elif [ "$SIGNIFICANT_CHANGES" = true ]; then echo "Significant changes detected, version bump needed" echo "needs_bump=true" >> $GITHUB_OUTPUT else From df796a005a84ee2e514b005cc84ce66f46f8c935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Wed, 12 Nov 2025 17:58:57 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8DhandleSimpleEndpoint?= =?UTF-8?q?=E8=BF=94=E5=9B=9EPromise=E5=AF=BC=E8=87=B4=E7=9A=84=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/geminiRoutes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 78a44bd3..54f84426 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -351,7 +351,7 @@ router.get('/key-info', authenticateApiKey, async (req, res) => { // 通用的简单端点处理函数(用于直接转发的端点) // 适用于:listExperiments 等不需要特殊业务逻辑的端点 -async function handleSimpleEndpoint(apiMethod) { +function handleSimpleEndpoint(apiMethod) { return async (req, res) => { try { if (!ensureGeminiPermission(req, res)) { From 008c7a2b03f87b3250bc7b088a93479710809633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Wed, 12 Nov 2025 21:15:44 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E7=A7=BB=E9=99=A4thought=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/standardGeminiRoutes.js | 69 +++--------------------------- 1 file changed, 5 insertions(+), 64 deletions(-) diff --git a/src/routes/standardGeminiRoutes.js b/src/routes/standardGeminiRoutes.js index 57c5b3a3..72ca4f11 100644 --- a/src/routes/standardGeminiRoutes.js +++ b/src/routes/standardGeminiRoutes.js @@ -301,30 +301,9 @@ async function handleStandardGenerateContent(req, res) { } // 返回标准 Gemini API 格式的响应 - // 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤 - if (response.response) { - // 过滤掉 thought 部分(这是内部 API 特有的) - const standardResponse = { ...response.response } - if (standardResponse.candidates) { - standardResponse.candidates = standardResponse.candidates.map((candidate) => { - if (candidate.content && candidate.content.parts) { - // 过滤掉 thought: true 的 parts - const filteredParts = candidate.content.parts.filter((part) => !part.thought) - return { - ...candidate, - content: { - ...candidate.content, - parts: filteredParts - } - } - } - return candidate - }) - } - res.json(standardResponse) - } else { - res.json(response) - } + // 内部 API 返回的是 { response: {...} } 格式,需要提取 + // 注意:不过滤 thought 字段,因为 gemini-cli 会自行处理 + res.json(response.response || response) } catch (error) { logger.error(`Error in standard generateContent endpoint`, { message: error.message, @@ -536,47 +515,9 @@ async function handleStandardStreamGenerateContent(req, res) { } // 转换格式:移除 response 包装,直接返回标准 Gemini API 格式 + // 注意:不过滤 thought 字段,因为 gemini-cli 会自行处理 if (data.response) { - // 过滤掉 thought 部分(这是内部 API 特有的) - if (data.response.candidates) { - const filteredCandidates = data.response.candidates - .map((candidate) => { - if (candidate.content && candidate.content.parts) { - // 过滤掉 thought: true 的 parts - const filteredParts = candidate.content.parts.filter( - (part) => !part.thought - ) - if (filteredParts.length > 0) { - return { - ...candidate, - content: { - ...candidate.content, - parts: filteredParts - } - } - } - return null - } - return candidate - }) - .filter(Boolean) - - // 只有当有有效内容时才发送 - if (filteredCandidates.length > 0 || data.response.usageMetadata) { - const standardResponse = { - candidates: filteredCandidates, - ...(data.response.usageMetadata && { - usageMetadata: data.response.usageMetadata - }), - ...(data.response.modelVersion && { - modelVersion: data.response.modelVersion - }), - ...(data.response.createTime && { createTime: data.response.createTime }), - ...(data.response.responseId && { responseId: data.response.responseId }) - } - res.write(`data: ${JSON.stringify(standardResponse)}\n\n`) - } - } + res.write(`data: ${JSON.stringify(data.response)}\n\n`) } else { // 如果没有 response 包装,直接发送 res.write(`data: ${JSON.stringify(data)}\n\n`) From e130405809b08962b15a5f8b5886dbfdfb87b07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Wed, 12 Nov 2025 21:31:34 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E6=B7=BB=E5=8A=A0tools=E5=92=8CtoolConfig?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/standardGeminiRoutes.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/routes/standardGeminiRoutes.js b/src/routes/standardGeminiRoutes.js index 72ca4f11..46914b93 100644 --- a/src/routes/standardGeminiRoutes.js +++ b/src/routes/standardGeminiRoutes.js @@ -144,7 +144,8 @@ async function handleStandardGenerateContent(req, res) { const sessionHash = sessionHelper.generateSessionHash(req.body) // 标准 Gemini API 请求体直接包含 contents 等字段 - const { contents, generationConfig, safetySettings, systemInstruction } = req.body + const { contents, generationConfig, safetySettings, systemInstruction, tools, toolConfig } = + req.body // 验证必需参数 if (!contents || !Array.isArray(contents) || contents.length === 0) { @@ -172,6 +173,15 @@ async function handleStandardGenerateContent(req, res) { actualRequestData.safetySettings = safetySettings } + // 添加工具配置(tools 和 toolConfig) + if (tools) { + actualRequestData.tools = tools + } + + if (toolConfig) { + actualRequestData.toolConfig = toolConfig + } + // 如果有 system instruction,修正格式并添加到请求体 // Gemini CLI 的内部 API 需要 role: "user" 字段 if (systemInstruction) { @@ -335,7 +345,8 @@ async function handleStandardStreamGenerateContent(req, res) { const sessionHash = sessionHelper.generateSessionHash(req.body) // 标准 Gemini API 请求体直接包含 contents 等字段 - const { contents, generationConfig, safetySettings, systemInstruction } = req.body + const { contents, generationConfig, safetySettings, systemInstruction, tools, toolConfig } = + req.body // 验证必需参数 if (!contents || !Array.isArray(contents) || contents.length === 0) { @@ -363,6 +374,15 @@ async function handleStandardStreamGenerateContent(req, res) { actualRequestData.safetySettings = safetySettings } + // 添加工具配置(tools 和 toolConfig) + if (tools) { + actualRequestData.tools = tools + } + + if (toolConfig) { + actualRequestData.toolConfig = toolConfig + } + // 如果有 system instruction,修正格式并添加到请求体 // Gemini CLI 的内部 API 需要 role: "user" 字段 if (systemInstruction) { From 7a6c287a7e44bd224ee5f3205ad3805936a400d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Thu, 13 Nov 2025 00:48:13 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=87=E5=87=86Gemini?= =?UTF-8?q?=20API=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94=E7=9A=84=E7=BC=93?= =?UTF-8?q?=E5=86=B2=E5=8C=BA=E5=92=8C=E8=A7=A3=E6=9E=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增通用SSE解析器(src/utils/sseParser.js) - 添加streamBuffer处理TCP数据包分割 - 统一两种API方式的SSE解析逻辑 - 记录解析失败和usage缺失的详细日志 --- src/routes/geminiRoutes.js | 21 +-------- src/routes/standardGeminiRoutes.js | 76 +++++++++++++++++++----------- src/utils/sseParser.js | 52 ++++++++++++++++++++ 3 files changed, 101 insertions(+), 48 deletions(-) create mode 100644 src/utils/sseParser.js diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 54f84426..73d4b7c7 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -9,6 +9,7 @@ const sessionHelper = require('../utils/sessionHelper') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const apiKeyService = require('../services/apiKeyService') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const { parseSSELine } = require('../utils/sseParser') // const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file // 生成会话哈希 @@ -917,26 +918,6 @@ async function handleStreamGenerateContent(req, res) { res.setHeader('Connection', 'keep-alive') res.setHeader('X-Accel-Buffering', 'no') - // SSE 解析函数 - const parseSSELine = (line) => { - if (!line.startsWith('data: ')) { - return { type: 'other', line, data: null } - } - - const jsonStr = line.substring(6).trim() - - if (!jsonStr || jsonStr === '[DONE]') { - return { type: 'control', line, data: null, jsonStr } - } - - try { - const data = JSON.parse(jsonStr) - return { type: 'data', line, data, jsonStr } - } catch (e) { - return { type: 'invalid', line, data: null, jsonStr, error: e } - } - } - // 处理流式响应并捕获usage数据 let streamBuffer = '' // 统一的流处理缓冲区 let totalUsage = { diff --git a/src/routes/standardGeminiRoutes.js b/src/routes/standardGeminiRoutes.js index 46914b93..4bf718ef 100644 --- a/src/routes/standardGeminiRoutes.js +++ b/src/routes/standardGeminiRoutes.js @@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const apiKeyService = require('../services/apiKeyService') const sessionHelper = require('../utils/sessionHelper') +const { parseSSELine } = require('../utils/sseParser') // 导入 geminiRoutes 中导出的处理函数 const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes') @@ -509,6 +510,7 @@ async function handleStandardStreamGenerateContent(req, res) { res.setHeader('X-Accel-Buffering', 'no') // 处理流式响应并捕获usage数据 + let streamBuffer = '' // 统一的流处理缓冲区 let totalUsage = { promptTokenCount: 0, candidatesTokenCount: 0, @@ -517,38 +519,52 @@ async function handleStandardStreamGenerateContent(req, res) { streamResponse.on('data', (chunk) => { try { - if (!res.destroyed) { - const chunkStr = chunk.toString() + const chunkStr = chunk.toString() - // 处理 SSE 格式的数据 - const lines = chunkStr.split('\n') - for (const line of lines) { - if (line.startsWith('data: ')) { - const jsonStr = line.substring(6).trim() - if (jsonStr && jsonStr !== '[DONE]') { - try { - const data = JSON.parse(jsonStr) + if (!chunkStr.trim()) { + return + } - // 捕获 usage 数据 - if (data.response?.usageMetadata) { - totalUsage = data.response.usageMetadata - } + // 使用统一缓冲区处理不完整的行 + streamBuffer += chunkStr + const lines = streamBuffer.split('\n') + streamBuffer = lines.pop() || '' // 保留最后一个不完整的行 - // 转换格式:移除 response 包装,直接返回标准 Gemini API 格式 - // 注意:不过滤 thought 字段,因为 gemini-cli 会自行处理 - if (data.response) { - res.write(`data: ${JSON.stringify(data.response)}\n\n`) - } else { - // 如果没有 response 包装,直接发送 - res.write(`data: ${JSON.stringify(data)}\n\n`) - } - } catch (e) { - // 忽略解析错误 - } - } else if (jsonStr === '[DONE]') { - // 保持 [DONE] 标记 - res.write(`${line}\n\n`) + for (const line of lines) { + if (!line.trim()) { + continue // 跳过空行 + } + + // 解析 SSE 行 + const parsed = parseSSELine(line) + + // 记录无效的解析(用于调试) + if (parsed.type === 'invalid') { + logger.warn('Failed to parse SSE line:', { + line: parsed.line.substring(0, 100), + error: parsed.error.message + }) + continue + } + + // 捕获 usage 数据 + if (parsed.type === 'data' && parsed.data.response?.usageMetadata) { + totalUsage = parsed.data.response.usageMetadata + logger.debug('📊 Captured Gemini usage data:', totalUsage) + } + + // 转换格式并发送 + if (!res.destroyed) { + if (parsed.type === 'data') { + // 转换格式:移除 response 包装,直接返回标准 Gemini API 格式 + if (parsed.data.response) { + res.write(`data: ${JSON.stringify(parsed.data.response)}\n\n`) + } else { + res.write(`data: ${JSON.stringify(parsed.data)}\n\n`) } + } else if (parsed.type === 'control') { + // 保持控制消息(如 [DONE])原样 + res.write(`${parsed.line}\n\n`) } } } @@ -578,6 +594,10 @@ async function handleStandardStreamGenerateContent(req, res) { } catch (error) { logger.error('Failed to record Gemini usage:', error) } + } else { + logger.warn( + `⚠️ Stream completed without usage data - totalTokenCount: ${totalUsage.totalTokenCount}` + ) } res.end() diff --git a/src/utils/sseParser.js b/src/utils/sseParser.js new file mode 100644 index 00000000..ea3d6a9c --- /dev/null +++ b/src/utils/sseParser.js @@ -0,0 +1,52 @@ +/** + * Server-Sent Events (SSE) 解析工具 + * + * 用于解析标准 SSE 格式的数据流 + * 当前主要用于 Gemini API 的流式响应处理 + * + * @module sseParser + */ + +/** + * 解析单行 SSE 数据 + * + * @param {string} line - SSE 格式的行(如:"data: {json}\n") + * @returns {Object} 解析结果 + * @returns {'data'|'control'|'other'|'invalid'} .type - 行类型 + * @returns {Object|null} .data - 解析后的 JSON 数据(仅 type='data' 时) + * @returns {string} .line - 原始行内容 + * @returns {string} [.jsonStr] - JSON 字符串 + * @returns {Error} [.error] - 解析错误(仅 type='invalid' 时) + * + * @example + * // 数据行 + * parseSSELine('data: {"key":"value"}') + * // => { type: 'data', data: {key: 'value'}, line: '...', jsonStr: '...' } + * + * @example + * // 控制行 + * parseSSELine('data: [DONE]') + * // => { type: 'control', data: null, line: '...', jsonStr: '[DONE]' } + */ +function parseSSELine(line) { + if (!line.startsWith('data: ')) { + return { type: 'other', line, data: null } + } + + const jsonStr = line.substring(6).trim() + + if (!jsonStr || jsonStr === '[DONE]') { + return { type: 'control', line, data: null, jsonStr } + } + + try { + const data = JSON.parse(jsonStr) + return { type: 'data', line, data, jsonStr } + } catch (e) { + return { type: 'invalid', line, data: null, jsonStr, error: e } + } +} + +module.exports = { + parseSSELine +} From a64b0d557fc4f1c86531be546c1463b6b27f05f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Fri, 14 Nov 2025 10:39:35 +0800 Subject: [PATCH 8/9] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8DloadCodeAssist?= =?UTF-8?q?=E4=B8=AD=E7=A7=BB=E9=99=A4tokeninfo=E5=92=8Cuserinfo=E8=B0=83?= =?UTF-8?q?=E7=94=A8"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit baffd02b02d0805243b2ece7362cb509f10808c2. --- src/services/geminiAccountService.js | 50 ++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index e50d1744..5cb2cff2 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -1110,10 +1110,54 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) { const { token } = await client.getAccessToken() const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + const tokenInfoConfig = { + url: 'https://oauth2.googleapis.com/tokeninfo', + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: new URLSearchParams({ access_token: token }).toString(), + timeout: 15000 + } + + if (proxyAgent) { + tokenInfoConfig.httpAgent = proxyAgent + tokenInfoConfig.httpsAgent = proxyAgent + tokenInfoConfig.proxy = false + } + + try { + await axios(tokenInfoConfig) + logger.info('📋 tokeninfo 接口验证成功') + } catch (error) { + logger.info('tokeninfo 接口获取失败', error) + } + + const userInfoConfig = { + url: 'https://www.googleapis.com/oauth2/v2/userinfo', + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: '*/*' + }, + timeout: 15000 + } + + if (proxyAgent) { + userInfoConfig.httpAgent = proxyAgent + userInfoConfig.httpsAgent = proxyAgent + userInfoConfig.proxy = false + } + + try { + await axios(userInfoConfig) + logger.info('📋 userinfo 接口获取成功') + } catch (error) { + logger.info('userinfo 接口获取失败', error) + } + // 创建ClientMetadata - // Note: 移除了 tokeninfo 和 userinfo 验证调用 - // 这些调用在原生 gemini-cli 的 CodeAssistServer.loadCodeAssist 中不存在 - // 且会导致使用特殊 token 时出现 401 错误 const clientMetadata = { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', From 47d7a394c9cd51146c6156b1e1ec21243ccc3ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BA=86=E9=9B=B7?= Date: Fri, 14 Nov 2025 11:13:56 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E4=BB=85=E5=AF=B9=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E8=B0=83=E7=94=A8=20tokeninfo/userinfo=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 projectId 非空判断,减少对企业账户的影响 - 优化错误日志级别为 warn --- src/routes/geminiRoutes.js | 15 ++++- src/services/geminiAccountService.js | 89 ++++++++++++++-------------- 2 files changed, 57 insertions(+), 47 deletions(-) diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 73d4b7c7..a4239a0c 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -381,13 +381,18 @@ function handleSimpleEndpoint(apiMethod) { let proxyConfig = null if (account.proxy) { try { - proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + 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(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig + ) // 直接转发请求体,不做特殊处理 const response = await geminiAccountService.forwardToCodeAssist( @@ -1080,7 +1085,11 @@ router.post('/v1internal\\:onboardUser', authenticateApiKey, handleOnboardUser) router.post('/v1internal\\:countTokens', authenticateApiKey, handleCountTokens) router.post('/v1internal\\:generateContent', authenticateApiKey, handleGenerateContent) router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, handleStreamGenerateContent) -router.post('/v1internal\\:listExperiments', authenticateApiKey, handleSimpleEndpoint('listExperiments')) +router.post( + '/v1internal\\:listExperiments', + authenticateApiKey, + handleSimpleEndpoint('listExperiments') +) // v1beta 版本的端点 - 支持动态模型名称 router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist) diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 5cb2cff2..a86490a4 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -1088,9 +1088,7 @@ async function forwardToCodeAssist(client, apiMethod, requestBody, proxyConfig = axiosConfig.httpAgent = proxyAgent axiosConfig.httpsAgent = proxyAgent axiosConfig.proxy = false - logger.info( - `🌐 Using proxy for ${apiMethod}: ${ProxyHelper.getProxyDescription(proxyConfig)}` - ) + logger.info(`🌐 Using proxy for ${apiMethod}: ${ProxyHelper.getProxyDescription(proxyConfig)}`) } else { logger.debug(`🌐 No proxy configured for ${apiMethod}`) } @@ -1109,52 +1107,55 @@ async function loadCodeAssist(client, projectId = null, proxyConfig = null) { const { token } = await client.getAccessToken() const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + // 🔍 只有个人账户(无 projectId)才需要调用 tokeninfo/userinfo + // 这些调用有助于 Google 获取临时 projectId + if (!projectId) { + const tokenInfoConfig = { + url: 'https://oauth2.googleapis.com/tokeninfo', + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: new URLSearchParams({ access_token: token }).toString(), + timeout: 15000 + } - const tokenInfoConfig = { - url: 'https://oauth2.googleapis.com/tokeninfo', - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - data: new URLSearchParams({ access_token: token }).toString(), - timeout: 15000 - } + if (proxyAgent) { + tokenInfoConfig.httpAgent = proxyAgent + tokenInfoConfig.httpsAgent = proxyAgent + tokenInfoConfig.proxy = false + } - if (proxyAgent) { - tokenInfoConfig.httpAgent = proxyAgent - tokenInfoConfig.httpsAgent = proxyAgent - tokenInfoConfig.proxy = false - } + try { + await axios(tokenInfoConfig) + logger.info('📋 tokeninfo 接口验证成功') + } catch (error) { + logger.warn('⚠️ tokeninfo 接口调用失败:', error.message) + } - try { - await axios(tokenInfoConfig) - logger.info('📋 tokeninfo 接口验证成功') - } catch (error) { - logger.info('tokeninfo 接口获取失败', error) - } + const userInfoConfig = { + url: 'https://www.googleapis.com/oauth2/v2/userinfo', + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: '*/*' + }, + timeout: 15000 + } - const userInfoConfig = { - url: 'https://www.googleapis.com/oauth2/v2/userinfo', - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: '*/*' - }, - timeout: 15000 - } + if (proxyAgent) { + userInfoConfig.httpAgent = proxyAgent + userInfoConfig.httpsAgent = proxyAgent + userInfoConfig.proxy = false + } - if (proxyAgent) { - userInfoConfig.httpAgent = proxyAgent - userInfoConfig.httpsAgent = proxyAgent - userInfoConfig.proxy = false - } - - try { - await axios(userInfoConfig) - logger.info('📋 userinfo 接口获取成功') - } catch (error) { - logger.info('userinfo 接口获取失败', error) + try { + await axios(userInfoConfig) + logger.info('📋 userinfo 接口获取成功') + } catch (error) { + logger.warn('⚠️ userinfo 接口调用失败:', error.message) + } } // 创建ClientMetadata