diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 26b67f5..0e0e8bb 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -24,6 +24,13 @@ from polygateway.ocr import OcrClient from polygateway.pricing import ModelPrice, PricingTable from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider from polygateway.telemetry.schema import telemetry_schema_sql +from polygateway.thinking import ( + ThinkingCapability, + ThinkingUnsupportedError, + get_capability, + register_capability, + resolve_thinking, +) from polygateway.types import ( EmbeddingResponse, LLMResponse, @@ -32,6 +39,7 @@ from polygateway.types import ( OcrTextResult, SourceConfig, TelemetryStatus, + ThinkingObservation, ) __version__ = "1.3.0" @@ -63,9 +71,15 @@ __all__ = [ "SourceDeadError", "SourceNotConfiguredError", "TelemetryStatus", + "ThinkingCapability", + "ThinkingObservation", + "ThinkingUnsupportedError", "TransientError", "__version__", "gather_bounded", + "get_capability", + "register_capability", "register_provider", + "resolve_thinking", "telemetry_schema_sql", ] diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 49319d6..3a53b5a 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -26,7 +26,7 @@ from polygateway.middleware.structured import StructuredMW from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW from polygateway.ports import TelemetryStatusProvider from polygateway.pricing import PricingTable -from polygateway.providers import get_capability, get_provider, resolve_thinking +from polygateway.providers import get_provider from polygateway.sources import ( AdaptivePacer, HealthAwareSelector, @@ -34,6 +34,7 @@ from polygateway.sources import ( RoundRobinSelector, SourceCooldownMemo, ) +from polygateway.thinking import get_capability, resolve_thinking from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.types import ( ChatRequest, @@ -58,7 +59,8 @@ if TYPE_CHECKING: TelemetryRecorder, Transport, ) - from polygateway.providers import ProviderProfile, ThinkingCapability + from polygateway.providers import ProviderProfile + from polygateway.thinking import ThinkingCapability from polygateway.types import ( BackpressurePolicy, RetryPolicy, diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index e64d4b4..6832b20 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -3,6 +3,9 @@ 每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字 **精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享 状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。 + +**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么、响应算不算推理, +全部在 `thinking.py`(P7 决策逻辑与状态存储分离)。 """ from collections.abc import Mapping @@ -10,8 +13,6 @@ from dataclasses import dataclass from types import MappingProxyType from typing import Any -from loguru import logger - @dataclass(frozen=True) class ProviderProfile: @@ -87,144 +88,6 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( ) -class ThinkingUnsupportedError(ValueError): - """推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。 - - 是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置** - 不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期 - 捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让 - 捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关 - 错误误贴成"推理开关无法满足")。 - """ - - -@dataclass(frozen=True) -class ThinkingCapability: - """某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。 - - 与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变, - 数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者 - 合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的 - M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。 - - `evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。 - """ - - can_disable: bool - evidence: str - - -DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType( - { - "MiniMax-M3": ThinkingCapability( - can_disable=True, - evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变", - ), - "MiniMax-M2.7": ThinkingCapability( - can_disable=False, - evidence=( - "2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} " - "各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段" - ), - ), - "MiniMax-M2.5": ThinkingCapability( - can_disable=False, - evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理", - ), - "qwen3.7-plus": ThinkingCapability( - can_disable=True, - evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)", - ), - "deepseek-v4-pro": ThinkingCapability( - can_disable=True, - evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)", - ), - } -) -"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。""" - - -def get_capability( - model: str, *, table: Mapping[str, ThinkingCapability] | None = None -) -> ThinkingCapability | None: - """按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。 - - 与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值, - 写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。 - """ - return (DEFAULT_CAPABILITIES if table is None else table).get(model) - - -def register_capability( - model: str, - capability: ThinkingCapability, - *, - base: Mapping[str, ThinkingCapability] | None = None, -) -> dict[str, ThinkingCapability]: - """纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。""" - table = dict(DEFAULT_CAPABILITIES if base is None else base) - table[model] = capability - return table - - -def resolve_thinking( - profile: ProviderProfile, - capability: ThinkingCapability | None, - enable_thinking: bool | None, - *, - model: str, - warn_unregistered: bool = True, -) -> Mapping[str, Any]: - """三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。 - - 调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为 - `RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时 - 无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有 - `can_disable` 可读,故 Phase 3 必须先于 Phase 4。 - - `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 - `capability` 为 None(未登记)时无从从别处取得模型名。 - - `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次 - 调用再喊只会刷屏。判定结果不受此参数影响。 - """ - # Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档 - if enable_thinking is None: - return {} - slot = profile.thinking_on if enable_thinking else profile.thinking_off - direction = "thinking_on" if enable_thinking else "thinking_off" - # Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方 - if slot is None: - raise ThinkingUnsupportedError( - f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): " - f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态," - f"或改用 SourceConfig.extra_body 直接下发供应商参数" - ) - # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 - if capability is None: - if warn_unregistered: - _warn_unregistered(model, profile, slot) - return slot - # Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说 - if enable_thinking is False and not capability.can_disable: - raise ThinkingUnsupportedError( - f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: " - f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——" - f"需要关闭思维链请换用支持关闭的模型" - ) - return slot - - -def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None: - logger.warning( - "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" - "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", - model, - profile.name, - dict(slot), - ) - - def get_provider( name: str, *, registry: Mapping[str, ProviderProfile] | None = None ) -> ProviderProfile: diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index b855a37..3553960 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -7,6 +7,14 @@ 内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)。 """ +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any + +from loguru import logger + +from polygateway.providers import ProviderProfile from polygateway.types import ThinkingObservation @@ -27,3 +35,141 @@ def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> Thinking if reasoning_tokens is None: return ThinkingObservation.UNKNOWN return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT + + +class ThinkingUnsupportedError(ValueError): + """推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。 + + 是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置** + 不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期 + 捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让 + 捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关 + 错误误贴成"推理开关无法满足")。 + """ + + +@dataclass(frozen=True) +class ThinkingCapability: + """某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。 + + 与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变, + 数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者 + 合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的 + M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。 + + `evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。 + """ + + can_disable: bool + evidence: str + + +DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType( + { + "MiniMax-M3": ThinkingCapability( + can_disable=True, + evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变", + ), + "MiniMax-M2.7": ThinkingCapability( + can_disable=False, + evidence=( + "2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} " + "各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段" + ), + ), + "MiniMax-M2.5": ThinkingCapability( + can_disable=False, + evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理", + ), + "qwen3.7-plus": ThinkingCapability( + can_disable=True, + evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)", + ), + "deepseek-v4-pro": ThinkingCapability( + can_disable=True, + evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)", + ), + } +) +"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。""" + + +def get_capability( + model: str, *, table: Mapping[str, ThinkingCapability] | None = None +) -> ThinkingCapability | None: + """按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。 + + 与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值, + 写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。 + """ + return (DEFAULT_CAPABILITIES if table is None else table).get(model) + + +def register_capability( + model: str, + capability: ThinkingCapability, + *, + base: Mapping[str, ThinkingCapability] | None = None, +) -> dict[str, ThinkingCapability]: + """纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。""" + table = dict(DEFAULT_CAPABILITIES if base is None else base) + table[model] = capability + return table + + +def resolve_thinking( + profile: ProviderProfile, + capability: ThinkingCapability | None, + enable_thinking: bool | None, + *, + model: str, + warn_unregistered: bool = True, +) -> Mapping[str, Any]: + """三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。 + + 调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为 + `RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时 + 无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有 + `can_disable` 可读,故 Phase 3 必须先于 Phase 4。 + + `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 + `capability` 为 None(未登记)时无从从别处取得模型名。 + + `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次 + 调用再喊只会刷屏。判定结果不受此参数影响。 + """ + # Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档 + if enable_thinking is None: + return {} + slot = profile.thinking_on if enable_thinking else profile.thinking_off + direction = "thinking_on" if enable_thinking else "thinking_off" + # Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方 + if slot is None: + raise ThinkingUnsupportedError( + f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): " + f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态," + f"或改用 SourceConfig.extra_body 直接下发供应商参数" + ) + # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 + if capability is None: + if warn_unregistered: + _warn_unregistered(model, profile, slot) + return slot + # Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说 + if enable_thinking is False and not capability.can_disable: + raise ThinkingUnsupportedError( + f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: " + f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——" + f"需要关闭思维链请换用支持关闭的模型" + ) + return slot + + +def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None: + logger.warning( + "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" + "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", + model, + profile.name, + dict(slot), + ) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index d6e8bf2..9014a22 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -22,15 +22,14 @@ from polygateway.errors import ( SourceDeadError, TransientError, ) -from polygateway.providers import ( - ProviderProfile, +from polygateway.providers import ProviderProfile, get_provider +from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts +from polygateway.thinking import ( ThinkingCapability, ThinkingUnsupportedError, get_capability, - get_provider, resolve_thinking, ) -from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts from polygateway.transports._http_errors import compose_message, summarize_body from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index 3ec76d1..f891701 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -37,7 +37,7 @@ from polygateway.errors import ( SourceDeadError, TransientError, ) -from polygateway.providers import DEFAULT_CAPABILITIES, get_capability +from polygateway.thinking import DEFAULT_CAPABILITIES, get_capability _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} _HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV) diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 9df149f..7730a97 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -49,3 +49,28 @@ def test_telemetry_status_exported(): assert "TelemetryStatus" in polygateway.__all__ assert polygateway.TelemetryStatus is not None assert "TelemetryStatusProvider" not in polygateway.__all__ + + +def test_thinking_public_surface_exported(): + """issue #16/#17: 推理决策搬进 `polygateway.thinking` 后,公共符号必须走顶层。 + + 搬模块本身会断掉 `from polygateway.providers import ThinkingCapability` 这类 + 深路径 import。给下游一个稳定引用点,是以后再重组不再破坏下游的前提——本库 + 的约定是「顶层导出即公共 API 面」。 + + `observe_thinking` / `reconcile_thinking` **不**导出: 它们是 transport 内部 + 的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份 + 永久承诺。 + """ + for name in ( + "ThinkingCapability", + "ThinkingObservation", + "ThinkingUnsupportedError", + "get_capability", + "register_capability", + "resolve_thinking", + ): + assert hasattr(polygateway, name), name + assert name in polygateway.__all__, name + assert "observe_thinking" not in polygateway.__all__ + assert "reconcile_thinking" not in polygateway.__all__ diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index f3fa8b4..646768e 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -1,18 +1,12 @@ """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, ) @@ -75,83 +69,3 @@ 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") diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 210f1d3..06fa229 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -6,11 +6,27 @@ """ import pytest +from loguru import logger -from polygateway.thinking import observe_thinking +from polygateway.providers import get_provider +from polygateway.thinking import ( + DEFAULT_CAPABILITIES, + ThinkingCapability, + get_capability, + observe_thinking, + register_capability, + resolve_thinking, +) from polygateway.types import ThinkingObservation +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。""" @@ -74,3 +90,76 @@ def test_unknown_strings_are_rejected(bogus): """非法值必须抛 ValueError: 缓存回放靠它把污染数据挡成"未命中"(设计 §6)。""" 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(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")