xtc-backend / app /utils /sse.py
a3216's picture
sync from GitHub 7633a5e: feat: 修复前端会话存储泄漏与后端管理面板多处问题
88deaca verified
Raw
History Blame Contribute Delete
4.58 kB
"""SSE 流式工具。"""
from __future__ import annotations
import codecs
import json
from typing import Any, AsyncIterator, Iterable, Optional
def sse_data(obj: Any) -> str:
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
def sse_done() -> str:
return "data: [DONE]\n\n"
def sse_event(event: str, obj: Any) -> str:
return f"event: {event}\ndata: {json.dumps(obj, ensure_ascii=False)}\n\n"
async def parse_sse_stream(byte_stream: AsyncIterator[bytes]) -> AsyncIterator[dict]:
"""从字节流解析 SSE,yield 每个 data JSON。
两个关键点(否则中文/长回复会丢字、换行被破坏):
1. 用增量 UTF-8 解码器(codecs.getincrementaldecoder)跨 chunk 组装多字节
字符。若每个 chunk 单独 decode(errors="replace"),被 TCP 分段切断的中文
字符的前半字节会被替换成 ``?``,造成乱码/丢字。
2. 按完整 SSE 帧(``\\n\\n`` 分隔)缓冲后再解析。httpx 的 aiter_bytes()
字节块边界与 SSE 帧边界无关,若按每个 chunk 独立 split('\\n') 解析,
跨块切断的 ``data: {...}`` 行会 json.loads 失败被静默丢弃。
"""
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
buffer = ""
async for chunk in byte_stream:
if isinstance(chunk, (bytes, bytearray)):
text = decoder.decode(bytes(chunk))
else:
text = str(chunk)
buffer += text
# SSE 以双换行分隔
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
data_lines = []
for line in block.split("\n"):
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if not data_lines:
continue
data_str = "\n".join(data_lines)
if data_str == "[DONE]":
yield {"__done__": True}
return
try:
yield json.loads(data_str)
except json.JSONDecodeError:
yield {"__raw__": data_str}
# 收尾:flush 增量解码器残余字节,并尝试解析缓冲区里最后一帧
tail = decoder.decode(b"", final=True)
if tail:
buffer += tail
if buffer.strip():
data_lines = []
for line in buffer.split("\n"):
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if data_lines:
data_str = "\n".join(data_lines)
if data_str == "[DONE]":
yield {"__done__": True}
return
try:
yield json.loads(data_str)
except json.JSONDecodeError:
yield {"__raw__": data_str}
def _usage_from_sse_block(block: str) -> Optional[dict]:
"""从一个 SSE 帧文本里提取 usage 字段,没有则返回 None。"""
data_lines = []
for line in block.split("\n"):
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if not data_lines:
return None
data_str = "\n".join(data_lines)
if data_str == "[DONE]":
return None
try:
obj = json.loads(data_str)
except json.JSONDecodeError:
return None
if not isinstance(obj, dict):
return None
u = obj.get("usage")
return u if isinstance(u, dict) else None
async def passthrough_with_usage(
byte_stream: AsyncIterator[bytes], state: dict
) -> AsyncIterator[bytes]:
"""透传 SSE 字节流,同时把最后一个 chunk 的 usage 写入 ``state['usage']``。
用于 OpenAI 透传流式:把字节原样转发给客户端的同时,旁路解析 SSE 帧,
提取上游在最后一个 chunk 里返回的 usage。需要请求时带
``stream_options.include_usage=true`` 上游才会返回 usage。
"""
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
buffer = ""
async for chunk in byte_stream:
yield chunk
if isinstance(chunk, (bytes, bytearray)):
text = decoder.decode(bytes(chunk))
else:
text = str(chunk)
buffer += text
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
u = _usage_from_sse_block(block)
if u is not None:
state["usage"] = u
tail = decoder.decode(b"", final=True)
if tail:
buffer += tail
if buffer.strip():
u = _usage_from_sse_block(buffer)
if u is not None:
state["usage"] = u