Files
PolyGateway/tests/unit/test_thinking.py
T
iomgaa a1c4273a8b 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.
2026-09-05 01:36:30 -04:00

619 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""推理裁定与对账的行为测试(issue #16/#17 设计 §4-§5)。
判据来自 2026-08-25 实测(findings): MiniMax-M3 在开启档流式路径下返回 185 字符
推理正文却不上报 `completion_tokens_details`,而 qwen/deepseek 两者都报。库因此
不能把任何单一信号当权威——本组用例逐条钉死"哪个信号该赢"。
"""
import pytest
from loguru import logger
from polygateway.providers import ProviderProfile, ThinkingWire, get_provider
from polygateway.thinking import (
DEFAULT_CAPABILITIES,
ThinkingCapability,
ThinkingUnsupportedError,
get_capability,
observe_thinking,
reconcile_thinking,
register_capability,
resolve_thinking,
)
from polygateway.types import Effort, ThinkingObservation
_MYSTERY = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
"""形态完全未知的 provider(issue #5 的守卫对象)。
2026-09-04 起默认表 8 段全部有形态,故未知样本改为显式构造——测的是**机制**
(不知道怎么表达就报错并指路),不是某个段当时的配置。"""
def _warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
return messages, sink_id
class TestObserveThinking:
"""三态裁定: 证据硬度决定优先级,无信号一律 UNKNOWN。"""
def test_reasoning_text_alone_proves_it_happened(self):
"""推理正文是事实本身: 上游不报 token 数也照样成立(M3 流式实测形态)。"""
assert (
observe_thinking(thinking="先解方程 x+y=35", reasoning_tokens=None)
is ThinkingObservation.OBSERVED
)
def test_blank_text_is_not_evidence(self):
"""纯空白正文不算证据: 网关响应是外部输入,truthy 判据会把空格计成推理(P5)。"""
assert (
observe_thinking(thinking=" \n\t ", reasoning_tokens=None)
is ThinkingObservation.UNKNOWN
)
def test_positive_token_count_proves_it_happened(self):
"""无正文但上游报了推理用量(qwen 非流式形态)。"""
assert observe_thinking(thinking="", reasoning_tokens=205) is ThinkingObservation.OBSERVED
def test_zero_token_count_is_positive_evidence_of_absence(self):
"""`0` 是"上报了且为零",与"没上报"语义不同,故是 ABSENT 而非 UNKNOWN。"""
assert observe_thinking(thinking="", reasoning_tokens=0) is ThinkingObservation.ABSENT
def test_no_signal_at_all_stays_unknown(self):
"""M3 非流式开启档的真实形态: 推理已计费却既无正文也无 token 数。
判成 ABSENT 就是伪装成"没推理"——正是 issue #16/#17 的病根。
"""
assert observe_thinking(thinking="", reasoning_tokens=None) is ThinkingObservation.UNKNOWN
def test_text_outranks_a_zero_count(self):
"""转述与事实冲突时事实赢: 正文在,`reasoning_tokens=0` 不能翻案。"""
assert (
observe_thinking(thinking="想了想", reasoning_tokens=0) is ThinkingObservation.OBSERVED
)
@pytest.mark.parametrize("negative", [-1, -205])
def test_negative_token_count_is_not_evidence_of_absence(self, negative):
"""负数是坏数据,不是"上游明确上报未推理"这个最强的正面结论。
当前 transport 已在边界把负数归 `None`,所以这条走不通;但本函数的
docstring 自称"外部输入校验后使用",第二个 transport 直接填该值时,
`> 0 else ABSENT` 会给出一个方向相反的强结论。函数自身必须闭合(P5)。
"""
assert observe_thinking(thinking="", reasoning_tokens=negative) is (
ThinkingObservation.UNKNOWN
)
class TestThinkingObservationEnum:
def test_values_are_stable_strings(self):
"""取值进遥测落库,改名即历史数据断层。"""
assert ThinkingObservation.OBSERVED == "observed"
assert ThinkingObservation.ABSENT == "absent"
assert ThinkingObservation.UNKNOWN == "unknown"
def test_enum_lives_in_the_innermost_layer(self):
"""枚举必须定义在 `types.py`(最内层)。
它是 `LLMResponse` 的字段类型;定义在决策层 `thinking.py` 会让 `types.py`
反向 import 决策模块,违反 P7 依赖铁律(import-linter 契约执法)。
"""
assert ThinkingObservation.__module__ == "polygateway.types"
@pytest.mark.parametrize("bogus", ["", "OBSERVED", "yes", "none"])
def test_unknown_strings_are_rejected(bogus):
"""非法值必须抛 ValueError: 缓存回放与遥测归一化都靠它识别域外取值(设计 §6)。
两处接住这个 ValueError 后**降级而非作废**(缓存复活内容 + 记 UNKNOWN、遥测
照常落行),但降级的前提是构造器真的会拒绝——它一旦放行,域外取值就会一路
进到 `LLMResponse` 与遥测列里。
"""
with pytest.raises(ValueError):
ThinkingObservation(bogus)
class TestThinkingCapability:
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
def test_registered_models_carry_evidence(self):
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
cap = get_capability(model)
assert cap is not None and cap.evidence.strip()
def test_m3_can_disable_but_m2x_cannot(self):
assert get_capability("MiniMax-M3").can_disable is True
assert get_capability("MiniMax-M2.7").can_disable is False
assert get_capability("MiniMax-M2.5").can_disable is False
def test_unregistered_model_is_unknown(self):
assert get_capability("some-brand-new-model") is None
def test_register_capability_is_pure(self):
table = register_capability("x-1", ThinkingCapability((Effort.NONE, Effort.AUTO), "实测"))
assert get_capability("x-1", table=table) is not None
assert get_capability("x-1") is None # 默认表未被污染
def test_default_capabilities_mapping_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
class TestResolveThinking:
"""五道关卡(顺序即语义)与 nearest 映射;设计 §4.1。
每一关都有独立的失败模式,漏测哪一关,判定顺序被调换都不会被抓住——而顺序
在本函数里**就是**语义(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.payload == {}
assert got.applied_effort is None
# —— Phase 2: 形态未知 ——
@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, Effort.NONE, model="MiniMax-M9")
finally:
logger.remove(sink_id)
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_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_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, Effort.AUTO, model="MiniMax-M2.7")
assert got.payload == {}
assert got.applied_effort is Effort.AUTO
def test_phase4_passes_when_none_is_registered(self):
cap = get_capability("MiniMax-M3")
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
# —— 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:
"""声明 × 观测对账(设计 §5): 矛盾出文案,不表态出 None。
文案本身是被断言对象——判定与日志分离正是为此: 告警内容可直接比对,不必
去解析日志格式。
"""
_CAP = ThinkingCapability(
(Effort.NONE, Effort.AUTO), "2026-08-02 实测 reasoning_effort=none 可关闭"
)
def test_off_but_observed_with_a_registered_capability_blames_the_table(self):
"""已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。"""
msg = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
assert msg is not None
assert "MiniMax-M3" in msg
assert "2026-08-02 实测 reasoning_effort=none 可关闭" in msg
assert "register_capability" in msg
def test_off_but_observed_unregistered_never_claims_a_table_entry(self):
"""未登记模型没有"能力表声称"这回事——说它就是撒谎。"""
msg = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=None,
model="MiniMax-M9",
)
assert msg is not None
assert "MiniMax-M9" in msg
assert "能力表" not in msg
assert "register_capability" in msg
def test_registered_and_unregistered_wordings_differ(self):
registered = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
unregistered = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=None,
model="MiniMax-M3",
)
assert registered != unregistered
@pytest.mark.parametrize("capability", [None, _CAP])
def test_on_but_absent_is_a_contradiction(self, capability):
"""上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。"""
msg = reconcile_thinking(
enable_thinking=True,
observation=ThinkingObservation.ABSENT,
capability=capability,
model="qwen3.7-plus",
)
assert msg is not None
assert "qwen3.7-plus" in msg
@pytest.mark.parametrize("capability", [None, _CAP])
def test_on_but_unknown_admits_it_cannot_confirm(self, capability):
"""issue #17 的诚实版本: 明说"我注入了,但我看不见结果"。"""
msg = reconcile_thinking(
enable_thinking=True,
observation=ThinkingObservation.UNKNOWN,
capability=capability,
model="MiniMax-M3",
)
assert msg is not None
assert "MiniMax-M3" in msg
def test_off_and_absent_stays_silent(self):
"""要求关闭 + 上游明确上报未推理 = 要求被满足,没有可报的矛盾。
这一格与 `test_off_and_unknown_stays_silent` 的沉默理由**不同**: 那里是
"没有证伪力",这里是"正面证实要求已满足"。两者都必须沉默,漏测哪一格,
把 Phase 2 的判据写成 `is ABSENT` 之类的反向条件都不会被抓住。
"""
assert (
reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.ABSENT,
capability=self._CAP,
model="qwen3.7-plus",
)
is None
)
def test_off_and_unknown_stays_silent(self):
"""UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。"""
assert (
reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.UNKNOWN,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
@pytest.mark.parametrize(
"observation",
[ThinkingObservation.OBSERVED, ThinkingObservation.ABSENT, ThinkingObservation.UNKNOWN],
)
def test_no_request_no_grievance(self, observation):
"""调用方不表态,就无从谈"违背"。"""
assert (
reconcile_thinking(
enable_thinking=None,
observation=observation,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
def test_on_and_observed_is_exactly_what_was_asked_for(self):
assert (
reconcile_thinking(
enable_thinking=True,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
class TestEffortVocabulary:
"""八档封闭词汇(设计 §3.1);`auto` 不可省——9 个纯开关型模型无强度档可填。"""
def test_none_and_auto_are_distinct_members(self):
assert Effort.NONE != Effort.AUTO
assert Effort("none") is Effort.NONE
assert Effort("auto") is Effort.AUTO
def test_vocabulary_is_exactly_eight(self):
assert len(list(Effort)) == 8
def test_values_are_wire_literals(self):
# 档位值直接写进请求体,改名即改变发出去的字节
assert [e.value for e in Effort] == [
"none",
"auto",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]
class TestCapabilityTierList:
"""能力表从 bool 变成档位清单(设计 §3.2);三个派生量不存字段,存了必漂移。"""
def test_capability_derives_can_disable(self):
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").can_disable is True
assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").can_disable is False
def test_cheapest_effort_skips_none(self):
# 「关不掉时的可执行替代」取的是除 none 外最弱的一档
assert (
ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "实测").cheapest_effort
is Effort.LOW
)
assert (
ThinkingCapability((Effort.NONE, Effort.HIGH, Effort.MAX), "实测").cheapest_effort
is Effort.HIGH
)
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").cheapest_effort is Effort.AUTO
assert ThinkingCapability((Effort.AUTO,), "实测").cheapest_effort is Effort.AUTO
def test_cheapest_effort_is_none_when_only_none(self):
# 只能关不能开: 没有可推荐的「最省的开启档」
assert ThinkingCapability((Effort.NONE,), "实测").cheapest_effort is None
def test_is_tiered_excludes_none_and_auto(self):
# 纯开关型模型不该被告知「可选档位」——它没有档位
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").is_tiered is False
assert ThinkingCapability((Effort.AUTO,), "实测").is_tiered is False
assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").is_tiered is True
def test_empty_efforts_rejected(self):
with pytest.raises(ValueError, match="至少"):
ThinkingCapability((), "实测")
def test_duplicate_efforts_rejected(self):
with pytest.raises(ValueError, match="重复"):
ThinkingCapability((Effort.LOW, Effort.LOW), "实测")
def test_glm53_cannot_be_disabled(self):
# 三源一致(智谱官方文档/cherry-studio/OpenRouter): thinking.type 只接受 enabled
cap = get_capability("glm-5.3")
assert cap is not None
assert cap.can_disable is False
assert cap.cheapest_effort is Effort.LOW
def test_m2_series_still_cannot_be_disabled(self):
# 迁移回归: 旧表用 can_disable=False 表达的事实,新表用「none 不在清单里」表达
assert get_capability("MiniMax-M2.7").can_disable is False
assert get_capability("MiniMax-M2.5").can_disable is False
assert get_capability("MiniMax-M3").can_disable is True