mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00: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:
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""
|
||||
Gemini SSE/JSON 流解析器
|
||||
Gemini 流解析器(SSE + JSON-array 兼容)
|
||||
|
||||
Gemini API 的流式响应格式与 Claude/OpenAI 不同:
|
||||
- 使用 JSON 数组格式 (不是 SSE)
|
||||
- 每个块是一个完整的 JSON 对象
|
||||
- 响应以 [ 开始,以 ] 结束,块之间用 , 分隔
|
||||
Gemini streamGenerateContent 常见两种返回(与上游/代理实现有关):
|
||||
1) `?alt=sse`:SSE(`data: {GenerateContentResponse}`)
|
||||
2) 默认:JSON-array / JSON-chunks(`[{...},{...},...]`,可能跨 chunk/跨行)
|
||||
|
||||
参考: https://ai.google.dev/api/generate-content#method:-models.streamgeneratecontent
|
||||
本解析器提供:
|
||||
- parse_line(): 适用于 SSE data 行或逐行 JSON 对象
|
||||
- parse_chunk(): 适用于 JSON-array/chunks(可跨 chunk 拼接)
|
||||
|
||||
参考:
|
||||
- https://ai.google.dev/gemini-api/docs/text-generation?lang=python#generate-a-text-stream
|
||||
- https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -20,17 +25,12 @@ class GeminiStreamParser:
|
||||
解析 Gemini streamGenerateContent API 的响应流。
|
||||
|
||||
Gemini 流式响应特点:
|
||||
- 返回 JSON 数组格式: [{chunk1}, {chunk2}, ...]
|
||||
- 每个 chunk 包含 candidates、usageMetadata 等字段
|
||||
- finish_reason 可能值: STOP, MAX_TOKENS, SAFETY, RECITATION, OTHER
|
||||
- 每个事件块本质上都是一个 GenerateContentResponse JSON 对象(包含 candidates、usageMetadata 等)
|
||||
- 结束判定以 `candidates[].finishReason` 为准(存在且不为 FINISH_REASON_UNSPECIFIED)
|
||||
"""
|
||||
|
||||
# 停止原因
|
||||
FINISH_REASON_STOP = "STOP"
|
||||
FINISH_REASON_MAX_TOKENS = "MAX_TOKENS"
|
||||
FINISH_REASON_SAFETY = "SAFETY"
|
||||
FINISH_REASON_RECITATION = "RECITATION"
|
||||
FINISH_REASON_OTHER = "OTHER"
|
||||
# finishReason(官方枚举值很多,见 discovery;这里仅保留一个明确的“未结束”哨兵)
|
||||
FINISH_REASON_UNSPECIFIED = "FINISH_REASON_UNSPECIFIED"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = ""
|
||||
@@ -129,17 +129,18 @@ class GeminiStreamParser:
|
||||
True 如果是结束事件
|
||||
"""
|
||||
candidates = event.get("candidates", [])
|
||||
if candidates:
|
||||
for candidate in candidates:
|
||||
finish_reason = candidate.get("finishReason")
|
||||
if finish_reason in (
|
||||
self.FINISH_REASON_STOP,
|
||||
self.FINISH_REASON_MAX_TOKENS,
|
||||
self.FINISH_REASON_SAFETY,
|
||||
self.FINISH_REASON_RECITATION,
|
||||
self.FINISH_REASON_OTHER,
|
||||
):
|
||||
return True
|
||||
if not candidates:
|
||||
return False
|
||||
|
||||
for candidate in candidates:
|
||||
finish_reason = candidate.get("finishReason")
|
||||
if not finish_reason:
|
||||
continue
|
||||
# 只要出现非 UNSPECIFIED 的 finishReason,通常表示该 candidate 已结束。
|
||||
# 例如:STOP/MAX_TOKENS/SAFETY/RECITATION/MALFORMED_FUNCTION_CALL/...(枚举持续演进)
|
||||
if str(finish_reason) != self.FINISH_REASON_UNSPECIFIED:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_error_event(self, event: Dict[str, Any]) -> bool:
|
||||
|
||||
@@ -128,6 +128,10 @@ def build_provider_url(
|
||||
# 上游认证始终使用 header 方式,不使用 URL 参数
|
||||
if resolved_format in (APIFormat.GEMINI, APIFormat.GEMINI_CLI):
|
||||
effective_query_params.pop("key", None)
|
||||
# Gemini streamGenerateContent 官方支持 `?alt=sse` 返回 SSE(data: {...})。
|
||||
# 网关侧统一使用 SSE 输出,优先向上游请求 SSE 以减少解析分支;同时保留 JSON-array 兜底解析。
|
||||
if is_stream:
|
||||
effective_query_params.setdefault("alt", "sse")
|
||||
|
||||
# 添加查询参数
|
||||
if effective_query_params:
|
||||
|
||||
@@ -91,6 +91,8 @@ async def test_create_response_stream_converts_claude_to_openai() -> None:
|
||||
events = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data: "):
|
||||
if line == "data: [DONE]":
|
||||
continue
|
||||
events.append(json.loads(line[6:]))
|
||||
|
||||
assert len(events) >= 2
|
||||
@@ -100,4 +102,3 @@ async def test_create_response_stream_converts_claude_to_openai() -> None:
|
||||
for e in events
|
||||
if isinstance(e, dict)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user