Merge pull request #662 from zengqinglei/fix-gemini-standard-api-issues

修复Gemini标准API多个兼容性问题
This commit is contained in:
Wesley Liddick
2025-11-13 22:33:25 -05:00
committed by GitHub
5 changed files with 289 additions and 154 deletions

View File

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

View File

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

View File

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

View File

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

52
src/utils/sseParser.js Normal file
View File

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