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 diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 8ece60fd..a4239a0c 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 // 生成会话哈希 @@ -349,6 +350,70 @@ router.get('/key-info', authenticateApiKey, async (req, res) => { } }) +// 通用的简单端点处理函数(用于直接转发的端点) +// 适用于:listExperiments 等不需要特殊业务逻辑的端点 +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 { @@ -858,26 +923,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 = { @@ -1040,6 +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') +) // v1beta 版本的端点 - 支持动态模型名称 router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist) @@ -1055,6 +1105,11 @@ router.post( authenticateApiKey, handleStreamGenerateContent ) +router.post( + '/v1beta/models/:modelName\\:listExperiments', + authenticateApiKey, + handleSimpleEndpoint('listExperiments') +) // 导出处理函数供标准路由使用 module.exports = router diff --git a/src/routes/standardGeminiRoutes.js b/src/routes/standardGeminiRoutes.js index 57c5b3a3..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') @@ -144,7 +145,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 +174,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) { @@ -301,30 +312,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, @@ -356,7 +346,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) { @@ -384,6 +375,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) { @@ -510,6 +510,7 @@ async function handleStandardStreamGenerateContent(req, res) { res.setHeader('X-Accel-Buffering', 'no') // 处理流式响应并捕获usage数据 + let streamBuffer = '' // 统一的流处理缓冲区 let totalUsage = { promptTokenCount: 0, candidatesTokenCount: 0, @@ -518,76 +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 格式 - 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) + for (const line of lines) { + if (!line.trim()) { + continue // 跳过空行 + } - // 只有当有有效内容时才发送 - 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`) - } - } - } else { - // 如果没有 response 包装,直接发送 - res.write(`data: ${JSON.stringify(data)}\n\n`) - } - } catch (e) { - // 忽略解析错误 - } - } else if (jsonStr === '[DONE]') { - // 保持 [DONE] 标记 - res.write(`${line}\n\n`) + // 解析 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`) } } } @@ -617,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/services/geminiAccountService.js b/src/services/geminiAccountService.js index 2e35966e..a86490a4 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -1060,6 +1060,45 @@ 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') @@ -1068,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 @@ -1573,6 +1615,7 @@ module.exports = { getAccountRateLimitInfo, isTokenExpired, getOauthClient, + forwardToCodeAssist, // 通用转发函数 loadCodeAssist, getOnboardTier, onboardUser, 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 +}