diff --git a/research-wiki/designs/2026-09-04-reasoning-effort-design.md b/research-wiki/designs/2026-09-04-reasoning-effort-design.md index 373018f..3c3e382 100644 --- a/research-wiki/designs/2026-09-04-reasoning-effort-design.md +++ b/research-wiki/designs/2026-09-04-reasoning-effort-design.md @@ -128,7 +128,7 @@ cherry 有两层我们**明确不做**: | Phase | 条件 | 结果 | |---|---|---| | 1 | 生效档位为 `None`(调用方不表态) | 返回 `{}`,不注入 | -| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` | +| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`;`none` 方向须 `off` 与 `on_base` **皆为 `None`** 才算「整体形态未知」——单 `off is None` 是「该 provider 关不掉」,归 `_inject` 说清缺的是哪半边,2026-09-05 实现时补正) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` | | 3 | 能力未登记 | warning 后按 wire 尽力注入,**不校验档位** | | 4 | 请求 `none` 而该模型无 `none` 档 | `ThinkingUnsupportedError`,**给出 `cheapest_effort` 作为替代** | | 5 | 其余档位不在 `supported_efforts` 且未开映射 | `ThinkingUnsupportedError`,列出该模型可选档 | diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 2fdd487..c4519a6 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -14,7 +14,7 @@ from typing import Any from loguru import logger -from polygateway.providers import ProviderProfile +from polygateway.providers import ProviderProfile, ThinkingWire from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation @@ -391,7 +391,7 @@ def resolve_thinking( return ThinkingResolution({}, None) wire = profile.thinking # Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方 - if wire.on_base is None: + if _wire_unknown_for(wire, effort): raise ThinkingUnsupportedError( f"provider {profile.name!r} 的推理形态未知(模型 {model!r},请求档位 " f"{effort.value!r}): 本库不知道该 provider 如何表达推理。请用 " @@ -399,7 +399,7 @@ def resolve_thinking( ) # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 if capability is None: - payload = _inject(profile, wire.on_base, effort, model=model) + payload = _inject(profile, effort, model=model) if warn_unregistered: _warn_unregistered(model, profile, effort, payload) return ThinkingResolution(payload, effort) @@ -409,7 +409,24 @@ def resolve_thinking( raise ThinkingUnsupportedError(_cannot_disable(model, capability)) # Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring) applied = _settle_tier(effort, capability, model=model, fallback=fallback) - return ThinkingResolution(_inject(profile, wire.on_base, applied, model=model), applied) + return ThinkingResolution(_inject(profile, applied, model=model), applied) + + +def _wire_unknown_for(wire: ThinkingWire, effort: Effort) -> bool: + """Phase 2 的判据: **按请求档取相关字段**,不是一律看 `on_base`。 + + 旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此。只看 + `on_base` 会让"关闭形态已知、开启形态未知"的自定义 provider 在请求 `none` 时 + 被误拒,且指向它已经做过的 `register_provider`(设计 §2 处置表第 2 条)。 + + 请求 `none` 时判据是**两者皆 None**,而不是单看 `off`: `ThinkingWire` 的三个 + `None` 语义互不重叠——`off is None` 而 `on_base` 已知是"该 provider 关不掉" + (由 `_inject` 说清是缺了哪半边),只有两者皆 None 才是"整个形态未知",此时 + 指路 `register_provider` 才是对的方向。 + """ + if effort is not Effort.NONE: + return wire.on_base is None + return wire.off is None and wire.on_base is None def _settle_tier( @@ -437,13 +454,12 @@ def _settle_tier( return mapped -def _inject( - profile: ProviderProfile, on_base: Mapping[str, Any], effort: Effort, *, model: str -) -> Mapping[str, Any]: +def _inject(profile: ProviderProfile, effort: Effort, *, model: str) -> Mapping[str, Any]: """按 wire 把档位写成请求体片段;wire 表达不了这一档时报错。 - `on_base` 由调用方传入而非在此重读,是为了让"Phase 2 已保证它不是 None"这条 - 前提在签名上看得见。 + 自己重读 `wire` 而不由调用方传 `on_base`: Phase 2 的判据按请求档取相关字段 + (`none` 看 `off`,其余档看 `on_base`)之后,"on_base 一定不是 None"这条前提 + 只对非 `none` 档成立,写进签名反而是句假话。 三种 `None` 的语义在此**各自兑现**(ThinkingWire 的 docstring 定义了它们): `off is None` = 该 provider 关不掉,`effort_key is None` = 它只有开关没有档位。 @@ -459,16 +475,18 @@ def _inject( f"需要不推理请换用支持关闭的 provider 或模型" ) return wire.off + # 非 none 档的开启形态由 Phase 2 保证已知(内部不变量,不承担生产校验) + assert wire.on_base is not None if effort is Effort.AUTO: # auto = 开启但不指定强度: 逐字节等于升级前的 `thinking_on` - return on_base + return wire.on_base if wire.effort_key is None: raise ThinkingUnsupportedError( f"provider {profile.name!r} 只有推理开关、没有档位键(模型 {model!r})," f"表达不了 reasoning_effort={effort.value!r}: 请改用 auto/none 两档," f"或用 register_provider 给该 provider 注册 effort_key" ) - return {**on_base, wire.effort_key: effort.value} + return {**wire.on_base, wire.effort_key: effort.value} def _cannot_disable(model: str, capability: ThinkingCapability) -> str: diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 85997dc..54934ef 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -177,6 +177,41 @@ class TestResolveThinking: assert "kimi-k3" in msg assert effort.value in msg + def test_phase2_reads_the_form_the_asked_for_tier_needs(self): + """请求 `none` 只需要**关闭**形态: 开启形态未知与这次请求无关。 + + 旧版 `slot = thinking_on if enable_thinking else thinking_off` 即按请求方向 + 取字段;档位化后一度写成"只看 `on_base`",于是一个已注册了关闭形态的自定义 + provider 在请求 `none` 时被误拒,还被指向它已经做过的 `register_provider` + ——指错方向比不指更糟(设计 §2 处置表第 2 条,2026-09-05 独立验证查出)。 + """ + profile = ProviderProfile( + name="off_only", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None + ), + strip_think_tags=False, + ) + cap = ThinkingCapability((Effort.NONE, Effort.AUTO), "构造: 关得掉,开启形态却未登记") + got = resolve_thinking(profile, cap, Effort.NONE, model="x-1") + assert got.payload == {"thinking": {"type": "disabled"}} + assert got.applied_effort is Effort.NONE + + @pytest.mark.parametrize("effort", [Effort.AUTO, Effort.HIGH]) + def test_phase2_still_fires_when_the_on_form_is_the_missing_half(self, effort): + """反方向不得被一并放过: 要开推理而开启形态未知,仍须报错并指路注册。""" + profile = ProviderProfile( + name="off_only", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None + ), + strip_think_tags=False, + ) + cap = ThinkingCapability((Effort.NONE, Effort.AUTO, Effort.HIGH), "构造") + with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc: + resolve_thinking(profile, cap, effort, model="x-1") + assert effort.value in str(exc.value) + def test_phase2_beats_the_capability_checks(self): """形态未知时无从注入,能力如何无关紧要——Phase 2 必须先于 4/5。""" cap = ThinkingCapability((Effort.AUTO,), "构造")