a194f4326e
The minimax wire lost its tier value on the assumption that these models
reason by default, so injecting nothing still reads as "on". T10 measured
the real gateway and the assumption does not hold: MiniMax-M3 with no
reasoning parameter did not reason in 5 of 5 rounds, while all six
strength values worked. Existing downstreams on ENABLE_THINKING=true
went from reasoning to silently not reasoning, and the capability table
cannot catch it because phase 5 lets auto through unconditionally.
Restore on_base to the old {"reasoning_effort": "medium"} verbatim. This
is a stopgap - it hands the tier choice back to the library, which this
work set out to remove. The real fix is to constrain auto by the
capability table, a public behaviour change tracked as issue #21.
The assertions that said "minimax injects no tier on the on-tier" go
back with it; each carries a note on why it moved twice.
828 lines
37 KiB
Python
828 lines
37 KiB
Python
"""推理裁定与对账的行为测试(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,
|
||
effective_effort,
|
||
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_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,), "构造")
|
||
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 默认就在推理,开的方向不该被拦。
|
||
|
||
期望片段 2026-09-05 由 `{}` 改成 minimax 的 `on_base` 实际值: issue #21 把
|
||
该段的"开"改回带 medium(T2 的"开档不注入"是推定,T10 实测推翻)。本用例守的
|
||
是 Phase 4 只拦关闭方向,注入什么由 wire 决定,故随 wire 走。
|
||
"""
|
||
cap = get_capability("MiniMax-M2.7")
|
||
got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7")
|
||
assert got.payload == {"reasoning_effort": "medium"}
|
||
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 第三个派生量)。
|
||
|
||
样本 2026-09-05 由 MiniMax-M3 换成 glm-4.6v: T10 实测 M3 的六个强度值全部生效,
|
||
它不再是纯开关型;glm-4.6v 是实测证据最硬的 (none, auto) 模型,且 zhipu 的 wire
|
||
有 effort_key——这两点缺一不可,否则命中的是"该 provider 没有档位键"那条分支。
|
||
"""
|
||
cap = get_capability("glm-4.6v") # (none, auto): 能开能关,但没有强度档
|
||
with pytest.raises(ThinkingUnsupportedError) as exc:
|
||
resolve_thinking(get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v")
|
||
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("zhipu"),
|
||
get_capability("glm-4.6v"),
|
||
Effort.MEDIUM,
|
||
model="glm-4.6v",
|
||
)
|
||
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("glm-4.6v") # (none, auto)
|
||
got = resolve_thinking(
|
||
get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v", fallback="nearest"
|
||
)
|
||
assert got.applied_effort is Effort.AUTO
|
||
assert got.payload == {"thinking": {"type": "enabled"}}
|
||
|
||
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), "形态已知,不该指向注册"
|
||
|
||
# —— 归一化: 本函数是档位进入库内的第四条入口(设计 §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。
|
||
|
||
文案本身是被断言对象——判定与日志分离正是为此: 告警内容可直接比对,不必
|
||
去解析日志格式。
|
||
|
||
判据自 2026-09-05 起是**档位**而非布尔(设计 §4.3): `Effort.NONE` 走"要求
|
||
关闭"一支,其余档走"要求开启"一支。档位化不是换个参数名——文案里写的是本次
|
||
真正发出去的那一档,而 transport 的节流键正按它分离,两者必须同源。
|
||
"""
|
||
|
||
_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(
|
||
effort=Effort.NONE,
|
||
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(
|
||
effort=Effort.NONE,
|
||
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(
|
||
effort=Effort.NONE,
|
||
observation=ThinkingObservation.OBSERVED,
|
||
capability=self._CAP,
|
||
model="MiniMax-M3",
|
||
)
|
||
unregistered = reconcile_thinking(
|
||
effort=Effort.NONE,
|
||
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(
|
||
effort=Effort.AUTO,
|
||
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(
|
||
effort=Effort.AUTO,
|
||
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(
|
||
effort=Effort.NONE,
|
||
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(
|
||
effort=Effort.NONE,
|
||
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(
|
||
effort=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(
|
||
effort=Effort.AUTO,
|
||
observation=ThinkingObservation.OBSERVED,
|
||
capability=self._CAP,
|
||
model="MiniMax-M3",
|
||
)
|
||
is None
|
||
)
|
||
|
||
@pytest.mark.parametrize("effort", [Effort.LOW, Effort.HIGH, Effort.MAX])
|
||
def test_a_strength_tier_is_an_on_request_not_an_off_one(self, effort):
|
||
"""强度档必须走"要求开启"一支: 观测到推理正是它要的结果,不得报警。
|
||
|
||
判据写成真值性(`if not effort`)会在这里翻车——`Effort.NONE` 的取值是
|
||
非空串 `"none"`,恒为真;那种写法会把每一个强度档都送进"要求关闭"分支,
|
||
于是"想了"被当成矛盾,而"没想"反倒沉默,告警方向整个颠倒。
|
||
"""
|
||
assert (
|
||
reconcile_thinking(
|
||
effort=effort,
|
||
observation=ThinkingObservation.OBSERVED,
|
||
capability=ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "构造"),
|
||
model="glm-5.3",
|
||
)
|
||
is None
|
||
)
|
||
|
||
def test_the_wording_names_the_tier_that_was_asked_for(self):
|
||
"""文案要写出**本次这一档**: 节流键按档分离,文案不分档就看不出是哪一档。"""
|
||
low = reconcile_thinking(
|
||
effort=Effort.LOW,
|
||
observation=ThinkingObservation.ABSENT,
|
||
capability=None,
|
||
model="glm-5.3",
|
||
)
|
||
max_ = reconcile_thinking(
|
||
effort=Effort.MAX,
|
||
observation=ThinkingObservation.ABSENT,
|
||
capability=None,
|
||
model="glm-5.3",
|
||
)
|
||
assert low is not None and max_ is not None
|
||
assert "low" in low and "max" in max_
|
||
assert low != max_
|
||
|
||
def test_none_and_observed_is_the_issue_20_contradiction(self):
|
||
"""请求 `none` 却观测到推理 —— issue #20 要恢复的那条报警,判据是**档位相等**。
|
||
|
||
与上一条互为对照: 同样是 OBSERVED,`none` 必须喊、强度档必须沉默。把分支
|
||
条件写反(`is not Effort.NONE`)会让这两条同时红,单有一条则抓不住。
|
||
"""
|
||
msg = reconcile_thinking(
|
||
effort=Effort.NONE,
|
||
observation=ThinkingObservation.OBSERVED,
|
||
capability=None,
|
||
model="glm-5.3",
|
||
)
|
||
assert msg is not None and "none" in msg
|
||
|
||
|
||
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
|
||
|
||
|
||
class TestEffectiveEffort:
|
||
"""三层优先级的**唯一**判定处(设计 §4.2): 请求级 > 源级 > 语法糖 > 不表态。
|
||
|
||
收口成一个纯函数,是因为它此前在装配守卫与 transport 里各写了一份就地转换:
|
||
两份各自演化的判定,迟早会在"装配期放行、运行期报错"这种最难查的形态上分叉。
|
||
"""
|
||
|
||
def test_request_beats_source(self):
|
||
assert (
|
||
effective_effort(
|
||
request_effort=Effort.MAX, source_effort=Effort.LOW, enable_thinking=None
|
||
)
|
||
is Effort.MAX
|
||
)
|
||
|
||
def test_source_beats_sugar(self):
|
||
assert (
|
||
effective_effort(request_effort=None, source_effort=Effort.HIGH, enable_thinking=None)
|
||
is Effort.HIGH
|
||
)
|
||
|
||
def test_none_request_does_not_clear_source(self):
|
||
"""请求级"没表态"绝不能被读成"要求关闭"——那会静默改掉源级的默认档。"""
|
||
assert (
|
||
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=None)
|
||
is Effort.LOW
|
||
)
|
||
|
||
def test_request_none_tier_is_an_opinion(self):
|
||
"""`Effort.NONE` 是一次明确的表态,必须压过源级档位而不是被当成缺省。"""
|
||
assert (
|
||
effective_effort(
|
||
request_effort=Effort.NONE, source_effort=Effort.MAX, enable_thinking=None
|
||
)
|
||
is Effort.NONE
|
||
)
|
||
|
||
def test_enable_thinking_true_is_auto(self):
|
||
"""`True` → `auto`(开启但不指定强度),而**不是**旧版硬编码的 medium。"""
|
||
assert (
|
||
effective_effort(request_effort=None, source_effort=None, enable_thinking=True)
|
||
is Effort.AUTO
|
||
)
|
||
|
||
def test_enable_thinking_false_is_the_none_tier(self):
|
||
assert (
|
||
effective_effort(request_effort=None, source_effort=None, enable_thinking=False)
|
||
is Effort.NONE
|
||
)
|
||
|
||
def test_sugar_is_the_last_word_only(self):
|
||
"""语法糖排在最末: 显式配了档位就以档位为准(矛盾组合已被构造期挡下)。"""
|
||
assert (
|
||
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=True)
|
||
is Effort.LOW
|
||
)
|
||
|
||
def test_all_absent_is_no_opinion(self):
|
||
"""三层都不表态 → None(随模型默认),与 `Effort.NONE` 严格区分。"""
|
||
assert (
|
||
effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None
|
||
)
|