feat: carry the per-call tier down to the transport that must send it

The Transport port took the request apart into five arguments, so a tier
placed on ChatRequest could never reach _build_payload: the field was set,
read by nobody, and silently ignored - the exact shape of failure that sent
downstream to extra_body in the first place.

complete() now takes reasoning_effort with no default, matching the
TelemetryRecorder convention: a default would turn a missing hand-off into
a silent 'no opinion'. All four fakes move with it, since @runtime_checkable
checks method names and not signatures.

EmbeddingTransport and OcrTransport are deliberately left alone - they have
no reasoning semantics - and a test now holds that line.

_build_payload drops its inline sugar conversion for effective_effort(), so
the guard and the hot path share one judgement, and passes the source's
effort_fallback for the same reason.
This commit is contained in:
2026-09-05 02:28:24 -04:00
parent 1f13eb18ab
commit 80a8013642
11 changed files with 142 additions and 15 deletions
+1
View File
@@ -514,6 +514,7 @@ class TestAssemblyGuardAgainstRealConfig:
stream=True,
overlay={},
call_id="e2e-guard",
reasoning_effort=None,
)
finally:
await client.aclose()
@@ -73,7 +73,7 @@ class ScriptedTransport:
self.hang = hang
self.calls: list[str] = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append(source.name)
if self.hang:
await asyncio.Event().wait()
+1 -1
View File
@@ -210,7 +210,7 @@ class ClockAdvancingTransport:
self.clock = clock
self.calls = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append((source.name, call_id))
advance, action = self.script.pop(0)
self.clock.advance(advance)
+26
View File
@@ -24,6 +24,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
Effort,
GlobalLimits,
RetryPolicy,
SourceConfig,
@@ -219,6 +220,31 @@ class TestReasoningEffortPriority:
overrides.setdefault("model", "glm-5.3")
return _source(provider="zhipu", **overrides)
async def test_request_effort_wins_over_source(self):
captured = []
source = self._zhipu(reasoning_effort=Effort.LOW)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MAX)
assert captured[0]["reasoning_effort"] == "max"
assert captured[0]["thinking"] == {"type": "enabled"}
async def test_none_request_does_not_clear_source(self):
"""请求级不表态 ≠ 请求级要求"不推理": 前者必须让源级默认继续生效。"""
captured = []
source = self._zhipu(reasoning_effort=Effort.LOW)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
assert captured[0]["reasoning_effort"] == "low"
async def test_request_none_tier_is_an_opinion_not_an_absence(self):
"""请求级 `none` 是"要求不推理",不得被当成"没表态"而回落到源级档位。"""
captured = []
source = self._zhipu(model="glm-5.2", reasoning_effort=Effort.MAX)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE)
assert captured[0]["thinking"] == {"type": "disabled"}
assert "reasoning_effort" not in captured[0]
@pytest.mark.parametrize(
("provider", "model", "fragment"),
[
+2 -1
View File
@@ -69,13 +69,14 @@ def _transport_for(handler, *, registry=None):
)
async def _complete(transport, source, *, stream=True, overlay=None):
async def _complete(transport, source, *, stream=True, overlay=None, reasoning_effort=None):
return await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=source,
stream=stream,
overlay=overlay or {},
call_id="cid-1",
reasoning_effort=reasoning_effort,
)
+32 -1
View File
@@ -1,15 +1,18 @@
"""ports.py 端口冻结测试(M1 设计 §4): Protocol 结构性检查 + Gate 快照校验。"""
import inspect
from typing import Any
import pytest
from polygateway.ports import (
CacheBackend,
EmbeddingTransport,
GateDecision,
GateState,
GateUpdate,
Middleware,
OcrTransport,
Permit,
ProviderGate,
RateLimiter,
@@ -69,7 +72,7 @@ class _DummyMw:
class _DummyTransport:
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
raise NotImplementedError
@@ -142,6 +145,34 @@ def test_protocols_are_runtime_checkable(impl, protocol):
assert isinstance(impl, protocol)
class TestReasoningTierIsOnlyOnTheChatPort:
"""档位属于 chat 端口,且**只属于**它(Task 5b)。
`@runtime_checkable` 只查方法名不查签名,故协议签名本身必须被显式断言——
否则实现漏改一个参数,要到运行期调用才会以 `TypeError` 现形,而那时的现场
离根因已经很远。
"""
def test_chat_transport_carries_the_per_call_tier(self):
params = inspect.signature(Transport.complete).parameters
assert "reasoning_effort" in params
# 不给默认值是有意的(与 TelemetryRecorder 同一既有约定): 库外无第三方
# 实现者,写全签名成本为零,而默认值会把"漏传"变成静默的"不表态"
assert params["reasoning_effort"].default is inspect.Parameter.empty
@pytest.mark.parametrize(
("protocol", "method"),
[
(EmbeddingTransport, "embed"),
(OcrTransport, "recognize_text"),
(OcrTransport, "parse_layout"),
],
)
def test_other_transports_have_no_reasoning_tier(self, protocol, method):
"""embedding 与 OCR 没有推理语义,给它们加档位只会静默无效(issue #4 同款决策)。"""
assert "reasoning_effort" not in inspect.signature(getattr(protocol, method)).parameters
class _DummyStatusProvider(_DummyRecorder):
@property
def telemetry_status(self) -> TelemetryStatus:
+41 -2
View File
@@ -24,6 +24,7 @@ from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
Effort,
GlobalLimits,
RetryPolicy,
SourceConfig,
@@ -63,14 +64,21 @@ def _ok(content="ok"):
class FakeTransport:
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id)"""
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id) 与收到的档位。
`reasoning_effort` 刻意**不给默认值**,与 `Transport` 协议保持逐字一致:
`@runtime_checkable` 只查方法名不查签名,fake 上多一个默认值就会把"中间件漏传"
这类缺口伪装成"调用方没表态",而报错现场离根因很远。
"""
def __init__(self, script):
self.script = list(script)
self.calls = []
self.efforts = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append((source.name, call_id))
self.efforts.append(reasoning_effort)
action = self.script.pop(0)
if isinstance(action, Exception):
raise action
@@ -135,6 +143,37 @@ def _harness(
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
class TestRequestTierReachesTransport:
"""请求级档位必须一路穿过洋葱到达 transport(Task 5b)。
`ChatRequest` 上填了字段而中间件不搬运,是"看起来配了、实际没发出去"的静默
失效——正是 issue #20 里下游改用 extra_body 绕过治理的成因。
"""
async def test_request_tier_reaches_transport(self):
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
await mw(
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.HIGH)
)
assert transport.efforts == [Effort.HIGH]
async def test_absent_tier_is_carried_as_none(self):
"""不表态也要显式传下去: 漏传与"传了 None"在协议上必须区分不开才安全。"""
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
await mw(_REQ)
assert transport.efforts == [None]
async def test_tier_is_carried_on_every_retry_attempt(self):
"""换源重试时档位不得在第二次尝试上丢失。"""
mw, _, _, transport, *_ = _harness(
[_src("a"), _src("b")], [TransientError("boom", source_name="a"), _ok()]
)
await mw(
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.LOW)
)
assert transport.efforts == [Effort.LOW, Effort.LOW]
class TestSuccessPath:
async def test_first_attempt_success_builds_response(self):
mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")])
+1
View File
@@ -134,6 +134,7 @@ async def test_salvage_override_stays_in_domain(usage):
stream=True,
overlay={},
call_id="cid",
reasoning_effort=None,
)
assert result.usage_source in USAGE_SOURCES