diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index fb1c1ad..b636a75 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -31,6 +31,7 @@ from polygateway.providers import ( from polygateway.telemetry.schema import telemetry_schema_sql from polygateway.thinking import ( ThinkingCapability, + ThinkingResolution, ThinkingUnsupportedError, get_capability, register_capability, @@ -82,6 +83,7 @@ __all__ = [ "TelemetryStatus", "ThinkingCapability", "ThinkingObservation", + "ThinkingResolution", "ThinkingUnsupportedError", "ThinkingWire", "TransientError", diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 3a53b5a..8e1c88b 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -38,6 +38,7 @@ from polygateway.thinking import get_capability, resolve_thinking from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.types import ( ChatRequest, + Effort, LLMResponse, TelemetryStatus, validate_caller_dimensions, @@ -80,10 +81,15 @@ def _guard_thinking( 就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。 """ for source, profile in zip(sources, profiles, strict=True): + # `enable_thinking` 的档位语法糖(True → auto,False → none,None 不表态); + # 两个调用点各自就地转换是过渡形态,T5 起由 thinking.effective_effort() + # 统一收口并接上源级/请求级档位(设计 §4.2) + enabled = source.enable_thinking + effort = None if enabled is None else (Effort.AUTO if enabled else Effort.NONE) resolve_thinking( profile, get_capability(source.model, table=capabilities), - source.enable_thinking, + effort, model=source.model, ) diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 2cdac4e..231e350 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -291,62 +291,234 @@ def register_capability( return table +@dataclass(frozen=True) +class ThinkingResolution: + """请求体注入片段 + 本次**实际**生效的档位(设计 §4.1)。 + + 返回 dataclass 而非裸 Mapping,是因为 `nearest` 映射后"请求的档"与"真正发出 + 去的档"会分叉(请求 `medium`、模型只有 low/high/max → 实际发 `low`)。遥测必 + 须记后者: 记请求档会让按档位分组的压测把整行数据挂在一个从未真正发出过的档 + 下,而那种数据错得看不出来。 + + `applied_effort is None` 只出现在 Phase 1(调用方不表态): 库既不注入,也不 + 去推定模型自己的默认档——"没看见"不许说成"发生了"。 + """ + + payload: Mapping[str, Any] + applied_effort: Effort | None + + def resolve_thinking( profile: ProviderProfile, capability: ThinkingCapability | None, - enable_thinking: bool | None, + effort: Effort | None, *, model: str, + fallback: str = "error", warn_unregistered: bool = True, -) -> Mapping[str, Any]: - """三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。 +) -> ThinkingResolution: + """档位 + 两层声明(形态/能力)→ 注入片段;不可满足时 `ThinkingUnsupportedError`。 调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为 - `RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时 - 无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有 - `can_disable` 可读,故 Phase 3 必须先于 Phase 4。 + `RequestRejectedError`(四分类之一)。**判定顺序即语义,不可调换**: + + ========== ================================================================ + Phase 1 不表态 → 不注入。与 `Effort.NONE` 严格区分: 前者是"随模型默认", + 后者是"要求不推理" + Phase 2 形态未知 → 报错。无从注入时,模型能力如何都无关紧要,故必须先于 + 4/5 + Phase 3 能力未登记 → 尽力注入且**不校验档位**。没有清单可比对,拿空清单 + 去拒绝档位就是凭空报错;新模型上线不该被库挡住(设计 §5 R4) + Phase 4 请求 `none` 而模型关不掉 → 报错并给出 `cheapest_effort` + Phase 5 其余档位打空 → 报错(或按 `fallback` 映射) + ========== ================================================================ + + **4 必须先于 5**: `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就 + 退化成"不支持 none,可选 low/high/max"——丢掉"这个模型根本关不掉"这个关键 + 信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是 + issue #20 的成因。 + + **`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是 + "不写 `effort_key`",而不是写进 `effort_key` 的某个取值,故 Phase 5 放行它。 + 反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`)在 deepseek-v4 + 与 glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量 + 配置继续可跑——那里唯一允许新报错的是"关闭一个官方不可关的模型"。 `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 `capability` 为 None(未登记)时无从从别处取得模型名。 - `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次 - 调用再喊只会刷屏。判定结果不受此参数影响。 + `fallback="nearest"` 是 Phase 5 的逃生口,**默认关闭的理由是钱**: 一次静默的 + `medium → max` 在 GLM-5.3 上是数倍账单(P5"严禁默认值掩盖错误")。 + + `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次调用 + 再喊只会刷屏。判定结果不受此参数影响。 """ - # Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档 - if enable_thinking is None: - return {} + # Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档 + if effort is None: + return ThinkingResolution({}, None) 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: + # Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方 + if wire.on_base is None: raise ThinkingUnsupportedError( - f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): " - f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态," - f"或改用 SourceConfig.extra_body 直接下发供应商参数" + f"provider {profile.name!r} 的推理形态未知(模型 {model!r},请求档位 " + f"{effort.value!r}): 本库不知道该 provider 如何表达推理。请用 " + f"register_provider 注册形态,或改用 SourceConfig.extra_body 直接下发供应商参数" ) # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 if capability is None: + payload = _inject(profile, wire.on_base, effort, model=model) if warn_unregistered: - _warn_unregistered(model, profile, slot) - return slot - # Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说 - if enable_thinking is False and not capability.can_disable: + _warn_unregistered(model, profile, effort, payload) + return ThinkingResolution(payload, effort) + # Phase 4: 明确关不掉 —— 调用方要的是"不推理"的语义保证,给不了必须说,且必须 + # 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由) + if effort is Effort.NONE and not capability.can_disable: + raise ThinkingUnsupportedError(_cannot_disable(model, capability)) + # Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring) + applied = _settle_tier(effort, capability, model=model, fallback=fallback) + return ThinkingResolution(_inject(profile, wire.on_base, applied, model=model), applied) + + +def _settle_tier( + effort: Effort, capability: ThinkingCapability, *, model: str, fallback: str +) -> Effort: + """Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。 + + `auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key" + (理由见 `resolve_thinking` 的 docstring)。 + """ + if effort is Effort.AUTO or effort in capability.supported_efforts: + return effort + mapped = _nearest_effort(effort, capability) if fallback == "nearest" else None + if mapped is None: raise ThinkingUnsupportedError( - f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: " - f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——" - f"需要关闭思维链请换用支持关闭的模型" + _tier_unsupported(model, effort, capability, fallback=fallback) ) - return slot - - -def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None: logger.warning( - "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" + "模型 {} 不支持 reasoning_effort={},按 effort_fallback=nearest 改用最近的 {} 档;" + "本次真正发出去的是后者,遥测与缓存 key 记的也是后者", + model, + effort.value, + mapped.value, + ) + return mapped + + +def _inject( + profile: ProviderProfile, on_base: Mapping[str, Any], effort: Effort, *, model: str +) -> Mapping[str, Any]: + """按 wire 把档位写成请求体片段;wire 表达不了这一档时报错。 + + `on_base` 由调用方传入而非在此重读,是为了让"Phase 2 已保证它不是 None"这条 + 前提在签名上看得见。 + + 三种 `None` 的语义在此**各自兑现**(ThinkingWire 的 docstring 定义了它们): + `off is None` = 该 provider 关不掉,`effort_key is None` = 它只有开关没有档位。 + 两者都不是"形态未知",故都不指向 `register_provider`——指错了排查方向比不指 + 还糟。 + """ + wire = profile.thinking + if effort is Effort.NONE: + if wire.off is None: + raise ThinkingUnsupportedError( + f"provider {profile.name!r} 没有关闭形态(模型 {model!r}): " + f"本库知道它如何表达开启,但该 provider 没有可用的关闭参数。" + f"需要不推理请换用支持关闭的 provider 或模型" + ) + return wire.off + if effort is Effort.AUTO: + # auto = 开启但不指定强度: 逐字节等于升级前的 `thinking_on` + return on_base + if wire.effort_key is None: + raise ThinkingUnsupportedError( + f"provider {profile.name!r} 只有推理开关、没有档位键(模型 {model!r})," + f"表达不了 reasoning_effort={effort.value!r}: 请改用 auto/none 两档," + f"或用 register_provider 给该 provider 注册 effort_key" + ) + return {**on_base, wire.effort_key: effort.value} + + +def _cannot_disable(model: str, capability: ThinkingCapability) -> str: + """Phase 4 的文案: 报错必须带一条能立刻照做的替代,否则等于把用户推回起点。 + + 只报"关不掉"而不给出路,下游就会去找 `extra_body` 那条绕过库的路——issue #20 + 的成因正是如此。故文案必须含 `cheapest_effort` 的值与 env 键名两样东西。 + """ + # Phase 4 只在 none 不在清单里时触发,而清单构造期保证非空,故必有一档可推荐 + alternative = capability.cheapest_effort + assert alternative is not None + return ( + f"模型 {model!r} 无法关闭推理,reasoning_effort='none' 无法满足: " + f"{capability.evidence}。最省的开启档是 {alternative.value!r}——请配 " + f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={alternative.value}," + f"或调用时传 reasoning_effort=Effort.{alternative.name};" + f"真正需要不推理请换用支持关闭的模型" + ) + + +def _tier_unsupported( + model: str, effort: Effort, capability: ThinkingCapability, *, fallback: str +) -> str: + """Phase 5 的文案: 按 `is_tiered` 分叉,纯开关型模型不能被告知"可选档位"。 + + 它没有档位——对它说"可选档位: none, auto"是把开关说成了强度轴,下游照着找 + 档位只会一无所获(设计 §3.2 第三个派生量的用途就是这一句话该怎么说)。 + """ + listed = ", ".join(e.value for e in _ordered(capability.supported_efforts)) + head = f"模型 {model!r} 不支持 reasoning_effort={effort.value!r}: {capability.evidence}。" + body = ( + f"该模型的可选档位: {listed}" + if capability.is_tiered + else f"该模型只有开关、没有强度档位,可用: {listed}" + ) + # 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话 + hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest" + return f"{head}{body}{hint}" + + +def _ordered(efforts: tuple[Effort, ...]) -> list[Effort]: + """按由弱到强列出档位;`auto` 不在强弱轴上,排在末尾。""" + ordered = [e for e in EFFORT_ORDER if e in efforts] + if Effort.AUTO in efforts: + ordered.append(Effort.AUTO) + return ordered + + +def _nearest_effort(requested: Effort, capability: ThinkingCapability) -> Effort | None: + """取距 `requested` 位序最近的**开启档**;等距取弱侧,无开启档时返回 None。 + + 候选**剔除 `none`**: 把"想得浅一点"映射成"别想了"是方向反转而非省钱,正是 + issue #20 那种静默失效的翻版。`none` 的领域归 Phase 4,它在那里已经被处理过, + 走不到这里(能关就不会打空,不能关就已经报错)。 + + `auto` 不在强弱轴上(`EFFORT_ORDER` 不含它),故不参与距离计算,只在一个强度 + 档都没有时兜底——它恰好是纯开关型模型唯一能表达"开"的档。 + + **等距取弱**的理由是钱: 一次静默的 `medium → max` 在 GLM-5.3 上是数倍账单, + 库不替下游涨价。 + """ + candidates = [ + e for e in EFFORT_ORDER if e is not Effort.NONE and e in capability.supported_efforts + ] + if not candidates: + return Effort.AUTO if Effort.AUTO in capability.supported_efforts else None + target = EFFORT_ORDER.index(requested) + # 排序键第二位是位序本身: 距离相同时位序小的(更省的)胜出 + return min( + candidates, key=lambda e: (abs(EFFORT_ORDER.index(e) - target), EFFORT_ORDER.index(e)) + ) + + +def _warn_unregistered( + model: str, profile: ProviderProfile, effort: Effort, payload: Mapping[str, Any] +) -> None: + logger.warning( + "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});" "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", model, profile.name, - dict(slot), + dict(payload), + effort.value, ) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 01c879c..a86f7af 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -34,7 +34,12 @@ from polygateway.thinking import ( resolve_thinking, ) from polygateway.transports._http_errors import compose_message, summarize_body -from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult +from polygateway.types import ( + Effort, + EmbeddingTransportResult, + SourceConfig, + TransportResult, +) if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Mapping @@ -355,14 +360,19 @@ class OpenAICompatTransport: capability = get_capability(source.model, table=self._capabilities) first_time = source.model not in self._warned_models self._warned_models.add(source.model) + # `enable_thinking` 的档位语法糖(True → auto,False → none,None 不表态); + # 就地转换是过渡形态,T5 起由 thinking.effective_effort() 统一收口并接上 + # 源级/请求级档位(设计 §4.2) + enabled = source.enable_thinking + effort = None if enabled is None else (Effort.AUTO if enabled else Effort.NONE) payload.update( resolve_thinking( profile, capability, - source.enable_thinking, + effort, model=source.model, warn_unregistered=first_time, - ) + ).payload ) # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 # overlay(含结构化注入)在后覆盖之。两行不可调换 diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 52866a4..c927049 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -60,11 +60,13 @@ def test_thinking_public_surface_exported(): `observe_thinking` / `reconcile_thinking` **不**导出: 它们是 transport 内部 的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份 - 永久承诺。 + 永久承诺。`ThinkingResolution` 则**要**导出——它是已导出的 `resolve_thinking` + 的返回类型,不导出等于下游拿得到实例却写不出类型标注。 """ for name in ( "ThinkingCapability", "ThinkingObservation", + "ThinkingResolution", "ThinkingUnsupportedError", "ThinkingWire", "get_capability", diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 500b570..cbaf5ee 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -12,6 +12,7 @@ from polygateway.providers import ProviderProfile, ThinkingWire, get_provider from polygateway.thinking import ( DEFAULT_CAPABILITIES, ThinkingCapability, + ThinkingUnsupportedError, get_capability, observe_thinking, reconcile_thinking, @@ -145,51 +146,266 @@ class TestThinkingCapability: class TestResolveThinking: - """五条判定规则(顺序即语义);设计 §5 真值表。""" + """五道关卡(顺序即语义)与 nearest 映射;设计 §4.1。 - def test_rule1_none_injects_nothing(self): + 每一关都有独立的失败模式,漏测哪一关,判定顺序被调换都不会被抓住——而顺序 + 在本函数里**就是**语义(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 == {} + assert got.payload == {} + assert got.applied_effort is None - @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(_MYSTERY, None, enable, model="kimi-k3") - assert "extra_body" in str(exc.value) + # —— Phase 2: 形态未知 —— - def test_rule3_unregistered_model_warns_but_passes(self): + @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_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, False, model="MiniMax-M9") + got = resolve_thinking(get_provider("minimax"), None, Effort.NONE, model="MiniMax-M9") finally: logger.remove(sink_id) - assert got == {"reasoning_effort": "none"} + 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_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_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_rule4_only_blocks_the_off_direction(self): + 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 默认就在推理,开的方向不该被拦。""" cap = get_capability("MiniMax-M2.7") - got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7") - # 2026-09-04: "开"不再硬编码 medium——那是替下游做的档位判断,且 medium 在 - # GLM/kimi/deepseek 的档位表里根本不存在。MiniMax 开启档本就无需参数 - assert got == {} + got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7") + assert got.payload == {} + assert got.applied_effort is Effort.AUTO - def test_rule5_normal_path(self): + def test_phase4_passes_when_none_is_registered(self): cap = get_capability("MiniMax-M3") - assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == { - "reasoning_effort": "none" - } + 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 - def test_unknown_shape_beats_capability_check(self): - """第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。""" - cap = ThinkingCapability((Effort.AUTO,), "构造") - with pytest.raises(ValueError, match="register_provider"): - resolve_thinking(_MYSTERY, cap, False, model="whatever") + # —— 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 第三个派生量)。""" + cap = get_capability("MiniMax-M3") # (none, auto): 能开能关,但没有强度档 + with pytest.raises(ThinkingUnsupportedError) as exc: + resolve_thinking(get_provider("minimax"), cap, Effort.HIGH, model="MiniMax-M3") + 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("minimax"), + get_capability("MiniMax-M3"), + Effort.MEDIUM, + model="MiniMax-M3", + ) + 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("MiniMax-M3") # (none, auto) + got = resolve_thinking( + get_provider("minimax"), cap, Effort.HIGH, model="MiniMax-M3", fallback="nearest" + ) + assert got.applied_effort is Effort.AUTO + assert got.payload == {} + + 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), "形态已知,不该指向注册" class TestReconcileThinking: