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:
@@ -25,6 +25,7 @@ from polygateway.transports.openai_compat import (
|
||||
_iter_sse_deltas,
|
||||
_parse_retry_after,
|
||||
_sse_data_payload,
|
||||
_stall_from_chunk_times,
|
||||
_translate_429,
|
||||
)
|
||||
from polygateway.types import (
|
||||
@@ -81,7 +82,14 @@ def _transport_for(handler, *, registry=None):
|
||||
|
||||
|
||||
async def _complete(
|
||||
transport, source, *, stream=True, overlay=None, reasoning_effort=None, first_token_event=None
|
||||
transport,
|
||||
source,
|
||||
*,
|
||||
stream=True,
|
||||
overlay=None,
|
||||
reasoning_effort=None,
|
||||
first_token_event=None,
|
||||
content_event=None,
|
||||
):
|
||||
return await transport.complete(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
@@ -91,6 +99,7 @@ async def _complete(
|
||||
call_id="cid-1",
|
||||
reasoning_effort=reasoning_effort,
|
||||
first_token_event=first_token_event,
|
||||
content_event=content_event,
|
||||
)
|
||||
|
||||
|
||||
@@ -277,6 +286,195 @@ class TestFirstTokenEvent:
|
||||
)
|
||||
|
||||
|
||||
class _CountingEvent(asyncio.Event):
|
||||
"""统计 set() 次数的 Event: content_event "每增量 chunk 置位一次"的观测设施。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.set_count = 0
|
||||
|
||||
def set(self) -> None:
|
||||
self.set_count += 1
|
||||
super().set()
|
||||
|
||||
|
||||
def _gated_sse_response(*items):
|
||||
"""事件驱动的 SSE 响应体: str 段直接产出,asyncio.Event 段等待置位。
|
||||
|
||||
停顿由测试体用真实 `asyncio.sleep` 制造(计划 §5 真实 loop 钟范式,4× 余量);
|
||||
闸门只决定"停顿发生在哪两帧之间",不断定时长,两个方向都不撞窗口。
|
||||
"""
|
||||
|
||||
async def gen():
|
||||
for item in items:
|
||||
if isinstance(item, asyncio.Event):
|
||||
await item.wait()
|
||||
else:
|
||||
yield item.encode()
|
||||
|
||||
return httpx.Response(200, content=gen(), headers={"content-type": "text/event-stream"})
|
||||
|
||||
|
||||
class TestContentEvent:
|
||||
"""增量 chunk 处置位(1.3.8, issue #27/#28): 中段沉默计量与对冲的信号源。"""
|
||||
|
||||
async def test_stream_sets_content_event_per_chunk(self):
|
||||
"""流式每个增量 chunk(内容或思考)置位一次——对冲触发窗的沉默复位信号。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(
|
||||
_chunk(reasoning="ponder"),
|
||||
_chunk(content="hi"),
|
||||
_chunk(content=" there"),
|
||||
_chunk(usage=_USAGE),
|
||||
)
|
||||
|
||||
event = _CountingEvent()
|
||||
result = await _complete(_transport_for(handler), _source(), content_event=event)
|
||||
assert result.content == "hi there"
|
||||
assert event.set_count == 3 # 3 个增量 chunk 各置位一次
|
||||
|
||||
async def test_reasoning_chunk_counts_as_liveness(self):
|
||||
"""reasoning_content 增量同样置位并进 chunk 序列(B1: 思考增量是存活证据)。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(
|
||||
_chunk(reasoning="step1"),
|
||||
_chunk(reasoning="step2"),
|
||||
_chunk(content="ok"),
|
||||
_chunk(usage=_USAGE),
|
||||
)
|
||||
|
||||
event = _CountingEvent()
|
||||
result = await _complete(_transport_for(handler), _source(), content_event=event)
|
||||
assert result.thinking == "step1step2"
|
||||
assert event.set_count == 3
|
||||
# 快速投递下零超阈沉默: "观测过且零沉默"(0.0) ≠ "无从观测"(None);
|
||||
# 两值为 0.0 而非 None 同时证明 chunk 序列 ≥2(思考增量也入了序列)
|
||||
assert result.stall_ms == 0.0 and result.stall_count == 0.0
|
||||
|
||||
async def test_non_stream_never_sets_content_event(self):
|
||||
"""非流式无中途信号: 给了事件也永不置位;stall 两值恒 None(排除条款)。"""
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||
)
|
||||
|
||||
event = _CountingEvent()
|
||||
result = await _complete(
|
||||
_transport_for(handler), _source(), stream=False, content_event=event
|
||||
)
|
||||
assert result.content == "42"
|
||||
assert event.set_count == 0
|
||||
assert result.stall_ms is None and result.stall_count is None
|
||||
|
||||
async def test_none_content_event_keeps_behavior(self):
|
||||
"""`None` = 调用方不观测增量 chunk(未启用中段对冲): 行为与旧版逐字相同。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||
|
||||
result = await _complete(_transport_for(handler), _source(), content_event=None)
|
||||
assert result.content == "ok"
|
||||
assert result.ttft_ms is not None
|
||||
|
||||
async def test_content_event_is_required_keyword(self):
|
||||
"""端口必填约定同 first_token_event: 漏传必须 TypeError。"""
|
||||
|
||||
def handler(request):
|
||||
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||
|
||||
transport = _transport_for(handler)
|
||||
with pytest.raises(TypeError):
|
||||
await transport.complete(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
source=_source(),
|
||||
stream=True,
|
||||
overlay={},
|
||||
call_id="cid-1",
|
||||
reasoning_effort=None,
|
||||
first_token_event=None,
|
||||
)
|
||||
|
||||
|
||||
class TestStallFromChunkTimes:
|
||||
"""沉默计量纯函数(设计 §4.2): 严格大于阈值才减、chunk<2 排除、尾部不计。"""
|
||||
|
||||
def test_gap_above_threshold_accumulates(self):
|
||||
stall_ms, stall_count = _stall_from_chunk_times([0.0, 0.3, 2.0, 2.1], 1000)
|
||||
assert stall_ms == pytest.approx(1700.0) # 仅 0.3→2.0 的 1.7s 段超阈
|
||||
assert stall_count == 1.0
|
||||
|
||||
def test_gap_exactly_at_threshold_not_deducted(self):
|
||||
"""间隔恰好 == 阈值不减(严格大于,pi-tps 口径)。"""
|
||||
assert _stall_from_chunk_times([0.0, 1.0], 1000) == (0.0, 0.0)
|
||||
|
||||
def test_fewer_than_two_chunks_returns_none(self):
|
||||
"""0/1 个 chunk → (None, None): 无从观测,不得伪造 0(vLLM 排除条款)。"""
|
||||
assert _stall_from_chunk_times([], 1000) == (None, None)
|
||||
assert _stall_from_chunk_times([0.5], 1000) == (None, None)
|
||||
|
||||
async def test_tail_not_counted(self):
|
||||
"""末 chunk → 流结束(usage/[DONE])的间隔不进 stall(尾部归活跃侧,N1)。"""
|
||||
gate = asyncio.Event()
|
||||
|
||||
def handler(request):
|
||||
return _gated_sse_response(
|
||||
_chunk(content="a"),
|
||||
_chunk(content="b"),
|
||||
gate,
|
||||
_chunk(usage=_USAGE),
|
||||
"data: [DONE]\n\n",
|
||||
)
|
||||
|
||||
async def open_after_pause():
|
||||
await asyncio.sleep(0.2) # 真实尾部停顿 200ms(阈值 50ms 的 4×;方向安全: 只会更大)
|
||||
gate.set()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
|
||||
)
|
||||
await asyncio.gather(task, open_after_pause())
|
||||
result = task.result()
|
||||
assert result.content == "ab"
|
||||
# 唯一的相邻 chunk 间隔是快速段;200ms 的尾部停顿若被计入即超阈暴露
|
||||
assert result.stall_ms == 0.0 and result.stall_count == 0.0
|
||||
|
||||
async def test_empty_or_usage_frames_not_timed(self):
|
||||
"""空 chunk/usage 帧/keepalive 不产生时间戳记录点: 三段停顿只算一次 chunk 间隔。"""
|
||||
gates = [asyncio.Event() for _ in range(3)]
|
||||
|
||||
def handler(request):
|
||||
return _gated_sse_response(
|
||||
_chunk(content="a"),
|
||||
gates[0],
|
||||
'data: {"choices":[{"delta":{}}]}\n\n', # 空 chunk
|
||||
gates[1],
|
||||
": ping\n\n", # SSE keepalive
|
||||
gates[2],
|
||||
_chunk(usage=_USAGE), # usage-only 帧
|
||||
_chunk(content="b"),
|
||||
"data: [DONE]\n\n",
|
||||
)
|
||||
|
||||
async def open_gates():
|
||||
for gate in gates:
|
||||
await asyncio.sleep(0.2)
|
||||
gate.set()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
|
||||
)
|
||||
await asyncio.gather(task, open_gates())
|
||||
result = task.result()
|
||||
assert result.content == "ab"
|
||||
# 三个非增量帧若被计时,三段 200ms 间隔各自超阈(count==3);
|
||||
# 真相: chunk1→chunk2 仅一个间隔(~600ms),count==1
|
||||
assert result.stall_count == 1.0
|
||||
assert result.stall_ms >= 400.0 # 下界证明停顿真实发生(慢机器只会更大,不设上界)
|
||||
|
||||
|
||||
class TestMissingDoneSemantics:
|
||||
def _no_done_handler(self, request):
|
||||
return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False)
|
||||
|
||||
Reference in New Issue
Block a user