fix: read a tier the way every config path actually spells it

Both public assembly paths took the tier on trust: a bare "none" from
JSON or a hand-built SourceConfig stayed a str, and `is Effort.NONE`
then read it as a contradiction and crashed on `.value` while wording
the error -- the caller got an AttributeError where a ValueError was
promised, and on the request side that unclassified exception walked
straight through the transport's ThinkingUnsupportedError catch and the
retry classifier.

Normalize at the two entrances instead, matching what the .env path has
always done, and let EFFORT_FALLBACK be spelled with the same freedom as
its neighbour.
This commit is contained in:
2026-09-05 04:07:06 -04:00
parent 701a8a6841
commit 1a35d515d9
6 changed files with 173 additions and 26 deletions
+35
View File
@@ -275,6 +275,41 @@ class TestReasoningEffortPriority:
assert "reasoning_effort" not in body
class TestRequestTierNormalization:
"""`chat(reasoning_effort=...)` 是公共入口,裸字符串必须在此归一(issue #20)。
两条装配路(工厂 / 构造函数全量注入,CLAUDE.md §4.5)与 `.env` 路的口径必须
一致——后者早已是"解析即归一"。不归一的后果不是"少个类型注解"那么轻: 档位
一路要被 `is Effort.NONE` 身份比较,裸字符串会在 transport 的错误路径上抛
`AttributeError`,而它不属错误四分类,会穿透 `except ThinkingUnsupportedError`
与 RetryMW 的分类捕获,以未分类异常冒出 `chat()`(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_bare_string_tier_reaches_the_wire(self):
captured = []
source = _source(provider="zhipu", model="glm-5.3")
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="max")
assert captured[0]["reasoning_effort"] == "max"
async def test_illegal_tier_is_a_value_error_listing_the_vocabulary(self):
"""非法档位是调用方编程错误: 当场 `ValueError`,不进洋葱、不成为未分类异常。"""
source = _source(provider="zhipu", model="glm-5.3")
async with _client(sources=[source]) as client:
with pytest.raises(ValueError) as exc:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="lowest")
message = str(exc.value)
assert "chat(reasoning_effort=...)" in message
assert all(tier.value in message for tier in Effort)
class _MemoryRecorder:
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
+14
View File
@@ -162,6 +162,20 @@ class TestReasoningEffortParsing:
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].effort_fallback == "nearest"
def test_effort_key_tolerates_case_and_whitespace(self):
"""`.env` 里的行尾空格与大写写法是常态,档位取值本身没有大小写语义。"""
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": " LOW "})
assert GatewaySettings.from_env("LLM", env=env).sources[0].reasoning_effort is Effort.LOW
def test_effort_fallback_tolerates_case_and_whitespace(self):
"""与相邻的 REASONING_EFFORT 同口径: 同一份 .env 里两个键脾气不同即是坑。
`EFFORT_FALLBACK=Nearest` 此前会原样落到 `SourceConfig`,被值域校验拒掉
——而人看着 .env 里明明写了 nearest(2026-09-05 独立验证查出)。
"""
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": " Nearest "})
assert GatewaySettings.from_env("LLM", env=env).sources[0].effort_fallback == "nearest"
def test_invalid_effort_fallback_rejected(self):
"""`resolve_thinking` 对未知 fallback 值是 fail-closed,不会替配置兜错。"""
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "closest"})
+44
View File
@@ -10,6 +10,7 @@ from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
Effort,
GlobalLimits,
LLMResponse,
RetryPolicy,
@@ -566,3 +567,46 @@ class TestChatRequestDimensions:
)
assert request.tenant_id == "t1"
assert request.meta == {"batch": "b-42"}
class TestSourceConfigEffortNormalization:
"""源级档位在**构造期**归一成 `Effort`(issue #20;2026-09-05 独立验证查出)。
库内一律用 `is Effort.NONE` 做身份比较,而 `Effort` 是 `StrEnum`——下游从
JSON/配置读出来的天然是裸字符串,不归一就会在**错误路径上**误判并二次崩溃。
"""
def test_bare_string_tier_is_normalized(self):
"""`reasoning_effort="low"` 必须存成 `Effort.LOW`,而不是原样留个 str。"""
assert _make_source(reasoning_effort="low").reasoning_effort is Effort.LOW
def test_whitespace_and_case_are_normalized(self):
"""与 `.env` 那条路同口径: 行尾空格与大写写法是常态,档位无大小写语义。"""
assert _make_source(reasoning_effort=" LOW ").reasoning_effort is Effort.LOW
def test_consistent_bare_string_survives_the_contradiction_guard(self):
"""设计 §4.2 明说"二者一致则放行",裸字符串写法不得被判成矛盾。
修复前实测: `("none" is Effort.NONE)` 为假 → 判为矛盾 → 拼文案时 `.value`
抛 `AttributeError`,连承诺的 `ValueError` 都拿不到。
"""
source = _make_source(enable_thinking=False, reasoning_effort="none")
assert source.reasoning_effort is Effort.NONE
def test_contradiction_still_caught_through_a_bare_string(self):
"""归一化不得把矛盾一并抹平: `True` + `"none"` 仍是配置错误。"""
with pytest.raises(ValueError, match="矛盾"):
_make_source(enable_thinking=True, reasoning_effort="none")
def test_illegal_tier_lists_the_whole_vocabulary(self):
"""写错档位的人要的是"那该填什么",故报错必须把八档全摆出来并指回字段。"""
with pytest.raises(ValueError) as exc:
_make_source(reasoning_effort="lowest")
message = str(exc.value)
assert "reasoning_effort" in message
assert all(tier.value in message for tier in Effort)
def test_non_string_tier_is_a_value_error_not_a_crash(self):
"""非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。"""
with pytest.raises(ValueError, match="推理档位"):
_make_source(reasoning_effort=3)