"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。 与 `providers.py` 的分工: 那里是**注册表**(provider 长什么样,静态声明的存放 与查找),这里是**决策**(拿声明和响应做判断)。P7"决策逻辑与状态存储分离"。 本模块**不定义** `ThinkingObservation` —— 它是 `LLMResponse` 的字段类型,归最 内层 `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 EFFORT_ORDER, Effort, ThinkingObservation def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation: """由多信号裁定推理是否发生;判据按**证据硬度**排序(issue #16/#17)。 推理正文是事实本身,`reasoning_tokens` 是对事实的转述——转述缺失时事实仍然 作数。2026-08-25 实测: MiniMax 这一路已不再返回 `usage.completion_tokens_details`,而同一次调用里库拿得到 185 字符推理正文; 只认 token 数的判据会把这种情形误判成"没推理"。 正文判据取 `strip()` 而非 truthy: 网关响应是外部输入,纯空白串不是证据(P5)。 判不出来时返回 `UNKNOWN` 而非 `ABSENT`——**不许把"没看见"说成"没发生"**。 """ if thinking.strip(): return ThinkingObservation.OBSERVED # 负数与 None 同档: `ABSENT` 是"上游明确上报未推理"这个最强的正面结论,坏 # 数据给不出它。当前 transport 已在边界把负数归 None,这里仍要自己闭合——本 # 函数对外承诺"外部输入校验后使用",第二个 transport 直接填该值时,漏判会 # 给出一个方向相反的强结论(P5) if reasoning_tokens is None or reasoning_tokens < 0: 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: """某个**具体模型**支持哪些推理档位(设计 §3.2);登记必须附证据与日期。 与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变, 数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者 合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的 M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。 **档位清单而非布尔**(2026-09-04): 旧版是 `can_disable: bool`,表达不了 "关不掉但能调到最低档"这第三种情况——而 GLM-5.3 系与 Gemini 3 Pro 都是它。 现在"能不能关"就是 `Effort.NONE` 在不在清单里,是派生量而非独立字段;三个 派生量一律不存字段,存了必与清单漂移。 `evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。 文档推定与实测必须在 evidence 里说清楚是哪种——前者会被 new-api 中转改写 (LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档)。 """ supported_efforts: tuple[Effort, ...] evidence: str def __post_init__(self) -> None: """构造期校验: 空清单与重复档都是登记错误,不能等到请求期才炸。""" if not self.supported_efforts: raise ValueError("supported_efforts 至少要有一档: 空清单表达不了任何能力") if len(set(self.supported_efforts)) != len(self.supported_efforts): raise ValueError(f"supported_efforts 有重复档: {self.supported_efforts}") @property def can_disable(self) -> bool: """能否关闭推理 = `none` 在不在清单里(旧 `can_disable` 字段的等价物)。""" return Effort.NONE in self.supported_efforts @property def cheapest_effort(self) -> Effort | None: """除 `none` 外最省的一档;关不掉时作为**可执行替代**推荐给调用方。 `AUTO` 参与候选(纯开关型模型只有它可推荐),但因不在 `EFFORT_ORDER` 中, 仅当没有任何强度档时才被选中。全清单只有 `none` 时返回 None——那种模型 没有"最省的开启档"可言。 """ tiers = [e for e in EFFORT_ORDER if e is not Effort.NONE and e in self.supported_efforts] if tiers: return tiers[0] return Effort.AUTO if Effort.AUTO in self.supported_efforts else None @property def is_tiered(self) -> bool: """是否档位型(除 `none`/`auto` 外仍有强度档)。 用途是**告警文案**: 对纯开关型模型说"可选档位: ..."是错的,它没有档位。 """ return any(e not in (Effort.NONE, Effort.AUTO) for e in self.supported_efforts) # 证据分两类,evidence 里必须自报家门: # 实测 = 经 new-api 中转打过真实请求(最硬,不得被文档推定覆盖); # 文档推定 = 官方文档 / OpenRouter / cherry-studio / LiteLLM 四方交叉(待实测校正)。 _MEASURED = "2026-08-02 经 new-api 中转实测" _DOC = "2026-09-04 文档推定(官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉),待经 new-api 实测" DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType( { # —— 实测条目(2026-08-02/08-25),证据原文保留 —— "MiniMax-M3": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=( "2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变;" "2026-08-25 复测依然成立(prompt 194 = 基线、completion 3、无推理正文)。" "两条限制(findings 2026-08-25-thinking-observability-regression §3.1/§5): " "① 非流式路径观测不到推理信号——推理已计费,但正文与 usage 明细都不回传;" "② enable_thinking / thinking:{type:enabled} 对本模型无效,仅 reasoning_effort 是真开关。" "无强度档: 官方只有开/关两态(thinking.type disabled/adaptive)" ), ), "MiniMax-M2.7": ThinkingCapability( supported_efforts=(Effort.AUTO,), evidence=( "2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} " "各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段。" "MiniMax 官方亦承认 M2.x 接受 disabled 但推理仍开着" ), ), "MiniMax-M2.5": ThinkingCapability( supported_efforts=(Effort.AUTO,), evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理", ), "qwen3.7-plus": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=( "2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)。" "无强度档: OpenRouter 登记本型号只支持 reasoning 开关,不支持 reasoning_effort" ), ), "deepseek-v4-pro": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), evidence=( "关闭档为 2026-08-02 实测(thinking:{type:disabled},completion 3 token,无推理);" f"强度档为{_DOC}: 官方 thinking_mode 文档列 Non-think/Think High/Think Max 三态,默认 high" ), ), # —— 文档推定条目(2026-09-04),待 T10 经 new-api 实测校正 —— "deepseek-v4-flash": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), evidence=f"{_DOC}: 官方文档「deepseek-v4-flash 与 deepseek-v4-pro 一致」,默认 high", ), "deepseek-v4-flash-vision-exp": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), evidence=f"{_DOC}: 同 v4-flash 一档(OpenRouter 登记支持 reasoning_effort)", ), "glm-5.3": ThinkingCapability( supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX), evidence=( f"{_DOC}: **推理不可关闭**——智谱官方文档明确 thinking.type 只接受 enabled," "官方迁移建议是改用 enabled + reasoning_effort=low;cherry-studio 标 toggle:false、" "OpenRouter 标 mandatory:true,三源一致。默认 max。" "注: issue #20 实测的 reasoning_effort=none 是**未定义值**,短提示词下 rt≈1.2 像是关了," "5552 token 长上下文下跳到 0/54/167 即露馅" ), ), "glm-5.3-flash": ThinkingCapability( supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX), evidence=f"{_DOC}: 同 glm-5.3(cherry-studio 的 pattern 'glm-5[.-]3' 覆盖两者),默认 max", ), "glm-5.2": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX), evidence=( f"{_DOC}: cherry-studio 登记 none/high/max(官方端点默认 max,百炼上默认 high)。" "注意: issue #20 记录本渠道对 glm-5.2 的请求 6/6 回报 model=glm-5.3,疑被路由,实测时须核对 model_reported" ), ), "glm-5": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=f"{_DOC}: OpenRouter 登记只支持 reasoning 开关、无 reasoning_effort;cherry-studio 标 toggle:true", ), "glm-5.1": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=f"{_DOC}: 同 glm-5(OpenRouter reasoning.mandatory=false 且无 supported_efforts)", ), "glm-4.6v": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=f"{_DOC}: OpenRouter 登记无 reasoning_effort;VLM,推理控制同 glm-4.x 系开关型", ), "kimi-k3": ThinkingCapability( supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX), evidence=( f"{_DOC}: 官方 reasoning_effort 三档 low/high/max,默认 max。" "**保守登记为不可关**——官方档位表无 none,而 OpenRouter 标 mandatory:false,两源分歧待实测;" "保守方向的代价是下游配 none 会报错并被指向 low,反方向的代价是静默失效(issue #20 的病)。" "另: 官方提示切换档位会使 prefix cache 失效,不宜在会话中途改档" ), ), "gpt-5.4": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH), evidence=f"{_DOC}: OpenRouter 登记 none/low/medium/high/xhigh,默认 medium;LiteLLM 登记 minimal 不支持", ), "gpt-5.5": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH), evidence=f"{_DOC}: 同 gpt-5.4(OpenRouter supported_efforts 一致,默认 medium)", ), "claude-opus-5": ThinkingCapability( supported_efforts=( Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH, Effort.MAX, ), evidence=( f"{_DOC}: Anthropic 官方 adaptive thinking + output_config.effort 五档(low/medium/high/" "xhigh/max),默认 high;OpenRouter 标 mandatory:false 故可关。" "关闭档依赖 new-api 把 reasoning_effort=none 转成 thinking 关闭形态,待实测确认" ), ), "claude-sonnet-5": ThinkingCapability( supported_efforts=( Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH, Effort.MAX, ), evidence=f"{_DOC}: 同 claude-opus-5(OpenRouter supported_efforts 与默认档一致)", ), "gemini-3.1-pro": ThinkingCapability( supported_efforts=(Effort.LOW, Effort.MEDIUM, Effort.HIGH), evidence=( f"{_DOC}: **推理不可关闭**——Google 官方文档明确 Gemini 3 Pro / 3.1 Pro 无法关闭思考," "OpenRouter 亦标 mandatory:true。thinking_level 三档;默认档两源打架" "(官方文档说 HIGH,OpenRouter 说 medium),待实测" ), ), "qwen-plus-latest": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=f"{_DOC}: 百炼 enable_thinking 开关型(thinking_budget 是 token 预算,本库不支持预算型)", ), "qwen3.5-flash": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=f"{_DOC}: 同 qwen-plus-latest(OpenRouter 登记无 reasoning_effort)", ), "qwen3.6-plus": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=f"{_DOC}: 同 qwen-plus-latest(OpenRouter 登记无 reasoning_effort)", ), "qwen3.7-max": ThinkingCapability( supported_efforts=(Effort.NONE, Effort.AUTO), evidence=f"{_DOC}: 同 qwen3.7-plus 一代(OpenRouter 登记无 reasoning_effort)", ), } ) """在用模型的推理能力登记(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 reconcile_thinking( *, enable_thinking: bool | None, observation: ThinkingObservation, capability: ThinkingCapability | None, model: str, ) -> str | None: """把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。 能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的 表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。 **只判定、不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容, 而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。 **不抛错**: 一次观测不足以否决一次成功的调用;可观测性属遥测方向,降级即 warning(P5 的"报错而非放行"只约束限流/熔断)。矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。 """ # Phase 1: 调用方不表态 —— 没提要求就无从谈"违背" if enable_thinking is None: return None # Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警 # 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警 if enable_thinking is False: if observation is not ThinkingObservation.OBSERVED: return None return _off_but_observed(model, capability) # Phase 3: 要求开启 —— ABSENT 是正面证伪,UNKNOWN 是"看不见",两者文案不可混 if observation is ThinkingObservation.ABSENT: return ( f"模型 {model!r} 的 enable_thinking=True 未生效: 已注入开启参数," f"上游却明确上报本次未推理(reasoning_tokens=0)" ) if observation is ThinkingObservation.UNKNOWN: return ( f"模型 {model!r} 的 enable_thinking=True 无法确认是否生效: 已注入开启参数," f"但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。" f"若走的是非流式路径,推理内容可能已计费却不回传" ) return None def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str: """关闭请求未被满足的两种说法;登记与否决定该说哪一句。 两者必须分开: `resolve_thinking` 对未登记模型的告警是**事前猜测**,这里是 **事后实证**。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。 """ if capability is None: return ( f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生," f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。" f"请实测后用 register_capability 登记其真实能力" ) return ( f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生," f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。" f"能力表可能已过期——请复测后用 register_capability 更新登记" )