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
+2
View File
@@ -741,6 +741,8 @@ class RetryMW:
return LLMResponse(
content=result.content,
completion=result.completion,
finish_reason=result.finish_reason,
stream_end=result.stream_end,
thinking=result.thinking,
model=source.model,
provider=source.provider,
+217 -82
View File
@@ -12,7 +12,7 @@ import json
import math
import re
import time
from dataclasses import replace
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any
import httpx
@@ -41,16 +41,21 @@ from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.transports._openai_completion import (
ChatCompletionAccumulator,
ChatDelta,
parse_chat_completion,
build_completion,
check_completion_body,
)
from polygateway.types import (
Effort,
EmbeddingTransportResult,
OpenAIChatCompletion,
ResponseMode,
SourceConfig,
StreamEnd,
TransportResult,
_has_completion_output,
_classify_finish,
_judge_outcome,
_stream_is_complete,
_summarize_message,
_summarize_text,
)
if TYPE_CHECKING:
@@ -58,6 +63,9 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Mapping
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
# 方言告警节流上限与 finish 值截断(设计 §5.4): 键由上游决定,必须有界
_WARN_CAP = 1024
_FINISH_WARN_CHARS = 64
# —— SSE 纯函数(VT llm.py 同款)——
@@ -135,6 +143,125 @@ async def _iter_sse_deltas(
yield delta
@dataclass
class _StreamTiming:
"""一次流式尝试的计时与文本片段(每尝试新建,不跨重试/对冲共享)。"""
content_parts: list[str] = field(default_factory=list)
reasoning_parts: list[str] = field(default_factory=list)
alias_parts: list[str] = field(default_factory=list)
chunk_times: list[float] = field(default_factory=list)
ttft_ms: float | None = None
max_gap: float = 0.0
@property
def has_reasoning(self) -> bool:
"""出现过非空 reasoning_content: 别名 `reasoning` 让位(设计 §4.4)。"""
return bool(self.reasoning_parts)
async def _consume_deltas(
guarded: AsyncIterator[ChatDelta],
started: float,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> _StreamTiming:
"""消费进展事件: 计时、置位对冲事件、收集文本片段。"""
timing = _StreamTiming()
last = started
async for delta in guarded:
now = time.monotonic()
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
# 空 chunk/usage 帧/SSE keepalive 已在聚合器被过滤
timing.chunk_times.append(now)
if content_event is not None:
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
content_event.set()
if timing.ttft_ms is None:
timing.ttft_ms = (now - started) * 1000
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
if first_token_event is not None:
first_token_event.set()
else:
timing.max_gap = max(timing.max_gap, (now - last) * 1000)
last = now
for parts, text in (
(timing.content_parts, delta.content),
(timing.reasoning_parts, delta.reasoning_content),
(timing.alias_parts, delta.reasoning),
):
if text:
parts.append(text)
return timing
def _raise_unless_deliverable(
mode: ResponseMode,
message: dict[str, Any] | None,
content: str,
thinking: str,
finish_reason: str | None,
source: SourceConfig,
) -> None:
"""结局判定(设计 §4.3)的唯一翻译点: 判定值 → 领域错误。
原因码以固定格式进 message(遥测 error 列可检索);不回显推理/工具内容。
"""
if mode == "openai" and message is not None:
summary = _summarize_message(message, content)
else:
summary = _summarize_text(content, thinking)
verdict = _judge_outcome(mode=mode, summary=summary, finish=_classify_finish(finish_reason))
if verdict.deliver:
return
text = (
f"{source.name} {verdict.code}: finish_reason={finish_reason!r}, "
f"thinking_len={len(thinking)}"
)
if verdict.error_kind == "result_invalid":
raise ResultInvalidError(text, raw_text="", source_name=source.name, operation="chat")
raise TransientError(text, source_name=source.name, operation="chat")
def _text_choice(body: dict[str, Any], source: SourceConfig) -> tuple[Any, str | None]:
"""文本模式取首个 choice 的 message 与 finish(非字符串 finish 记 None)。"""
choices = body.get("choices") or []
if not choices:
raise TransientError(
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
)
if not isinstance(choices, list) or not isinstance(choices[0], dict):
raise TransientError("chat choices 形态非法", operation="chat")
finish = choices[0].get("finish_reason")
return choices[0].get("message", {}), finish if isinstance(finish, str) else None
def _nonstream_message(
body: dict[str, Any], mode: ResponseMode, source: SourceConfig
) -> tuple[dict[str, Any], str | None]:
"""非流式取单 choice 的原始 message 与 finish;只校验形态,不构造快照。"""
if mode == "openai":
message, finish = check_completion_body(body)
else:
message, finish = _text_choice(body, source)
if not isinstance(message, dict) or any(
message.get(key) is not None and not isinstance(message[key], str)
for key in ("content", "reasoning_content")
):
raise TransientError("chat message 形态非法", operation="chat")
return message, finish
def _reasoning_of(message: dict[str, Any]) -> str:
"""非流式思考投影: 非空 reasoning_content 优先,否则 `reasoning` 别名。"""
primary = message.get("reasoning_content")
if isinstance(primary, str) and primary:
return primary
alias = message.get("reasoning")
return alias if isinstance(alias, str) else ""
# —— 错误翻译(CHS invokers.py 同款)——
@@ -392,6 +519,9 @@ class OpenAICompatTransport:
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
# 分开维护的理由是语义,不是碰撞)
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
# 方言告警(1.3.10)另立集合: 冲突删键/未知 finish 与推理对账语义无关
self._warned_fields: set[tuple[str, str]] = set()
self._warn_saturated = False
self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {}
@@ -636,54 +766,48 @@ class OpenAICompatTransport:
inter_token_s=source.inter_token_timeout_s or source.timeout_s,
total_s=source.timeout_s,
)
content_parts: list[str] = []
thinking_parts: list[str] = []
ttft_ms: float | None = None
last = started
max_gap = 0.0
chunk_times: list[float] = []
async for delta in guarded:
now = time.monotonic()
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
# 空 chunk/usage 帧/SSE keepalive 已在 _sse_data_payload/_sse_delta
# 被过滤,这里逐字追加即可;阈值判定不在热路径,派生收敛在
# _stall_from_chunk_times(设计 §3 派生原则)
chunk_times.append(now)
if content_event is not None:
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
content_event.set()
if ttft_ms is None:
ttft_ms = (now - started) * 1000
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
if first_token_event is not None:
first_token_event.set()
else:
max_gap = max(max_gap, (now - last) * 1000)
last = now
if delta.content:
content_parts.append(delta.content)
if delta.reasoning_content:
thinking_parts.append(delta.reasoning_content)
if response_mode == "openai" and not sink.get("done"):
raise TransientError(
"保真响应缺 [DONE],禁止打捞", source_name=source.name, operation="chat"
)
timing = await _consume_deltas(guarded, started, first_token_event, content_event)
return self._stream_result(sink, accumulator, timing, source, profile, response_mode)
def _stream_result(
self,
sink: dict[str, Any],
accumulator: ChatCompletionAccumulator,
timing: _StreamTiming,
source: SourceConfig,
profile: ProviderProfile,
response_mode: ResponseMode,
) -> TransportResult:
"""流结束后的统一判定(设计 §4.2-§4.3): 完整性 → 结局 → 组装。"""
self._warn_dialect(source, accumulator.conflicts, accumulator.finish_reason)
thinking_parts = timing.reasoning_parts if timing.has_reasoning else timing.alias_parts
content, thinking = self._finalize_text(timing.content_parts, thinking_parts, profile)
stream_end = _stream_is_complete(
done=bool(sink.get("done")),
finish_reason=accumulator.finish_reason,
usage_after_finish=accumulator.usage_after_finish,
usage_measured=_resolve_usage(sink.get("usage") or {})[2] == "measured",
)
if stream_end is None:
stream_end = self._truncated(timing, thinking_parts, source, response_mode)
message = accumulator.raw_message() if response_mode == "openai" else None
_raise_unless_deliverable(
response_mode, message, content, thinking, accumulator.finish_reason, source
)
completion_result = accumulator.finish() if response_mode == "openai" else None
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, completion_result, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
prompt, completion, usage_source = _resolve_stream_usage(sink, stream_end == "salvaged")
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms)
stall_ms, stall_count = _stall_from_chunk_times(
timing.chunk_times, source.stall_threshold_ms
)
return TransportResult(
content=content,
thinking=thinking,
prompt_tokens=prompt,
completion_tokens=completion,
usage_source=usage_source,
ttft_ms=ttft_ms,
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
ttft_ms=timing.ttft_ms,
max_inter_token_ms=(timing.max_gap if timing.ttft_ms is not None else None),
stall_ms=stall_ms,
stall_count=stall_count,
raw={"usage": sink.get("usage")},
@@ -696,40 +820,55 @@ class OpenAICompatTransport:
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
finish_reason=accumulator.finish_reason,
stream_end=stream_end,
)
def _check_done(
def _truncated(
self,
sink: dict[str, Any],
content_parts: list[str],
timing: _StreamTiming,
thinking_parts: list[str],
source: SourceConfig,
) -> bool:
"""缺 [DONE] 语义(设计 §6): 零内容恒 retry;有内容按 missing_done 策略。"""
if sink.get("done"):
return False
response_mode: ResponseMode,
) -> StreamEnd:
"""截断流(设计 §4.2): 保真不打捞;文本按 missing_done,零产物恒 retry。"""
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
if not content_parts and not thinking_parts:
if response_mode == "openai":
raise TransientError(f"{source.name} missing_done: 保真响应截断,禁止打捞", **ctx)
if not any(timing.content_parts) and not any(thinking_parts):
raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx)
if source.missing_done == "retry":
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
return True
return "salvaged"
def _reject_empty_completion(
self, content: str, completion: OpenAIChatCompletion | None, source: SourceConfig
def _warn_dialect(
self, source: SourceConfig, conflicts: frozenset[str], finish_reason: str | None
) -> None:
"""空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。
服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等
网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**;
承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。
"""
if not _has_completion_output(content, completion):
raise TransientError(
f"{source.name} 空补全(empty_completion): 流程完整但零内容",
source_name=source.name,
operation="chat",
"""方言告警: 冲突删键与未登记 finish 各按 (源, 路径/值) 只喊一次。"""
for path in sorted(conflicts):
self._warn_once(
source.name, f"field:{path}", f"字段 {path} 合并冲突已删除,保真结果缺该字段"
)
if finish_reason is not None and _classify_finish(finish_reason) == "unknown":
value = finish_reason[:_FINISH_WARN_CHARS]
self._warn_once(
source.name,
f"finish:{value}",
f"未登记的 finish_reason {value!r},按正常结束交付,该值的结果不缓存",
)
def _warn_once(self, source_name: str, key: str, text: str) -> None:
"""有界节流: 键由上游决定,长跑 worker 不能让集合无限增长(设计 §5.4)。"""
if (source_name, key) in self._warned_fields:
return
if len(self._warned_fields) >= _WARN_CAP:
if not self._warn_saturated:
self._warn_saturated = True
logger.warning("方言告警节流集合已饱和({}),后续同类告警省略", _WARN_CAP)
return
self._warned_fields.add((source_name, key))
# 单参数传入 loguru: 文案可能含上游给的花括号(同 `_warn_on_thinking_mismatch`)
logger.warning("源 {} —— {}", source_name, text)
def _finalize_text(
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
@@ -759,6 +898,9 @@ class OpenAICompatTransport:
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
TransportResult 默认 None(排除条款)。
无流完整性可言,直接以 body 的 finish_reason 走结局判定;保真模式**先判定
后构造快照**,否则半截工具在构造时就抛 Transient,到不了 `tool_truncated`。
"""
resp = await client.post(url, json=payload)
if resp.status_code != 200:
@@ -772,24 +914,16 @@ class OpenAICompatTransport:
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
) from exc
_check_chat_error(body)
completion_result = parse_chat_completion(body) if response_mode == "openai" else None
choices = body.get("choices") or []
if not choices:
raise TransientError(
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
)
if not isinstance(choices, list) or not isinstance(choices[0], dict):
raise TransientError("chat choices 形态非法", operation="chat")
message = choices[0].get("message", {})
if not isinstance(message, dict) or any(
message.get(key) is not None and not isinstance(message[key], str)
for key in ("content", "reasoning_content")
):
raise TransientError("chat message 形态非法", operation="chat")
message, finish = _nonstream_message(body, response_mode, source)
content, thinking = self._finalize_text(
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
[message.get("content") or ""], [_reasoning_of(message)], profile
)
self._warn_dialect(source, frozenset(), finish)
raw_message = message if response_mode == "openai" else None
_raise_unless_deliverable(response_mode, raw_message, content, thinking, finish, source)
completion_result = (
build_completion(message, body, finish) if response_mode == "openai" else None
)
self._reject_empty_completion(content, completion_result, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
return TransportResult(
@@ -810,6 +944,7 @@ class OpenAICompatTransport:
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
finish_reason=finish,
)
async def aclose(self) -> None: