fix: judge stream completion by finish reason and trailing usage

Both transport paths now share one verdict: a stream is complete on
[DONE] or on finish_reason followed by measured usage and a clean EOF
(both modes; missing_done only governs real truncation). Outcomes follow
the design's first-match table: upstream_error finishes stay transient,
fidelity mode delivers reasoning-only/length results, text mode raises
ResultInvalid for content-less length/filter endings, and non-stream
fidelity judges before building the snapshot so truncated tools reach
tool_truncated. Projections honour the reasoning alias, dialect warnings
are bounded, and responses carry finish_reason and stream_end.

Red: 39 of 46 tests in tests/unit/test_completion_transport.py failed
before this change (e.g. eof_after_finish raised 保真响应缺 [DONE];
reasoning-only length raised empty_completion; reasoning-only stream hit
the TTFT watchdog, re-confirmed by disabling alias liveness).
This commit is contained in:
2026-10-14 23:31:54 -04:00
parent 16743ca11d
commit 39a319e34e
4 changed files with 661 additions and 84 deletions
+24 -2
View File
@@ -146,10 +146,10 @@ async def test_invalid_mode_combinations_fail_before_http(kwargs):
async def test_openai_never_salvages():
"""已收完整工具也必须有 DONE。"""
"""无 finish 后 usage 的截断流即使收全工具也不打捞(1.3.10: 判据由 DONE 改为完成判定)。"""
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False))
try:
with pytest.raises(TransientError, match="DONE"):
with pytest.raises(TransientError, match="missing_done"):
await transport.complete(
messages=[],
source=_source(missing_done="salvage"),
@@ -165,6 +165,28 @@ async def test_openai_never_salvages():
await transport.aclose()
async def test_openai_finish_with_trailing_usage_is_complete_without_done():
"""对照: 同一工具流补上 finish 之后的 usage 帧,即使无 DONE 也完整交回(#31)。"""
usage = 'data: {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}}\n\n'
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), usage, done=False))
try:
result = await transport.complete(
messages=[],
source=_source(),
stream=True,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="openai",
)
finally:
await transport.aclose()
assert result.stream_end == "eof_after_finish"
assert [t["id"] for t in result.completion.message["tool_calls"]] == ["a", "b"]
async def test_tool_progress_sets_events():
"""工具首进展驱动两个事件与时间观测。"""
import asyncio