feat: 增强 Gemini 流式响应兼容性,支持 JSON-array 格式解析

- 添加 Gemini JSON-array/chunks 格式的解析支持(兼容非 SSE 返回)
- 在上游请求中添加 alt=sse 参数,优先使用 SSE 格式
- 为 OpenAI 客户端统一补齐 [DONE] 流结束标记
- 更新 Gemini finishReason 判断逻辑,采用更宽松的结束判定
This commit is contained in:
fawney19
2026-01-23 23:34:38 +08:00
parent c3be83bf94
commit 76ed7e9fcb
5 changed files with 154 additions and 115 deletions

View File

@@ -2005,27 +2005,39 @@ class CliMessageHandlerBase(BaseMessageHandler):
if not line or line.strip() == "" or line == "data: [DONE]":
return [line] if line else []
# 如果不是 data 行,直接透传
provider_format = (ctx.provider_api_format or "").upper()
client_format = (ctx.client_api_format or "").upper()
# 兼容 Gemini 上游 JSON-array/chunks可能是“裸 JSON 行”而非 `data: {...}`
if not line.startswith("data:"):
return [line]
if provider_format != "GEMINI":
return [line]
# 提取 data 内容
data_content = line[5:].strip() # 去掉 "data:" 前缀
stripped = line.strip()
if stripped in ("", "[", "]", ","):
return []
# 尝试解析 JSON
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
# 无法解析,直接透传
return [line]
candidate = stripped.lstrip(",").rstrip(",").strip()
try:
data_obj = json.loads(candidate)
except json.JSONDecodeError:
# 不是可解析的 JSON 对象:不透传(避免把 Gemini 原始分块泄漏到目标 SSE
logger.debug(f"Gemini JSON-array line skip: {stripped[:50]}")
return []
else:
# 提取 data 内容
data_content = line[5:].strip() # 去掉 "data:" 前缀
# 类型断言:当 needs_conversion=True 调用此方法时,格式字段必定有值
provider_format = ctx.provider_api_format or ""
client_format = ctx.client_api_format or ""
# 尝试解析 JSON
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
# 无法解析,直接透传
return [line]
# 初始化流式转换状态(首次调用时,根据 Provider 格式选择状态类)
if ctx.stream_conversion_state is None:
if provider_format.upper() == "GEMINI":
if provider_format == "GEMINI":
ctx.stream_conversion_state = GeminiStreamConversionState(
model=ctx.mapped_model or ctx.model or "",
message_id=ctx.response_id or ctx.request_id or "",

View File

@@ -484,86 +484,102 @@ class StreamProcessor:
message_id=ctx.response_id or ctx.request_id or "",
)
skip_next_blank_line = False
empty_yield_count = 0 # 空转计数(防护异常情况)
skip_next_blank_line = False
empty_yield_count = 0 # 空转计数(防护异常情况)
openai_done_sent = (
False # 统一为 OpenAI 客户端补齐 [DONE](避免不同 Provider 行为差异)
)
def _emit_converted_line(normalized_line: str) -> list[bytes]:
nonlocal skip_next_blank_line
def _emit_converted_line(normalized_line: str) -> list[bytes]:
nonlocal skip_next_blank_line, openai_done_sent
# 空行:事件分隔符(避免重复输出)
if normalized_line == "":
if skip_next_blank_line:
skip_next_blank_line = False
# 空行:事件分隔符(避免重复输出)
if normalized_line == "":
if skip_next_blank_line:
skip_next_blank_line = False
return []
return [b"\n"]
# 丢弃 Provider 的 event 行,避免泄漏/污染目标格式
if normalized_line.startswith("event:"):
return []
return [b"\n"]
# 丢弃 Provider 的 event 行,避免泄漏/污染目标格式
if normalized_line.startswith("event:"):
return []
# OpenAI done 信号(仅用于 OpenAI 客户端)
if (
normalized_line.startswith("data:")
and normalized_line[5:].strip() == "[DONE]"
):
skip_next_blank_line = True
if client_format.startswith("OPENAI"):
openai_done_sent = True
return [b"data: [DONE]\n\n"]
return []
# 默认只处理 SSE 的 data 行;但 Gemini 上游可能返回 JSON-array/chunks无 data 前缀)
is_data_line = normalized_line.startswith("data:")
if not is_data_line:
if provider_format != "GEMINI":
return []
data_content = normalized_line.strip()
else:
data_content = normalized_line[5:].strip()
# Gemini 可能包含 JSON 数组包装符,直接忽略
if data_content in ("", "[", "]", ","):
return []
# JSON-array/chunks 可能带前后逗号(对象分隔符),做一次保守清理
data_content = data_content.lstrip(",").rstrip(",").strip()
if data_content in ("", "[", "]", ","):
return []
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
# 跨格式转换时JSON 解析失败应跳过而不是透传(避免泄漏 Provider 格式)
logger.warning(
f"[{self.request_id}] JSON 解析失败,跳过该行: {data_content[:100]}"
)
return []
if not isinstance(data_obj, dict):
return []
try:
converted_events = converter_registry.convert_stream_chunk_strict(
data_obj,
provider_format,
client_format,
state=ctx.stream_conversion_state,
)
except Exception as conv_err:
# 首字节后无法 failover输出目标格式错误事件并终止流
# 使用 502 表示上游返回了非预期格式Bad Gateway
ctx.status_code = 502
ctx.error_message = "format_conversion_failed"
# 日志记录完整错误(内部排查),客户端只返回脱敏消息
logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}")
payload = _build_stream_error_payload("响应格式转换失败,请稍后重试")
error_bytes = (
f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode(
"utf-8"
)
)
done_bytes = (
b"data: [DONE]\n\n" if client_format.startswith("OPENAI") else b""
)
if done_bytes:
openai_done_sent = True
return [error_bytes, done_bytes]
# OpenAI done 信号
if (
normalized_line.startswith("data:")
and normalized_line[5:].strip() == "[DONE]"
):
skip_next_blank_line = True
if client_format.startswith("OPENAI"):
return [b"data: [DONE]\n\n"]
return []
# 非 data 行:在跨格式场景下统一丢弃(避免泄露 Provider 格式细节)
if not normalized_line.startswith("data:"):
return []
data_content = normalized_line[5:].strip()
# Gemini 可能包含 JSON 数组包装符,直接忽略
if data_content in ("", "[", "]", ","):
return []
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
# 跨格式转换时JSON 解析失败应跳过而不是透传(避免泄漏 Provider 格式)
logger.warning(
f"[{self.request_id}] JSON 解析失败,跳过该行: {data_content[:100]}"
)
return []
if not isinstance(data_obj, dict):
return []
try:
converted_events = converter_registry.convert_stream_chunk_strict(
data_obj,
provider_format,
client_format,
state=ctx.stream_conversion_state,
)
except Exception as conv_err:
# 首字节后无法 failover输出目标格式错误事件并终止流
# 使用 502 表示上游返回了非预期格式Bad Gateway
ctx.status_code = 502
ctx.error_message = "format_conversion_failed"
# 日志记录完整错误(内部排查),客户端只返回脱敏消息
logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}")
payload = _build_stream_error_payload("响应格式转换失败,请稍后重试")
error_bytes = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode(
"utf-8"
)
done_bytes = (
b"data: [DONE]\n\n" if client_format.startswith("OPENAI") else b""
)
return [error_bytes, done_bytes]
skip_next_blank_line = True
out: list[bytes] = []
for evt in converted_events:
# 统一使用 SSE 格式输出Gemini streamGenerateContent 也使用 SSE
# 参考: https://ai.google.dev/api/generate-content
out.append(
f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode("utf-8")
)
return out
out: list[bytes] = []
for evt in converted_events:
# 统一使用 SSE 格式输出Gemini streamGenerateContent 也使用 SSE
# 参考: https://ai.google.dev/api/generate-content
out.append(
f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode("utf-8")
)
return out
# 统一处理 prefetched + iterator
if prefetched_chunks:
@@ -653,6 +669,11 @@ class StreamProcessor:
if ctx.error_message == "format_conversion_failed":
return
# Provider 流结束后,为 OpenAI 客户端补齐 [DONE](许多上游不发送该哨兵)
if client_format.startswith("OPENAI") and not openai_done_sent:
_mark_stream_started()
yield b"data: [DONE]\n\n"
else:
if prefetched_chunks:
for chunk in prefetched_chunks: