From 1a35d515d9630fc63d9796aaeaa132dfff28eb37 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 5 Sep 2026 04:07:06 -0400 Subject: [PATCH] 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. --- src/polygateway/client.py | 17 +++++++++-- src/polygateway/config.py | 26 ++++------------ src/polygateway/types.py | 63 +++++++++++++++++++++++++++++++++++++-- tests/unit/test_client.py | 35 ++++++++++++++++++++++ tests/unit/test_config.py | 14 +++++++++ tests/unit/test_types.py | 44 +++++++++++++++++++++++++++ 6 files changed, 173 insertions(+), 26 deletions(-) diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 31f77d0..5b3e1b7 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -41,6 +41,7 @@ from polygateway.types import ( Effort, LLMResponse, TelemetryStatus, + coerce_effort, validate_caller_dimensions, validate_request_overlay, ) @@ -315,7 +316,7 @@ class GatewayClient: structured: type[BaseModel] | Literal["json"] | None = None, stream: bool = True, overlay: Mapping[str, Any] | None = None, - reasoning_effort: Effort | None = None, + reasoning_effort: Effort | str | None = None, tenant_id: str | None = None, meta: Mapping[str, Any] | None = None, ) -> LLMResponse: @@ -327,7 +328,8 @@ class GatewayClient: `reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT` 与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与 - `Effort.NONE`("要求不推理")严格区分。 + `Effort.NONE`("要求不推理")严格区分。裸字符串(`"low"`)也收,在此归一成 + `Effort`,非法值当场 `ValueError`——与 `SourceConfig` 那条装配路同口径。 `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key** (租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇 @@ -348,6 +350,15 @@ class GatewayClient: dimension_tenant_id, dimensions = validate_caller_dimensions( tenant_id, meta, origin="chat(tenant_id=..., meta=...)" ) + # 同样必须在洋葱之外归一: 档位一路要被 `is Effort.NONE` 身份比较,裸字符串 + # 进去会在 transport 的错误路径上抛 `AttributeError`——那不属错误四分类, + # 会穿透 `except ThinkingUnsupportedError` 与 RetryMW 的分类捕获(库铁律 + # 「错误分类驱动」)。归一失败是调用方编程错误,抛裸 ValueError 不进洋葱 + effort = ( + None + if reasoning_effort is None + else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)") + ) request = ChatRequest( messages=messages, session_id=session_id, @@ -358,7 +369,7 @@ class GatewayClient: stream=stream, overlay=sampling, sampling=sampling, - reasoning_effort=reasoning_effort, + reasoning_effort=effort, tenant_id=dimension_tenant_id, meta=dimensions, ) diff --git a/src/polygateway/config.py b/src/polygateway/config.py index de8cbee..27dcc5f 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -22,10 +22,10 @@ from loguru import logger from polygateway.types import ( BackpressurePolicy, BreakerConfig, - Effort, GlobalLimits, RetryPolicy, SourceConfig, + coerce_effort, ) if TYPE_CHECKING: @@ -47,6 +47,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = { # 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名), # fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace) "REASONING_EFFORT": ("reasoning_effort", "effort"), + # 归一化(strip+lower)在 SourceConfig 构造期,与值域校验同处一点,故这里是裸 "str" "EFFORT_FALLBACK": ("effort_fallback", "str"), "MISSING_DONE": ("missing_done", "str"), "TRUST_ENV": ("trust_env", "bool"), @@ -99,7 +100,9 @@ def _cast(raw: str, kind: str, key: str) -> object: return False raise ValueError(f"非法布尔值: {raw!r}") if kind == "effort": - return _to_effort(raw) + # 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径; + # origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上 + return coerce_effort(raw, origin="") if kind == "json": # JSONDecodeError 是 ValueError 子类,复用下方的统一包装 parsed = json.loads(raw) @@ -111,25 +114,6 @@ def _cast(raw: str, kind: str, key: str) -> object: raise ValueError(f"配置 {key} 解析失败: {exc}") from exc -def _to_effort(raw: str) -> Effort: - """把 env 字符串解成 `Effort`;越界时报错并**列出全部八档**。 - - 列全八档不是啰嗦: 档位词汇是封闭的,而下游会照着别处的习惯写 - (`lowest`/`off`/`disabled` 都出现过),只说"非法值"等于让人去翻源码。 - - `strip().lower()` 与 `bool` 分支同一先例: `.env` 里的行尾空格与大写写法 - 是常态,而档位取值本身没有大小写语义(`Effort` 的值全小写)。 - """ - try: - return Effort(raw.strip().lower()) - except ValueError: - # 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一 - # 件事的英文复述,链上去反而把可操作的那句挤到后面 - raise ValueError( - f"非法推理档位 {raw!r};允许: {', '.join(e.value for e in Effort)}" - ) from None - - def _first(env: Mapping[str, str], *keys: str) -> tuple[str, str] | None: for key in keys: raw = env.get(key) diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 3a698a0..7eba116 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -217,6 +217,42 @@ EFFORT_ORDER: tuple[Effort, ...] = ( """ +def coerce_effort(raw: Any, *, origin: str) -> Effort: + """把外部传入的档位**归一**成 `Effort`;非法值报 `ValueError` 并列全八档。 + + 存在的理由是"归一化点必须在入口":库内一律用 `is Effort.NONE` 做身份比较 + (枚举成员唯一,`is` 比 `==` 更能表达"就是这一档"),而 `Effort` 是 `StrEnum` + ——下游从 JSON/配置/命令行读出来的天然是裸字符串,`"none" is Effort.NONE` + 恒为假。不在入口归一,身份比较就会在**错误路径上**误判(把一致的配置判成 + 矛盾),随后拼错误文案时再 `.value` 抛 `AttributeError`,连承诺的 `ValueError` + 都拿不到(2026-09-05 独立验证实测)。 + + 故裸字符串**接受并归一**而非拒收: 拒收会把 `.env` 之外的两条装配路(工厂 / + 构造函数全量注入,CLAUDE.md §4.5)口径劈成两半,而 `.env` 那条早已是"解析即 + 归一"。`strip().lower()` 与 `config._to_effort` 同口径,理由同样是配置里的 + 行尾空格与大写写法是常态,而档位取值本身没有大小写语义。 + + `origin` 指回具体的配置项或调用点: 档位在源级、请求级两处都能配,只说 + "非法档位"要人自己去找是哪一处填错了。传空串表示调用方自己会补上下文 + (`config._cast` 的 `配置 X 解析失败` 已经说了是哪个 env 键)。 + """ + if isinstance(raw, Effort): + return raw + prefix = f"{origin}: " if origin else "" + listed = ", ".join(e.value for e in Effort) + if isinstance(raw, str): + try: + return Effort(raw.strip().lower()) + except ValueError: + # 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一 + # 件事的英文复述,链上去反而把可操作的那句挤到后面 + raise ValueError(f"{prefix}非法推理档位 {raw!r};允许: {listed}") from None + raise ValueError( + f"{prefix}推理档位必须是 Effort 或其字面量字符串," + f"收到 {type(raw).__name__}: {raw!r};允许: {listed}" + ) + + class ThinkingObservation(StrEnum): """一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。 @@ -440,7 +476,11 @@ class SourceConfig: reasoning_effort: Effort | None = None """本源默认的推理档位;None = 不表态(与 `Effort.NONE`「要求不推理」不同)。 - **追加在末尾**是硬要求: 三项目的测试按位置构造 fake,插在中间会静默错位 + 裸字符串(`"low"`、`" LOW "`)也收,构造期由 `coerce_effort` 归一成 `Effort`, + 非法值当场 `ValueError` 并列出八档;**构造完成后本字段一定是 `Effort`**,库内 + 的 `is Effort.NONE` 身份比较依赖这条不变式。 + + **追加在末尾**是硬要求:三项目的测试按位置构造 fake,插在中间会静默错位 (本模块头部 docstring 的字段保序约定)。""" effort_fallback: str = "error" @@ -494,7 +534,12 @@ class SourceConfig: raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s") def _validate_thinking(self) -> None: - """推理两键的值域与互不矛盾(issue #20 设计 §4.2)。 + """推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。 + + 归一化必须先于下面的矛盾判定: 判据用的是 `is Effort.NONE`,而本类是公共 + 入口,`reasoning_effort="none"` 这种裸字符串写法(从 JSON/配置读出来的 + 常态)会让它误判成矛盾,再拼文案时 `.value` 直接 `AttributeError`。同一 + 理由也适用于下游读侧——归一化后库内一律是 `Effort`,`is` 比较才安全。 矛盾**报错而非「后者赢」**: `enable_thinking` 与 `reasoning_effort` 表达的是 同一件事,静默取其一等于替下游猜它到底想要哪个,而猜错的代价是账单—— @@ -504,6 +549,20 @@ class SourceConfig: `reasoning_effort is NONE` 必须同真同假。`True` + 某个开启档(如 `low`) 不算矛盾,那只是把同一件事说了两遍,且后者更精确。 """ + if self.reasoning_effort is not None: + # frozen dataclass 改字段走 object.__setattr__(同款先例: _freeze_extra_body) + object.__setattr__( + self, + "reasoning_effort", + coerce_effort( + self.reasoning_effort, origin=f"SourceConfig({self.name}).reasoning_effort" + ), + ) + if isinstance(self.effort_fallback, str): + # 与相邻的 `REASONING_EFFORT` 同口径: `.env` 里的行尾空格与大写写法是 + # 常态,而 `nearest`/`error` 本身没有大小写语义。归一化放在值域校验的 + # 同一处(而不是 env 解析处),三条配置路一并覆盖 + object.__setattr__(self, "effort_fallback", self.effort_fallback.strip().lower()) if self.effort_fallback not in _EFFORT_FALLBACK_DOMAIN: raise ValueError( f"SourceConfig.effort_fallback(EFFORT_FALLBACK)非法值 " diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 451e594..e99b884 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -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: """收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index f8d1a3f..401e6a9 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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"}) diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index e7322da..6efafe8 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -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)