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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user