From 4866e6b858b005958901bc56790bf2863a0be150 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 5 Sep 2026 06:49:34 -0400 Subject: [PATCH] fix: normalise a bare tier string at the fourth door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_thinking` is exported, and its third argument turned from `bool` into `Effort` this cycle — so the most natural downstream call now passes the `"low"` it read out of JSON or config. Every gate inside compares by identity, and `"none" is Effort.NONE` is always false: the tier was not rejected, it was silently misjudged. Phase 2 read the on-form for a request that wanted the off-form, Phase 4 was skipped entirely on a model that cannot be disabled, and the only visible symptom arrived much later as an AttributeError from `.value` — an exception that is neither documented nor one of the four error classes. Design 4.4 already lists this as the fourth entry a tier can come in through; this makes the code agree with it. --- src/polygateway/thinking.py | 14 ++++++++++-- tests/unit/test_thinking.py | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 45079fc..d94cf4d 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -15,7 +15,7 @@ from typing import Any from loguru import logger from polygateway.providers import ProviderProfile, ThinkingWire -from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation +from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation, coerce_effort def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation: @@ -344,7 +344,7 @@ def effective_effort( def resolve_thinking( profile: ProviderProfile, capability: ThinkingCapability | None, - effort: Effort | None, + effort: Effort | str | None, *, model: str, fallback: str = "error", @@ -385,7 +385,17 @@ def resolve_thinking( `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次调用 再喊只会刷屏。判定结果不受此参数影响。 + + **裸字符串也收**(设计 §4.4 第 4 条入口): 本函数在 `__all__` 里,下游直调时 + 传的天然是从 JSON/配置读出来的 `"low"`,而第三参数本次由 `bool` 换成 `Effort` + 正是这条入口冒出来的时机。签名照实写 `Effort | str`——下面每一关的判据都是 + `is Effort.X` 的身份比较,`"none" is Effort.NONE` 恒假,不归一的后果不是报错 + 而是**静默判否**: Phase 2 按开启方向取字段、Phase 4 整条被绕过,最后在拼错误 + 文案时才以 `AttributeError` 现形(一个未文档化、也不属四分类的异常)。 """ + # Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举 + if effort is not None: + effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})") # Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档 if effort is None: return ThinkingResolution({}, None) diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 3057d96..61fffce 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -443,6 +443,51 @@ class TestResolveThinking: resolve_thinking(profile, cap, Effort.NONE, model="x-1") assert "register_provider" not in str(exc.value), "形态已知,不该指向注册" + # —— 归一化: 本函数是档位进入库内的第四条入口(设计 §4.4) —— + + def test_a_bare_string_tier_is_normalised_at_the_door(self): + """`resolve_thinking` 在 `__all__` 里,下游直调时传的天然是裸串。 + + 第三参数本次由 `bool` 换成 `Effort`,而下游最自然的写法是从 JSON/配置读出来 + 的 `"low"`。不在入口归一,`_inject` 撞 `.value` 抛的是 `AttributeError`—— + 一个未文档化、也不属错误四分类的异常(2026-09-05 独立验证查出)。 + """ + got = resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.3"), "low", model="glm-5.3" + ) + assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"} + assert got.applied_effort is Effort.LOW + + def test_a_bare_none_string_still_means_the_off_tier(self): + """裸 `"none"` 必须走到关闭形态,而不是被当成某个开启档。 + + 身份比较 `"none" is Effort.NONE` 恒假,漏归一的后果是**静默判否**: + `_wire_unknown_for` 的 `effort is not Effort.NONE` 恒真,于是关闭请求会去看 + `on_base`——正是设计 §2 处置表第 2 条点名要避免的误判方向。 + """ + got = resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.2"), "none", model="glm-5.2" + ) + assert got.payload == {"thinking": {"type": "disabled"}} + assert got.applied_effort is Effort.NONE + + def test_a_bare_none_string_reaches_phase4_on_a_model_that_cannot_disable(self): + """漏归一时 Phase 4 整条被绕过: 关不掉的模型会被静默放行成"开启"。""" + with pytest.raises(ThinkingUnsupportedError, match="无法关闭推理") as exc: + resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.3"), "none", model="glm-5.3" + ) + assert "'low'" in str(exc.value), "Phase 4 的可执行替代不能丢" + + def test_an_illegal_tier_string_names_this_function_as_the_origin(self): + """非法档位报 `ValueError` 并指回**是哪一处**填错——档位有四条入口,不说清 + 就得让人自己去翻。""" + with pytest.raises(ValueError, match="resolve_thinking") as exc: + resolve_thinking( + get_provider("zhipu"), get_capability("glm-5.3"), "lowest", model="glm-5.3" + ) + assert "非法推理档位" in str(exc.value) + class TestReconcileThinking: """声明 × 观测对账(设计 §4.3): 矛盾出文案,不表态出 None。