7622eb0402
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.
176 lines
7.9 KiB
Python
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),
|
|
)
|