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)