mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge pull request #662 from zengqinglei/fix-gemini-standard-api-issues
修复Gemini标准API多个兼容性问题
This commit is contained in:
8
.github/workflows/auto-release-pipeline.yml
vendored
8
.github/workflows/auto-release-pipeline.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
52
src/utils/sseParser.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user