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:
+245
-29
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user