diff --git a/adapters/llm.py b/adapters/llm.py index 8443301..6af3b3d 100644 --- a/adapters/llm.py +++ b/adapters/llm.py @@ -578,6 +578,10 @@ class GovernedLLMClient: else: thinking_parts.append(text) + # 流耗尽但未收 [DONE] → 服务端截断,视为可重试的 SSE 异常(不写缓存/不当成功) + if not usage_sink.get("done"): + raise _SseAnomaly("truncated_no_done") + content = "".join(content_parts) thinking = "".join(thinking_parts) usage = usage_sink.get("usage", {}) diff --git a/tests/unit/test_governed_llm.py b/tests/unit/test_governed_llm.py index 9f487e4..c75d2b8 100644 --- a/tests/unit/test_governed_llm.py +++ b/tests/unit/test_governed_llm.py @@ -259,6 +259,33 @@ async def test_qwen_thinking_stripped(): assert thinking2 == "" +@pytest.mark.asyncio +async def test_truncated_stream_without_done_raises(): + """SSE 流耗尽但未收 [DONE] → _SseAnomaly(进重试,不当成功)。""" + from adapters.llm import _SseAnomaly + + async def _lines(): + yield 'data: {"choices":[{"delta":{"content":"半"}}]}' + # 无 data: [DONE] —— 模拟服务端截断 + + client = _build_client() + with pytest.raises(_SseAnomaly): + await client._consume_stream(_lines()) + + +@pytest.mark.asyncio +async def test_complete_stream_with_done_ok(): + """正常带 [DONE] 的流不抛异常,正确累积 content。""" + + async def _lines(): + yield 'data: {"choices":[{"delta":{"content":"完整"}}]}' + yield "data: [DONE]" + + client = _build_client() + content, _thinking, _ttft, _gap, _usage = await client._consume_stream(_lines()) + assert content == "完整" + + @pytest.mark.asyncio async def test_parent_call_id_forwarded_to_telemetry(): """parent_call_id 和 session_id 正确传递到遥测记录。"""