Files
PolyGateway/src/polygateway/thinking.py
T
iomgaa 7622eb0402 refactor: give reasoning decisions their own module
providers.py had been holding two jobs: the registry of what each
provider looks like, and the decisions made from those declarations.
Adding response-side judgement would have made it the module for
everything about reasoning, so the decisions move to thinking.py and
the registry keeps only profiles and their lookup.

Moving a module breaks any deep-path import of what moved, so the six
public symbols are promoted to the package root at the same time. The
top level is this library's stated API surface; giving downstream a
stable name to import is what makes the next reorganisation harmless.
observe_thinking stays unexported — downstream reads the verdict off
LLMResponse, and exporting it would be a permanent promise for nothing.
2026-08-25 23:48:45 -04:00

176 lines
7.9 KiB
Python

"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。
与 `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 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
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),
)