diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e99b884..27b95be 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -12,6 +12,7 @@ from polygateway import ( AllSourcesExhausted, GatewayClient, GatewaySettings, + RequestRejectedError, gather_bounded, ) from polygateway.backends.memory.breaker import InMemoryGate @@ -275,6 +276,42 @@ class TestReasoningEffortPriority: assert "reasoning_effort" not in body +class TestEffortFallbackWiring: + """源级 `effort_fallback` 必须真的走到 `resolve_thinking`(issue #20)。 + + 只测配置值域与纯函数是不够的: 变异实测显示把 transport 里的 + `fallback=source.effort_fallback` 换成硬编码 `"error"`,全套单测依然全绿—— + `nearest` 是人类明确要求实现的功能,没有这条端到端用例它会在零告警下变成 + 死代码(2026-09-05 独立验证查出)。故断言看**发出去的字节**。 + """ + + def _capturing_client(self, captured, **overrides): + def handler(request): + captured.append(json.loads(request.content)) + return _sse() + + return _client(handler=handler, **overrides) + + async def test_nearest_sends_the_mapped_tier(self): + """glm-5.3 只有 low/high/max: 请求 `medium` 等距,按"取弱"落到 low。""" + captured = [] + source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest") + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM) + assert captured[0]["reasoning_effort"] == "low" + + async def test_error_fallback_refuses_the_same_request(self): + """默认 `error` 必须报错而不是映射: 一次静默的换档就是一笔没打招呼的账单。""" + captured = [] + source = _source(provider="zhipu", model="glm-5.3") + async with self._capturing_client(captured, sources=[source]) as client: + with pytest.raises(RequestRejectedError, match="medium"): + await client.chat( + [{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM + ) + assert captured == [] # 请求根本没发出去 + + class TestRequestTierNormalization: """`chat(reasoning_effort=...)` 是公共入口,裸字符串必须在此归一(issue #20)。 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 401e6a9..61af01c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -758,6 +758,24 @@ class TestCrossFieldInvariants: with pytest.raises(ValueError, match="register_provider"): GatewayClient.from_settings(settings, registry=register_provider(mystery)) + def test_source_tier_the_model_cannot_satisfy_fails_at_assembly(self): + """守卫必须读**源级档位**,而不只是 `enable_thinking`(设计 §4.2 三层优先级)。 + + 变异实测: 把 `_guard_thinking` 的 `source_effort=source.reasoning_effort` + 改成 `None`,全套单测依然全绿(2026-09-05 独立验证查出)。文案里必须出现 + 可执行替代 `low`——glm-5.3 官方关不掉推理,只报"不行"会把人推回 + `extra_body` 那条绕过治理的老路(issue #20 的成因)。 + """ + base = self._base() + src = dataclasses.replace( + base.sources[0], provider="zhipu", model="glm-5.3", reasoning_effort=Effort.NONE + ) + with pytest.raises(ValueError) as exc: + GatewayClient.from_settings(dataclasses.replace(base, sources=(src,))) + message = str(exc.value) + assert "glm-5.3" in message + assert "low" in message + def test_openai_segment_now_assembles_with_the_standard_field(self): # 行为变更(2026-09-04): reasoning_effort 是 OpenAI 官方字段而非厂商方言, # 经网关的兼容端点收得下,故兜底段不再把"关闭"判为形态未知