82f4ec4910
enable_thinking=False was a no-op for minimax and openai sources: both profiles had empty dicts on each side, so the payload update injected nothing while the caller believed reasoning had been turned off. A downstream project was blocked on exactly this. The root cause is that an empty dict meant two different things -- "no injection needed" and "we do not know how this provider spells it" -- and that a provider-level table cannot express what turned out to be a per-model property. Live testing showed MiniMax-M3 can disable reasoning via reasoning_effort while M2.7 and M2.5 cannot be disabled at all, which two external registries independently confirm. So the shape stays at provider level and a capability table joins it at model level. Unknown, unsupported and no-opinion are now three distinct values, and resolve_thinking is the single place they meet: it raises at assembly time when a model cannot honour the request, warns and injects for unregistered models, and injects silently otherwise. Every registered capability carries the evidence it was derived from. enable_thinking also joins the cache fingerprint, since it now really does change the request body.
158 lines
6.6 KiB
Python
158 lines
6.6 KiB
Python
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
|
|
|
import pytest
|
|
from loguru import logger
|
|
|
|
from polygateway.providers import (
|
|
DEFAULT_CAPABILITIES,
|
|
DEFAULT_PROFILES,
|
|
ProviderProfile,
|
|
ThinkingCapability,
|
|
get_capability,
|
|
get_provider,
|
|
register_capability,
|
|
register_provider,
|
|
resolve_thinking,
|
|
)
|
|
|
|
|
|
class TestDefaultProfiles:
|
|
def test_qwen_profile(self):
|
|
p = get_provider("qwen")
|
|
assert p.thinking_on == {"enable_thinking": True}
|
|
assert p.thinking_off == {"enable_thinking": False}
|
|
assert p.strip_think_tags is True
|
|
assert p.supports_native_schema is False
|
|
|
|
def test_deepseek_profile(self):
|
|
p = get_provider("deepseek")
|
|
assert p.thinking_on == {"thinking": {"type": "enabled"}}
|
|
assert p.thinking_off == {"thinking": {"type": "disabled"}}
|
|
assert p.strip_think_tags is False
|
|
|
|
def test_openai_slots_are_unknown_not_empty(self):
|
|
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
|
|
|
|
故不能下发任何厂商方言参数。None = 形态未知 → 配了 enable_thinking 即报错,
|
|
而不是空字典那种"注入了个寂寞"的静默失效。
|
|
"""
|
|
p = get_provider("openai")
|
|
assert p.thinking_on is None and p.thinking_off is None
|
|
assert p.strip_think_tags is False
|
|
|
|
def test_minimax_profile_uses_reasoning_effort(self):
|
|
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
|
|
p = get_provider("minimax")
|
|
assert p.thinking_off == {"reasoning_effort": "none"}
|
|
assert p.thinking_on == {"reasoning_effort": "medium"}
|
|
assert p.strip_think_tags is False
|
|
|
|
def test_unknown_provider_fails_loudly(self):
|
|
"""消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。"""
|
|
with pytest.raises(ValueError, match="glm"):
|
|
get_provider("glm")
|
|
with pytest.raises(ValueError):
|
|
get_provider("qwen2") # 子串相似也不放行
|
|
|
|
|
|
class TestPureFunctionRegistration:
|
|
def test_register_returns_new_mapping(self):
|
|
glm = ProviderProfile(name="glm", thinking_on={}, thinking_off={}, strip_think_tags=False)
|
|
table = register_provider(glm)
|
|
assert get_provider("glm", registry=table) is glm
|
|
# 默认表未被污染(无可变全局状态铁律)
|
|
with pytest.raises(ValueError):
|
|
get_provider("glm")
|
|
|
|
def test_register_on_custom_base_and_override(self):
|
|
custom_qwen = ProviderProfile(
|
|
name="qwen", thinking_on={"x": 1}, thinking_off={}, strip_think_tags=False
|
|
)
|
|
table = register_provider(custom_qwen, base=DEFAULT_PROFILES)
|
|
assert get_provider("qwen", registry=table).thinking_on == {"x": 1}
|
|
assert get_provider("qwen").thinking_on == {"enable_thinking": True}
|
|
|
|
def test_default_profiles_mapping_is_read_only(self):
|
|
with pytest.raises(TypeError):
|
|
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
|
|
|
|
|
def _warnings():
|
|
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
|
messages: list[str] = []
|
|
sink_id = logger.add(messages.append, level="WARNING")
|
|
return messages, sink_id
|
|
|
|
|
|
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(True, "实测"))
|
|
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:
|
|
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
|
|
|
|
def test_rule1_none_injects_nothing(self):
|
|
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
|
assert got == {}
|
|
|
|
@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(get_provider("openai"), None, enable, model="kimi-k3")
|
|
assert "extra_body" in str(exc.value)
|
|
|
|
def test_rule3_unregistered_model_warns_but_passes(self):
|
|
messages, sink_id = _warnings()
|
|
try:
|
|
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
|
|
finally:
|
|
logger.remove(sink_id)
|
|
assert got == {"reasoning_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_rule4_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")
|
|
assert got == {"reasoning_effort": "medium"}
|
|
|
|
def test_rule5_normal_path(self):
|
|
cap = get_capability("MiniMax-M3")
|
|
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
|
|
"reasoning_effort": "none"
|
|
}
|
|
|
|
def test_unknown_shape_beats_capability_check(self):
|
|
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
|
|
cap = ThinkingCapability(can_disable=False, evidence="构造")
|
|
with pytest.raises(ValueError, match="register_provider"):
|
|
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
|