48805cb9fb
The verifier caught that the disable-direction evidence only proved "no regression", not "actually took effect": on M3 the disabled runs and the no-opinion baseline are identically distributed, because that model does not reason by default anyway. So the disable runs alone cannot rule out the very failure mode issue #5 is about -- the parameter being silently dropped upstream. The bogus-value experiment that does rule it out was sitting in the findings document instead of the test suite; it is now case L3b, and the L3 assertion that could never fail is gone. Also from the review: the e2e helper caught bare Exception, which would have disguised a library bug as an unavailable source, exactly the silence the reporting discipline exists to prevent; the unregistered model warning fired on every request instead of once per source; and the transport caught ValueError broadly enough to mislabel unrelated errors, now narrowed to a dedicated ThinkingUnsupportedError. The design and plan still described the original judgement criteria, which the measurements had already overturned. Both now match what the tests actually do, and the design no longer claims the only new failure surface is the openai one -- dissect configures MiniMax-M2.7 with ENABLE_THINKING=false and will fail at assembly, which has to be coordinated before this merges.
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""provider 注册表(D11): 消灭 `"qwen" in provider` 式字符串猜测。
|
|
|
|
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
|
|
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
|
|
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
|
|
"""
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from types import MappingProxyType
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderProfile:
|
|
"""单个 provider 的能力与差异声明。
|
|
|
|
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
|
|
True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不
|
|
注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ``<think>``
|
|
标签(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`。
|
|
"""
|
|
|
|
name: str
|
|
thinking_on: Mapping[str, Any] | None
|
|
thinking_off: Mapping[str, Any] | None
|
|
strip_think_tags: bool
|
|
supports_native_schema: bool = False
|
|
|
|
|
|
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},
|
|
strip_think_tags=True,
|
|
),
|
|
"deepseek": ProviderProfile(
|
|
name="deepseek",
|
|
thinking_on={"thinking": {"type": "enabled"}},
|
|
thinking_off={"thinking": {"type": "disabled"}},
|
|
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,
|
|
strip_think_tags=False,
|
|
),
|
|
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
|
|
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被
|
|
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。
|
|
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
|
|
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
|
|
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
|
|
# 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段)
|
|
"minimax": ProviderProfile(
|
|
name="minimax",
|
|
thinking_on={"reasoning_effort": "medium"},
|
|
thinking_off={"reasoning_effort": "none"},
|
|
strip_think_tags=False,
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
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),
|
|
)
|
|
|
|
|
|
def get_provider(
|
|
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
|
) -> ProviderProfile:
|
|
"""按名字精确查找 profile;未注册直接报错(严禁默认值掩盖配置错误)。"""
|
|
table = DEFAULT_PROFILES if registry is None else registry
|
|
profile = table.get(name)
|
|
if profile is None:
|
|
raise ValueError(
|
|
f"未注册的 provider: {name!r}(已注册: {sorted(table)});"
|
|
f"新 provider 用 register_provider(ProviderProfile(...)) 注册后经 registry 参数传入"
|
|
)
|
|
return profile
|
|
|
|
|
|
def register_provider(
|
|
profile: ProviderProfile, *, base: Mapping[str, ProviderProfile] | None = None
|
|
) -> dict[str, ProviderProfile]:
|
|
"""纯函数注册: 返回 base(缺省 DEFAULT_PROFILES)+ 新条目的新表,同名覆盖。"""
|
|
table = dict(DEFAULT_PROFILES if base is None else base)
|
|
table[profile.name] = profile
|
|
return table
|