feat: add a content event and stall accounting to the transport port

T1(计划 §4):观测原语与端口契约(issue #27/#28 底层)。

- Transport.complete 加必填 kw content_event(顺序在 first_token_event
  后,无默认值,同款约定):流式每增量 chunk 置位一次,非流式/对冲路
  恒不置位,None = 不观测
- TransportResult 增 stall_ms/stall_count(带默认 None):超阈值相邻
  增量 chunk 沉默总额与段数,chunk<2/非流式 → None,尾部不计
- SourceConfig 增 stall_threshold_ms(默认 1000,正整数拒 bool,
  _validate_watchdog 同款落点)
- _complete_stream 记增量 chunk 时间戳(空 chunk/usage 帧/keepalive
  不产生记录点)+ 模块级纯函数 _stall_from_chunk_times(严格大于
  阈值才减);max_inter_token_ms 原语义一字不动
- §2.2 同步清单全量:11 实现 + 11 调用点(重跑 grep 复核零遗漏)
- 批次 A 12 用例先红(TypeError/ImportError/AttributeError)后绿,
  证据 tests/outputs/138/t1/
This commit is contained in:
2026-09-14 00:35:35 -04:00
parent 6f3a620c73
commit 901a193514
15 changed files with 398 additions and 13 deletions
+45 -3
View File
@@ -85,6 +85,29 @@ def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool,
return None
def _stall_from_chunk_times(
times: list[float], threshold_ms: int
) -> tuple[float | None, float | None]:
"""增量 chunk 时间戳序列 → (stall_ms, stall_count);chunk <2 → (None, None)。
沉默 = 相邻间隔**严格大于**阈值(恰好 = 阈值不减,pi-tps 口径);
尾部(末 chunk → 流结束)不计——墙钟基底口径,尾部归活跃侧(设计 §4.2 N1)。
无超阈段但 chunk ≥2 时返回 (0.0, 0.0) 而非 None——"观测过且零沉默"与
"无从观测"是两回事,下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。
"""
if len(times) < 2:
return None, None
total = 0.0
count = 0
# strict=False: 两序列恒差 1(times[1:] 少末尾),B905 要求显式表态——不等长是本设计的本意
for prev, cur in zip(times, times[1:], strict=False):
gap_ms = (cur - prev) * 1000
if gap_ms > threshold_ms:
total += gap_ms
count += 1
return (total, float(count)) if count else (0.0, 0.0)
async def _iter_sse_deltas(
lines: AsyncIterator[str], usage_sink: dict[str, Any]
) -> AsyncIterator[tuple[bool, str]]:
@@ -434,6 +457,7 @@ class OpenAICompatTransport:
call_id: str,
reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult:
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
@@ -441,6 +465,8 @@ class OpenAICompatTransport:
在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。
`first_token_event` 为对冲处置位: `None` = 不观测首 token;仅流式路径
(`_complete_stream`)在首个增量到达时置位,非流式路径收它但永不置位。
`content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个**
增量 chunk 置位一次,非流式路径收它但永不置位。
"""
profile = get_provider(source.provider, registry=self._registry)
try:
@@ -466,11 +492,11 @@ class OpenAICompatTransport:
try:
if stream:
result = await self._complete_stream(
client, url, payload, source, profile, first_token_event
client, url, payload, source, profile, first_token_event, content_event
)
else:
result = await self._complete_once(
client, url, payload, source, profile, first_token_event
client, url, payload, source, profile, first_token_event, content_event
)
except StreamLivenessTimeout as exc:
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
@@ -558,6 +584,7 @@ class OpenAICompatTransport:
source: SourceConfig,
profile: ProviderProfile,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult:
started = time.monotonic()
async with client.stream("POST", url, json=payload) as resp:
@@ -578,8 +605,17 @@ class OpenAICompatTransport:
ttft_ms: float | None = None
last = started
max_gap = 0.0
chunk_times: list[float] = []
async for is_content, text 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"(思考增量同样是存活证据,
@@ -595,6 +631,7 @@ class OpenAICompatTransport:
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms)
return TransportResult(
content=content,
thinking=thinking,
@@ -603,6 +640,8 @@ class OpenAICompatTransport:
usage_source=usage_source,
ttft_ms=ttft_ms,
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
stall_ms=stall_ms,
stall_count=stall_count,
raw={"usage": sink.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
model_reported=_coerce_model_reported(sink.get("model")),
@@ -664,11 +703,14 @@ class OpenAICompatTransport:
source: SourceConfig,
profile: ProviderProfile,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult:
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。
接收 `first_token_event` 但**永不置位**: 非流式无中途信号,事件自然退化
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
TransportResult 默认 None(排除条款)。
"""
resp = await client.post(url, json=payload)
if resp.status_code != 200: