feat: refuse an impossible tier with the cheapest one that model does have

resolve_thinking now takes an Effort instead of a tri-state bool, and the
four gates become five. The new one sits ahead of the generic tier check
on purpose: asking for `none` on GLM-5.3 used to fall through to "none is
not supported, pick low/high/max", which loses both the fact that the
model cannot stop reasoning and the one tier the caller could switch to
right now. Without that alternative, downstream goes looking for
extra_body — which is how issue #20 happened in the first place.

The return type is a ThinkingResolution rather than the payload alone.
Under fallback="nearest" the tier that goes out is not the tier that was
asked for, and telemetry has to record the one that ran, or task 10 files
a call under a tier it never used. Ties in that mapping go to the weaker
side: a silent medium -> max is a multiple of the bill, and the library
does not raise a caller's price on its own.

Two readings the design left implicit, both settled the way its own
compatibility promise requires:

- `auto` is exempt from the tier list. It means "on, no tier named",
  which in the body is the absence of the effort key, not a value of it.
  Checking it against the list would break every existing source that
  sets ENABLE_THINKING=true against deepseek-v4 or glm-5.3.
- `none` is never a mapping target. Turning "think less" into "do not
  think" reverses the decision instead of cheapening it; a switch-only
  model maps to `auto` and a model that only has `none` still errors.

