diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index f8f39ce..b7b1f9e 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -78,6 +78,7 @@ __all__ = [ "ThinkingCapability", "ThinkingObservation", "ThinkingUnsupportedError", + "ThinkingWire", "TransientError", "__version__", "gather_bounded", diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index 36ab1c1..9427635 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -14,75 +14,147 @@ from types import MappingProxyType from typing import Any +@dataclass(frozen=True) +class ThinkingWire: + """一个 provider 表达"开/关/多深"的请求体形态(设计 §3.3)。 + + 三个字段各自的 `None` **语义互不重叠**,混淆任意两个都会退回 issue #5 修掉的 + 那种静默失效: + + ============== ========================================================== + ``on_base=None`` **形态未知**: 本库不知道该 provider 如何表达"开",配了开关 + 即装配期报错并指路 `register_provider`/`extra_body` + ``off=None`` 已知开启形态,但**没有关闭形态**(该 provider 关不掉) + ``effort_key`` ``None`` = 该 provider 只有开关、没有档位(qwen 系靠 + ``=None`` ``thinking_budget`` 调深度,不是档位) + ============== ========================================================== + + `on_base={}` 与 `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于 + 开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加), + 后者是"不知道怎么表达"。 + + **为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat / + openai-responses / anthropic-messages / google-generate-content 四种端点协议, + 故需要 closed operation 集合与 `budgetWire` 代际变体。本库只有一个 OpenAI 兼容 + transport,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent), + 一个协议一层形态即够(P1 YAGNI)。 + """ + + off: Mapping[str, Any] | None + on_base: Mapping[str, Any] | None + effort_key: str | None + + @dataclass(frozen=True) class ProviderProfile: """单个 provider 的能力与差异声明。 - thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为 - True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不 - 注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ```` - 标签(qwen 系);supports_native_schema 供 D14 阶梯选择原生 response_format。 + `thinking` 声明推理参数的**形态**(按 provider 变,数年不变一次); + `strip_think_tags` 声明响应 content 需剥离 ```` 标签(qwen 系); + `supports_native_schema` 供 D14 阶梯选择原生 response_format。 - 两档各有三种取值,**语义互不重叠**(issue #5): - - ========== ========================================================== - ``{...}`` 已知的注入片段 - ``{}`` 已知**无需注入**任何参数即处于该档 - ``None`` **未知**: 本库不知道该 provider 如何表达这一档 - ========== ========================================================== - - `None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False` - 对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。 - 现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。 - - 注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否 - 关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。 + 注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型支持哪些 + 档位属**能力**(按 model 变),见 `thinking.ThinkingCapability`。二者合一在 + provider 级表达不了代际差异——glm-5.2 能关而 glm-5.3 不能,形态却完全相同。 """ name: str - thinking_on: Mapping[str, Any] | None - thinking_off: Mapping[str, Any] | None + thinking: ThinkingWire strip_think_tags: bool supports_native_schema: bool = False +# 经 new-api 中转的口径。四家参考实现(cherry-studio / OpenRouter / LiteLLM / +# new-api 自身)一致的结论: OpenAI 兼容端点上,档位一律走标准的 `reasoning_effort`, +# 跨协议转换(→ Claude 的 thinking、Gemini 的 thinkingConfig)由网关服务端完成。 DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( { # 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态) "qwen": ProviderProfile( name="qwen", - thinking_on={"enable_thinking": True}, - thinking_off={"enable_thinking": False}, + thinking=ThinkingWire( + off={"enable_thinking": False}, + on_base={"enable_thinking": True}, + # 百炼的深度控制是 `thinking_budget`(token 预算)而非档位; + # 预算型控制本库当前不支持(设计 §11 明确不做) + effort_key=None, + ), strip_think_tags=True, ), + # 官方 thinking_mode 文档: thinking:{type} 是开关,reasoning_effort 是深度, + # V4 一代两者并用(deepseek-v4-* 的档位见能力表) "deepseek": ProviderProfile( name="deepseek", - thinking_on={"thinking": {"type": "enabled"}}, - thinking_off={"thinking": {"type": "disabled"}}, + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, + on_base={"thinking": {"type": "enabled"}}, + effort_key="reasoning_effort", + ), strip_think_tags=False, ), - # OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把 - # kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给 - # 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在 - # 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider - "openai": ProviderProfile( - name="openai", - thinking_on=None, - thinking_off=None, + # issue #20。智谱官方迁移建议原文: 原先用 {"type":"disabled"} 的应改为 + # {"type":"enabled"} + reasoning_effort="low"——GLM-5.3 起 thinking.type + # 不再接受 disabled,故"关"这一档由能力表按型号裁定(5.2 能关,5.3 不能) + "zhipu": ProviderProfile( + name="zhipu", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, + on_base={"thinking": {"type": "enabled"}}, + effort_key="reasoning_effort", + ), strip_think_tags=False, ), - # 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2), - # 2026-08-25 复测结论不变(findings 2026-08-25 §5);**直连官方端点未验证**。 - # 实测 enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens - # 恒定等于基线 194),reasoning_effort 才是真开关——本片段的选型据此成立。 - # "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的 - # thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近 - # "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。 - # 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段) + # kimi-k3 的档位是 low/high/max;thinking.type 为月之暗面的开关形态 + "moonshot": ProviderProfile( + name="moonshot", + thinking=ThinkingWire( + off={"thinking": {"type": "disabled"}}, + on_base={"thinking": {"type": "enabled"}}, + effort_key="reasoning_effort", + ), + strip_think_tags=False, + ), + # 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。 + # enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线 + # 194),reasoning_effort 才是真开关——本段形态据此成立。 + # `on_base={}`: "开"本身不需要任何参数,档位(若模型支持)由 effort_key 附加。 + # 旧版此处硬编码 {"reasoning_effort": "medium"},那是替下游做的档位判断, + # 且 medium 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本次要消灭的 "minimax": ProviderProfile( name="minimax", - thinking_on={"reasoning_effort": "medium"}, - thinking_off={"reasoning_effort": "none"}, + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + ), + strip_think_tags=False, + ), + # OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底。两档此前标 + # None(未知),因为当时无法区分"厂商方言"与"标准字段";`reasoning_effort` + # 是 OpenAI **官方**字段而非方言,发给经网关的兼容端点不会打到不认识它的 + # 厂商,故 2026-09-04 起给出标准形态。真正形态未知的 provider 仍走 + # register_provider 注册,而不是挂在本段下 + "openai": ProviderProfile( + name="openai", + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + ), + strip_think_tags=False, + ), + # Claude 5 系原生是 thinking.type=adaptive + output_config.effort,Gemini 3 系 + # 原生是 thinkingConfig.thinkingLevel;两者的代际方言(Claude ≤4.5 的 + # budget_tokens、Gemini 2.x 的 thinkingBudget)由 new-api 的 canonical intent + # 层吸收,本库只发 OpenAI 形态(设计 §3.4) + "anthropic": ProviderProfile( + name="anthropic", + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + ), + strip_think_tags=False, + ), + "google": ProviderProfile( + name="google", + thinking=ThinkingWire( + off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + ), strip_think_tags=False, ), } diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 153f9b3..2cdac4e 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -315,8 +315,9 @@ def resolve_thinking( # 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" + wire = profile.thinking + slot = wire.on_base if enable_thinking else wire.off + direction = "on_base" if enable_thinking else "off" # Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方 if slot is None: raise ThinkingUnsupportedError( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e29bc4d..338f1b1 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -8,6 +8,7 @@ from loguru import logger from polygateway.client import GatewayClient from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings +from polygateway.providers import ProviderProfile, ThinkingWire, register_provider _BASE_ENV = { "LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1", @@ -637,10 +638,23 @@ class TestCrossFieldInvariants: GatewayClient.from_settings(settings) def test_unknown_thinking_shape_fails_at_assembly(self): - """provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。""" - settings = self._thinking_sources("openai", "kimi-k3", False) + # 形态未知即报错并指路。2026-09-04 起默认表 8 段全部有形态(openai 段改发 + # OpenAI 标准的 reasoning_effort),故样本改为显式注册一个未知段——守卫测的 + # 是机制,不是某个段当时的配置 + mystery = ProviderProfile( + name="mystery", + thinking=ThinkingWire(off=None, on_base=None, effort_key=None), + strip_think_tags=False, + ) + settings = self._thinking_sources("mystery", "kimi-k3", False) with pytest.raises(ValueError, match="register_provider"): - GatewayClient.from_settings(settings) + GatewayClient.from_settings(settings, registry=register_provider(mystery)) + + def test_openai_segment_now_assembles_with_the_standard_field(self): + # 行为变更(2026-09-04): reasoning_effort 是 OpenAI 官方字段而非厂商方言, + # 经网关的兼容端点收得下,故兜底段不再把"关闭"判为形态未知 + settings = self._thinking_sources("openai", "gpt-5.5", False) + assert GatewayClient.from_settings(settings) is not None def test_supported_combination_assembles(self): settings = self._thinking_sources("minimax", "MiniMax-M3", False) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 2791828..5116cb2 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -16,6 +16,7 @@ from polygateway.errors import ( ) from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.pricing import ModelPrice, PricingTable +from polygateway.providers import ProviderProfile, ThinkingWire, register_provider from polygateway.transports._http_errors import summarize_body from polygateway.transports.openai_compat import ( OpenAICompatTransport, @@ -60,10 +61,11 @@ def _sse_stream(*frames, done=True): return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"}) -def _transport_for(handler): +def _transport_for(handler, *, registry=None): mock = httpx.MockTransport(handler) return OpenAICompatTransport( - client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock) + client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock), + registry=registry, ) @@ -666,7 +668,9 @@ class TestRequestShaping: @pytest.mark.parametrize( ("enable_thinking", "expected"), - [(True, "medium"), (False, "none")], + # 开档不再附 medium(2026-09-04): 那是替下游做的档位判断,且 medium 不在 + # GLM/kimi/deepseek 的档位表里。MiniMax 开启档本就无需参数,要强度请配档位 + [(True, None), (False, "none")], ) async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected): """issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。""" @@ -680,7 +684,10 @@ class TestRequestShaping: name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking ) await _complete(_transport_for(handler), source) - assert seen["reasoning_effort"] == expected + if expected is None: + assert "reasoning_effort" not in seen + else: + assert seen["reasoning_effort"] == expected assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发 async def test_extra_body_overrides_the_profile_slot(self): @@ -758,9 +765,15 @@ class TestRequestShaping: def handler(request): # pragma: no cover - 不该走到发请求 raise AssertionError("请求不该发出") - source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False) + # 2026-09-04 起默认表 8 段全部有形态,守卫样本改为显式注册的未知段 + mystery = ProviderProfile( + name="mystery", + thinking=ThinkingWire(off=None, on_base=None, effort_key=None), + strip_think_tags=False, + ) + source = _source(name="k3", provider="mystery", model="kimi-k3", enable_thinking=False) with pytest.raises(RequestRejectedError, match="register_provider"): - await _complete(_transport_for(handler), source) + await _complete(_transport_for(handler, registry=register_provider(mystery)), source) async def test_overlay_merged_into_payload(self): seen = {} diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 646768e..6b1e5b5 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -1,57 +1,99 @@ -"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。""" +"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。 + +2026-09-04 起 profile 存的是 `ThinkingWire`(off / on_base / effort_key)而非两个 +固定片段——档位型模型(GLM-5.3、kimi-k3、deepseek-v4…)的"开"档需要附一个档位值, +两个固定片段表达不了。 +""" import pytest from polygateway.providers import ( DEFAULT_PROFILES, ProviderProfile, + ThinkingWire, get_provider, register_provider, ) +_EXPECTED_SEGMENTS = frozenset( + {"qwen", "deepseek", "zhipu", "moonshot", "minimax", "openai", "anthropic", "google"} +) + 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_all_eight_profiles_registered(self): + """issue #20: 智谱缺段,下游只能把 GLM 挂在 openai 兜底段下再手写 extra_body。""" + assert set(DEFAULT_PROFILES) == _EXPECTED_SEGMENTS - 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_qwen_is_a_switch_with_no_tiers(self): + w = get_provider("qwen").thinking + assert w.on_base == {"enable_thinking": True} + assert w.off == {"enable_thinking": False} + assert w.effort_key is None # 百炼靠 thinking_budget 调深度,不是档位 + assert get_provider("qwen").strip_think_tags is True - def test_openai_slots_are_unknown_not_empty(self): - """issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此), + def test_deepseek_carries_both_switch_and_tier(self): + w = get_provider("deepseek").thinking + assert w.on_base == {"thinking": {"type": "enabled"}} + assert w.off == {"thinking": {"type": "disabled"}} + assert w.effort_key == "reasoning_effort" - 故不能下发任何厂商方言参数。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_zhipu_matches_the_vendor_migration_note(self): + """智谱官方: thinking.type=enabled + reasoning_effort 才是 GLM-5.3 的正确形态。""" + w = get_provider("zhipu").thinking + assert w.on_base == {"thinking": {"type": "enabled"}} + assert w.off == {"thinking": {"type": "disabled"}} + assert w.effort_key == "reasoning_effort" - 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_openai_family_sends_the_standard_field_only(self): + """gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。""" + for name in ("openai", "anthropic", "google", "minimax"): + w = get_provider(name).thinking + assert w.on_base == {}, name + assert w.off == {"reasoning_effort": "none"}, name + assert w.effort_key == "reasoning_effort", name def test_unknown_provider_fails_loudly(self): """消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。""" with pytest.raises(ValueError, match="glm"): - get_provider("glm") + get_provider("glm") # 段名是 zhipu,不是 glm with pytest.raises(ValueError): get_provider("qwen2") # 子串相似也不放行 + def test_error_lists_every_registered_segment(self): + with pytest.raises(ValueError, match="zhipu"): + get_provider("nope") + + +class TestWireNoneSemantics: + """三个 `None` 语义互不重叠(issue #5 的成果,不可退回成"注入了个寂寞")。""" + + def test_on_base_none_means_shape_unknown(self): + w = ThinkingWire(off=None, on_base=None, effort_key=None) + assert w.on_base is None + + def test_off_none_means_no_off_shape(self): + """有开启形态但没有关闭形态,与"整个形态未知"是两回事。""" + w = ThinkingWire(off=None, on_base={"x": 1}, effort_key=None) + assert w.on_base is not None and w.off is None + + def test_effort_key_none_means_switch_only(self): + """qwen 是这一档: 能开能关,但没有档位可谈。""" + assert get_provider("qwen").thinking.effort_key is None + + def test_empty_on_base_is_not_none(self): + """`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。""" + w = get_provider("minimax").thinking + assert w.on_base == {} and w.on_base is not None + class TestPureFunctionRegistration: def test_register_returns_new_mapping(self): - glm = ProviderProfile(name="glm", thinking_on={}, thinking_off={}, strip_think_tags=False) + glm = ProviderProfile( + name="glm", + thinking=ThinkingWire(off={}, on_base={}, effort_key=None), + strip_think_tags=False, + ) table = register_provider(glm) assert get_provider("glm", registry=table) is glm # 默认表未被污染(无可变全局状态铁律) @@ -59,12 +101,14 @@ class TestPureFunctionRegistration: 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 + custom = ProviderProfile( + name="qwen", + thinking=ThinkingWire(off={}, on_base={"x": 1}, effort_key=None), + 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} + table = register_provider(custom, base=DEFAULT_PROFILES) + assert get_provider("qwen", registry=table).thinking.on_base == {"x": 1} + assert get_provider("qwen").thinking.on_base == {"enable_thinking": True} def test_default_profiles_mapping_is_read_only(self): with pytest.raises(TypeError): diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 711022a..500b570 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -8,7 +8,7 @@ import pytest from loguru import logger -from polygateway.providers import get_provider +from polygateway.providers import ProviderProfile, ThinkingWire, get_provider from polygateway.thinking import ( DEFAULT_CAPABILITIES, ThinkingCapability, @@ -20,6 +20,16 @@ from polygateway.thinking import ( ) 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 抓不到。""" @@ -144,7 +154,7 @@ class TestResolveThinking: @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") + resolve_thinking(_MYSTERY, None, enable, model="kimi-k3") assert "extra_body" in str(exc.value) def test_rule3_unregistered_model_warns_but_passes(self): @@ -165,7 +175,9 @@ class TestResolveThinking: """关不掉 ≠ 开不了: 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"} + # 2026-09-04: "开"不再硬编码 medium——那是替下游做的档位判断,且 medium 在 + # GLM/kimi/deepseek 的档位表里根本不存在。MiniMax 开启档本就无需参数 + assert got == {} def test_rule5_normal_path(self): cap = get_capability("MiniMax-M3") @@ -177,7 +189,7 @@ class TestResolveThinking: """第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。""" cap = ThinkingCapability((Effort.AUTO,), "构造") with pytest.raises(ValueError, match="register_provider"): - resolve_thinking(get_provider("openai"), cap, False, model="whatever") + resolve_thinking(_MYSTERY, cap, False, model="whatever") class TestReconcileThinking: