mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 增强 Gemini 流式响应兼容性,支持 JSON-array 格式解析
- 添加 Gemini JSON-array/chunks 格式的解析支持(兼容非 SSE 返回) - 在上游请求中添加 alt=sse 参数,优先使用 SSE 格式 - 为 OpenAI 客户端统一补齐 [DONE] 流结束标记 - 更新 Gemini finishReason 判断逻辑,采用更宽松的结束判定
This commit is contained in:
@@ -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 "",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user