Both call sites convert enable_thinking in place for now; task 5 folds
that into effective_effort along with the source- and call-level tiers.
This commit is contained in:
2026-09-05 01:36:30 -04:00
parent 84230673b9
commit a1c4273a8b
6 changed files with 473 additions and 65 deletions
+3 -1
View File
@@ -60,11 +60,13 @@ def test_thinking_public_surface_exported():
`observe_thinking` / `reconcile_thinking` **不**导出: 它们是 transport 内部
的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份
永久承诺。
永久承诺。`ThinkingResolution` 则**要**导出——它是已导出的 `resolve_thinking`
的返回类型,不导出等于下游拿得到实例却写不出类型标注。
"""
for name in (
"ThinkingCapability",
"ThinkingObservation",
"ThinkingResolution",
"ThinkingUnsupportedError",
"ThinkingWire",
"get_capability",
+245 -29
View File
@@ -12,6 +12,7 @@ from polygateway.providers import ProviderProfile, ThinkingWire, get_provider
from polygateway.thinking import (
DEFAULT_CAPABILITIES,
ThinkingCapability,
ThinkingUnsupportedError,
get_capability,
observe_thinking,
reconcile_thinking,
@@ -145,51 +146,266 @@ class TestThinkingCapability:
class TestResolveThinking:
"""条判定规则(顺序即语义);设计 §5 真值表。"""
"""道关卡(顺序即语义)与 nearest 映射;设计 §4.1。
def test_rule1_none_injects_nothing(self):
每一关都有独立的失败模式,漏测哪一关,判定顺序被调换都不会被抓住——而顺序
在本函数里**就是**语义(Phase 4 落进 Phase 5 就丢掉"这个模型根本关不掉")。
"""
# —— Phase 1: 不表态 ——
def test_phase1_absent_effort_injects_nothing(self):
"""没表态就什么都不注入,用模型自己的默认档(与 `none` 严格区分)。"""
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
assert got == {}
assert got.payload == {}
assert got.applied_effort is None
@pytest.mark.parametrize("enable", [True, False])
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
with pytest.raises(ValueError, match="register_provider") as exc:
resolve_thinking(_MYSTERY, None, enable, model="kimi-k3")
assert "extra_body" in str(exc.value)
# —— Phase 2: 形态未知 ——
def test_rule3_unregistered_model_warns_but_passes(self):
@pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH])
def test_phase2_unknown_wire_points_to_register(self, effort):
"""不知道怎么发就报错并指路;静默放行是 issue #5 修掉的那种欺骗。
文案必须报出**请求的档位**而非"开/关"方向: `Effort` 是非空字符串,拿它
的真值判方向会把 `none` 说成"开启形态未知",指错了排查方向。
"""
with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc:
resolve_thinking(_MYSTERY, None, effort, model="kimi-k3")
msg = str(exc.value)
assert "extra_body" in msg
assert "kimi-k3" in msg
assert effort.value in msg
def test_phase2_beats_the_capability_checks(self):
"""形态未知时无从注入,能力如何无关紧要——Phase 2 必须先于 4/5。"""
cap = ThinkingCapability((Effort.AUTO,), "构造")
with pytest.raises(ThinkingUnsupportedError, match="register_provider"):
resolve_thinking(_MYSTERY, cap, Effort.NONE, model="whatever")
# —— Phase 3: 能力未登记 ——
def test_phase3_unregistered_warns_then_injects(self):
"""新模型上线不该被库挡住,但也不该假装成功: 喊一声再尽力注入。"""
messages, sink_id = _warnings()
try:
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
got = resolve_thinking(get_provider("minimax"), None, Effort.NONE, model="MiniMax-M9")
finally:
logger.remove(sink_id)
assert got == {"reasoning_effort": "none"}
assert got.payload == {"reasoning_effort": "none"}
assert got.applied_effort is Effort.NONE
assert any("MiniMax-M9" in m for m in messages)
def test_rule4_cannot_disable_raises_with_the_model_name(self):
cap = get_capability("MiniMax-M2.7")
with pytest.raises(ValueError, match="MiniMax-M2.7"):
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
def test_phase3_can_be_silenced_on_the_hot_path(self):
"""装配期已经喊过一次,逐次调用再喊只会刷屏;判定结果不受影响。"""
messages, sink_id = _warnings()
try:
got = resolve_thinking(
get_provider("minimax"),
None,
Effort.NONE,
model="MiniMax-M9",
warn_unregistered=False,
)
finally:
logger.remove(sink_id)
assert got.payload == {"reasoning_effort": "none"}
assert not [m for m in messages if "MiniMax-M9" in m]
def test_rule4_only_blocks_the_off_direction(self):
def test_phase3_does_not_validate_tiers(self):
"""能力未知就没有清单可比对,拿空清单去拒绝档位等于凭空报错。"""
got = resolve_thinking(
get_provider("zhipu"),
None,
Effort.XHIGH,
model="glm-9-not-registered",
warn_unregistered=False,
)
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "xhigh"}
assert got.applied_effort is Effort.XHIGH
# —— Phase 4: 关不掉 ——
def test_phase4_before_phase5(self):
"""请求 `none` 而模型关不掉: 文案必须给出可执行替代与 env 键名。
若落进 Phase 5 的通用分支,报错会退化成"不支持 none,可选 low/high/max",
丢掉"这个模型根本关不掉"这个关键信息——下游随后就会去找 extra_body 那条
绕过的路,而那正是 issue #20 的成因。
"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError) as exc:
resolve_thinking(get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3")
msg = str(exc.value)
assert "glm-5.3" in msg
assert "'low'" in msg, "必须给出 cheapest_effort 的值"
assert "REASONING_EFFORT" in msg, "必须给出 env 键名"
assert "可选档位" not in msg, "退化成 Phase 5 的通用文案即失去可执行替代"
def test_phase4_never_maps_even_with_nearest(self):
"""`none` 不走映射: 把"关不掉"映射成"开着最低档"就是又一次静默降级。"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError, match="REASONING_EFFORT"):
resolve_thinking(
get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest"
)
def test_phase4_only_blocks_the_off_direction(self):
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
# 2026-09-04: "开"不再硬编码 medium——那是替下游做的档位判断,且 medium 在
# GLM/kimi/deepseek 的档位表里根本不存在。MiniMax 开启档本就无需参数
assert got == {}
got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7")
assert got.payload == {}
assert got.applied_effort is Effort.AUTO
def test_rule5_normal_path(self):
def test_phase4_passes_when_none_is_registered(self):
cap = get_capability("MiniMax-M3")
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
"reasoning_effort": "none"
}
got = resolve_thinking(get_provider("minimax"), cap, Effort.NONE, model="MiniMax-M3")
assert got.payload == {"reasoning_effort": "none"}
assert got.applied_effort is Effort.NONE
def test_unknown_shape_beats_capability_check(self):
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
cap = ThinkingCapability((Effort.AUTO,), "构造")
with pytest.raises(ValueError, match="register_provider"):
resolve_thinking(_MYSTERY, cap, False, model="whatever")
# —— Phase 5: 档位打空 ——
def test_phase5_lists_tiers_for_tiered_model(self):
"""档位型模型: 文案必须列出它真有的档,否则下游只能猜。"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError) as exc:
resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3")
msg = str(exc.value)
assert "medium" in msg and "glm-5.3" in msg
assert "可选档位" in msg
assert "low" in msg and "high" in msg and "max" in msg
def test_phase5_says_toggle_only_for_switch_model(self):
"""纯开关型模型没有档位,对它说"可选档位"是错的(设计 §3.2 第三个派生量)。"""
cap = get_capability("MiniMax-M3") # (none, auto): 能开能关,但没有强度档
with pytest.raises(ThinkingUnsupportedError) as exc:
resolve_thinking(get_provider("minimax"), cap, Effort.HIGH, model="MiniMax-M3")
msg = str(exc.value)
assert "可选档位" not in msg
assert "该模型只有开关" in msg
assert "auto" in msg and "none" in msg
def test_phase5_wording_forks_on_is_tiered(self):
"""两条分叉必须真的不同——同一句话套两种模型等于没分叉。"""
with pytest.raises(ThinkingUnsupportedError) as tiered:
resolve_thinking(
get_provider("zhipu"), get_capability("glm-5.3"), Effort.MEDIUM, model="glm-5.3"
)
with pytest.raises(ThinkingUnsupportedError) as switch:
resolve_thinking(
get_provider("minimax"),
get_capability("MiniMax-M3"),
Effort.MEDIUM,
model="MiniMax-M3",
)
assert str(tiered.value) != str(switch.value)
def test_phase5_passes_a_supported_tier(self):
cap = get_capability("glm-5.3")
got = resolve_thinking(get_provider("zhipu"), cap, Effort.MAX, model="glm-5.3")
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"}
assert got.applied_effort is Effort.MAX
def test_auto_never_trips_phase5(self):
"""`auto` = 不指定档位,可满足性只取决于 wire 有没有 on_base。
它不是写进 `effort_key` 的取值,故不受档位清单约束。反过来判会让存量的
`ENABLE_THINKING=true`(T5 起等价于 auto)在 deepseek/glm-5.3 这类清单里
没有 auto 的模型上当场报错——设计 §12 明确承诺存量配置继续可跑。
"""
cap = get_capability("deepseek-v4-pro") # (none, high, max),清单里没有 auto
got = resolve_thinking(get_provider("deepseek"), cap, Effort.AUTO, model="deepseek-v4-pro")
assert got.payload == {"thinking": {"type": "enabled"}}
assert got.applied_effort is Effort.AUTO
# —— nearest 映射(fallback 的逃生口)——
def test_nearest_ties_go_cheaper(self):
"""等距取弱: 省钱优先,库不替下游涨价(一次 medium→max 是数倍账单)。"""
cap = get_capability("glm-5.3") # (low, high, max)
messages, sink_id = _warnings()
try:
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest"
)
finally:
logger.remove(sink_id)
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"}
assert any("glm-5.3" in m and "medium" in m and "low" in m for m in messages)
def test_nearest_ties_go_cheaper_on_the_strong_side_too(self):
"""xhigh 与 high/max 位序各差 1,同样取弱侧——规则不因方向而变。"""
cap = get_capability("glm-5.3")
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.XHIGH, model="glm-5.3", fallback="nearest"
)
assert got.applied_effort is Effort.HIGH
def test_nearest_goes_up_when_the_only_neighbour_is_stronger(self):
"""minimal 之下无档可选,映射必须上行到 low,而不是无解报错。"""
cap = get_capability("glm-5.3")
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.MINIMAL, model="glm-5.3", fallback="nearest"
)
assert got.applied_effort is Effort.LOW
def test_nearest_never_turns_reasoning_off(self):
"""请求"想得浅一点"绝不能被映射成"别想了": 那是方向反转,不是省钱。"""
cap = get_capability("MiniMax-M3") # (none, auto)
got = resolve_thinking(
get_provider("minimax"), cap, Effort.HIGH, model="MiniMax-M3", fallback="nearest"
)
assert got.applied_effort is Effort.AUTO
assert got.payload == {}
def test_nearest_still_errors_when_no_on_tier_exists(self):
"""只能关不能开的模型,映射无解——报错而非挑一个反向的档。"""
cap = ThinkingCapability((Effort.NONE,), "构造: 只登记了关闭档")
with pytest.raises(ThinkingUnsupportedError, match="only-off"):
resolve_thinking(
get_provider("minimax"), cap, Effort.HIGH, model="only-off", fallback="nearest"
)
def test_error_fallback_is_the_default(self):
"""默认关闭映射的理由是钱: 静默的 medium→max 在 GLM-5.3 上是数倍账单。"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError):
resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3")
def test_resolution_reports_applied_effort_after_mapping(self):
"""遥测记的必须是**实际**发出去的档,否则压测按档分组时挂在从未发出的档下。"""
cap = get_capability("glm-5.3")
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest"
)
assert got.applied_effort is Effort.LOW
assert got.applied_effort is not Effort.MEDIUM
# —— 注入形态 ——
def test_auto_injects_on_base_only(self):
"""`auto` 逐字节等于旧的 `thinking_on`: 开启,但不附任何档位。"""
got = resolve_thinking(
get_provider("qwen"), get_capability("qwen3.7-plus"), Effort.AUTO, model="qwen3.7-plus"
)
assert got.payload == {"enable_thinking": True}
def test_effort_key_none_rejects_a_tier(self):
"""qwen 系只有开关没有档位键: 硬塞一个档位只会发出一个厂商不认的字段。"""
cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 假设它有档位")
with pytest.raises(ThinkingUnsupportedError, match="没有档位键"):
resolve_thinking(get_provider("qwen"), cap, Effort.LOW, model="qwen-hypothetical")
def test_provider_without_an_off_form_says_which_half_is_missing(self):
"""`off is None` ≠ `on_base is None`: 前者是"关不了",后者是"不知道怎么发""""
profile = ProviderProfile(
name="no_off",
thinking=ThinkingWire(off=None, on_base={}, effort_key="reasoning_effort"),
strip_think_tags=False,
)
cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 能力表说能关,形态却没有")
with pytest.raises(ThinkingUnsupportedError, match="没有关闭形态") as exc:
resolve_thinking(profile, cap, Effort.NONE, model="x-1")
assert "register_provider" not in str(exc.value), "形态已知,不该指向注册"
class TestReconcileThinking: