feat: model the thinking switch as shape plus capability (issue #5)
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.
This commit is contained in:
@@ -202,6 +202,33 @@ class TestModelFingerprint:
|
||||
assert plain != tuned
|
||||
assert tuned.startswith("qwen-max|") # 旧字面量仍是前缀,便于人眼辨认
|
||||
|
||||
def test_enable_thinking_changes_fingerprint(self):
|
||||
"""issue #5 配套: thinking 一旦真正改变请求体,就必须进缓存身份。
|
||||
|
||||
否则"关掉推理后重启"会读到开着推理时缓存的旧响应——issue #4 为
|
||||
temperature 写过逐字相同的理由。
|
||||
"""
|
||||
from polygateway.client import build_model_fingerprint
|
||||
|
||||
plain = build_model_fingerprint([_source()])
|
||||
off = build_model_fingerprint([_source(enable_thinking=False)])
|
||||
on = build_model_fingerprint([_source(enable_thinking=True)])
|
||||
assert len({plain, off, on}) == 3
|
||||
|
||||
def test_extra_body_only_fingerprint_is_byte_identical_to_before(self):
|
||||
"""只配 extra_body、不表态 thinking 的存量源不得触发冷启动。
|
||||
|
||||
字面量在此硬编码: 这条断言的价值全在"逐字相同",改实现时必须先看见它红。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from polygateway.client import build_model_fingerprint
|
||||
|
||||
mark = json.dumps(["qwen-max", {"temperature": 0}], sort_keys=True, ensure_ascii=False)
|
||||
expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest()
|
||||
assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected
|
||||
|
||||
def test_source_rename_does_not_change_fingerprint(self):
|
||||
"""指纹按 (model, extra_body) 而非源名: 改名不该误触全量冷启动。"""
|
||||
from polygateway.client import build_model_fingerprint
|
||||
|
||||
@@ -460,6 +460,36 @@ class TestCrossFieldInvariants:
|
||||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
||||
|
||||
# —— 推理开关的装配守卫(issue #5)——
|
||||
|
||||
def _thinking_sources(self, provider, model, enable_thinking):
|
||||
base = self._base()
|
||||
src = dataclasses.replace(
|
||||
base.sources[0], provider=provider, model=model, enable_thinking=enable_thinking
|
||||
)
|
||||
return dataclasses.replace(base, sources=(src,))
|
||||
|
||||
def test_model_that_cannot_disable_thinking_fails_at_assembly(self):
|
||||
"""M2.x 关不掉推理: 配了 false 必须当场炸,而不是装出一个骗人的 client。"""
|
||||
settings = self._thinking_sources("minimax", "MiniMax-M2.7", False)
|
||||
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||
GatewayClient.from_settings(settings)
|
||||
|
||||
def test_unknown_thinking_shape_fails_at_assembly(self):
|
||||
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
|
||||
settings = self._thinking_sources("openai", "kimi-k3", False)
|
||||
with pytest.raises(ValueError, match="register_provider"):
|
||||
GatewayClient.from_settings(settings)
|
||||
|
||||
def test_supported_combination_assembles(self):
|
||||
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
|
||||
assert GatewayClient.from_settings(settings) is not None
|
||||
|
||||
def test_not_taking_a_position_never_trips_the_guard(self):
|
||||
"""enable_thinking=None(不干预)对任何 provider 都不该被守卫拦下。"""
|
||||
settings = self._thinking_sources("openai", "kimi-k3", None)
|
||||
assert GatewayClient.from_settings(settings) is not None
|
||||
|
||||
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
||||
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
||||
base = self._base()
|
||||
|
||||
@@ -506,6 +506,65 @@ class TestRequestShaping:
|
||||
assert "enable_thinking" not in seen
|
||||
assert seen["stream_options"] == {"include_usage": True}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enable_thinking", "expected"),
|
||||
[(True, "medium"), (False, "none")],
|
||||
)
|
||||
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
|
||||
"""issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。"""
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen.update(json.loads(request.content))
|
||||
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||
|
||||
source = _source(
|
||||
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
|
||||
)
|
||||
await _complete(_transport_for(handler), source)
|
||||
assert seen["reasoning_effort"] == expected
|
||||
assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发
|
||||
|
||||
async def test_extra_body_overrides_the_profile_slot(self):
|
||||
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。"""
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen.update(json.loads(request.content))
|
||||
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||
|
||||
source = _source(
|
||||
name="mm",
|
||||
provider="minimax",
|
||||
model="MiniMax-M3",
|
||||
enable_thinking=True,
|
||||
extra_body={"reasoning_effort": "high"},
|
||||
)
|
||||
await _complete(_transport_for(handler), source)
|
||||
assert seen["reasoning_effort"] == "high"
|
||||
|
||||
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
|
||||
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
|
||||
|
||||
裸异常会逃出 chat() —— 它不属错误四分类、TelemetryMW 也不捕,结果是一行
|
||||
遥测都没有就崩了(设计 §5.1)。
|
||||
"""
|
||||
|
||||
def handler(request): # pragma: no cover - 不该走到发请求
|
||||
raise AssertionError("请求不该发出")
|
||||
|
||||
source = _source(name="mm", provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
||||
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
|
||||
await _complete(_transport_for(handler), source)
|
||||
|
||||
async def test_unknown_shape_is_rejected(self):
|
||||
def handler(request): # pragma: no cover - 不该走到发请求
|
||||
raise AssertionError("请求不该发出")
|
||||
|
||||
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
|
||||
with pytest.raises(RequestRejectedError, match="register_provider"):
|
||||
await _complete(_transport_for(handler), source)
|
||||
|
||||
async def test_overlay_merged_into_payload(self):
|
||||
seen = {}
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,14 +30,21 @@ class TestDefaultProfiles:
|
||||
assert p.thinking_off == {"thinking": {"type": "disabled"}}
|
||||
assert p.strip_think_tags is False
|
||||
|
||||
def test_openai_baseline_profile(self):
|
||||
def test_openai_slots_are_unknown_not_empty(self):
|
||||
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
|
||||
|
||||
故不能下发任何厂商方言参数。None = 形态未知 → 配了 enable_thinking 即报错,
|
||||
而不是空字典那种"注入了个寂寞"的静默失效。
|
||||
"""
|
||||
p = get_provider("openai")
|
||||
assert p.thinking_on == {} and p.thinking_off == {}
|
||||
assert p.thinking_on is None and p.thinking_off is None
|
||||
assert p.strip_think_tags is False
|
||||
|
||||
def test_minimax_baseline_profile(self):
|
||||
def test_minimax_profile_uses_reasoning_effort(self):
|
||||
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
|
||||
p = get_provider("minimax")
|
||||
assert p.thinking_on == {} and p.thinking_off == {}
|
||||
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):
|
||||
@@ -62,3 +75,83 @@ class TestPureFunctionRegistration:
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user