Files
PolyGateway/src/polygateway/thinking.py
T
iomgaa 85892fb1b5 fix: stop telling people the cache key records the mapped tier
It records the asked-for one. CacheMW sits outside the transport in the
onion, so at lookup time the nearest-mapping has not happened yet and the
applied tier does not exist. Telemetry's success rows do record the mapped
tier, which is where the confusion came from — the warning conflated the
two and would have sent anyone debugging a cache miss the wrong way.

Also repairs the design doc: the 2026-09-05 rollback note had been spliced
into the equivalence table, orphaning its last row, and §3.1 still said
seven tiers after `auto` made it eight.
2026-09-05 11:30:52 -04:00

760 lines
41 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, ThinkingWire
from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation, coerce_effort
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 四方交叉;
# 实测未覆盖 = T10 试过但拿不到数据(渠道限额/上游报错/被路由到别的模型),
# 此时**必须写明原因**——"没测到"与"测了没问题"是两回事,T9 之类的下游
# 文档任务不得把前者写成后者。
_MEASURED = "2026-08-02 经 new-api 中转实测"
_T10 = "2026-09-05 经 new-api 中转实测(T10: 短提示词 N=5,声称可关的再加长上下文 N=3 复核)"
_DOC = "2026-09-04 文档推定(官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉)"
# T10 的三条判据(报告见 tests/outputs/thinking/,用例见 tests/e2e/test_thinking_live.py):
# ① 关闭方向要求**每轮**未观测到推理,任一轮观测到即证伪;
# ② 短提示词下的"关掉了"必须过长上下文复核——glm-5.3-flash 正是短提示词 5/5
# 未观测到推理、5000 token 长上下文下 2/3 轮露馅(issue #20 的原始现象);
# ③ 上游整片不回传推理信号(kimi/MiniMax/qwen/gpt 这几路的关闭档都是)时,
# "没看见"不算"没发生",另取一个无魔数锚点: 关闭档的 completion_tokens
# 必须严格小于 max 档。
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
{
# —— MiniMax ——
"MiniMax-M3": ThinkingCapability(
supported_efforts=(
Effort.NONE,
Effort.MINIMAL,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=(
f"{_T10}: reasoning_effort=none 关闭成立(短 5/5 + 长上下文 3/3 未观测到推理,"
"completion 恒 3 token,且与 max 档 completion 57-173 锚点可分);六个强度值各 N=5 "
"全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / "
"high 62-128 / xhigh 58-118 / max 57-170)——**它们是'开'的六种写法,不是六个深度档**,"
"MiniMax 官方只有开/关两态,配哪一个都一样贵。"
"**`auto` 已从清单移除**: 实测当时 minimax 的「开」在 wire 上是 on_base={}"
"(什么参数都不注入),而 M3 的默认档实测不推理,故 auto 在这条路上表达不了「开」"
"(N=5 全部未观测到推理)。`resolve_thinking` 的 Phase 5 无条件放行 auto,能力表"
"堵不住这条,故 2026-09-05 由 wire 侧兜住: on_base 改回 {'reasoning_effort': 'medium'},"
"存量 ENABLE_THINKING=true 恢复真开推理(权宜之计,正解见 issue #21)。"
"本清单仍不含 auto——它记的是实测结论,不随 wire 的权宜之计变动。"
f"历史: {_MEASURED} N=10 同样成立;enable_thinking / thinking:{{type}} 两种写法对本模型"
"无效,reasoning_effort 才是真开关(findings 2026-08-25 §3.1/§5)。"
"另: 2026-08-25 记录的'MiniMax 这一路已停报 completion_tokens_details'本次**不再成立**"
"——开启档 rt 有值,只有关闭档整片缺 details"
),
),
"MiniMax-M2.7": ThinkingCapability(
supported_efforts=(Effort.AUTO,),
evidence=(
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 100-161、推理正文 274-482 字符),"
"**关不掉**成立;auto 档 5/5 观测到推理。"
f"历史({_MEASURED}): reasoning_effort=none / thinking:{{disabled}} / thinking:{{adaptive}} "
"各 N=3 全部无效;OpenRouter 登记 mandatory:true,models.dev 登记无控制手段"
),
),
"MiniMax-M2.5": ThinkingCapability(
supported_efforts=(Effort.AUTO,),
evidence=(
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 104-158),**关不掉**成立;"
"auto 档 5/5 观测到推理(rt 121-245)。"
f"历史({_MEASURED}): 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理"
),
),
# —— qwen(百炼系,开关型) ——
"qwen3.7-plus": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,且与 auto 档 completion 锚点可分);"
"auto 档 5/5 观测到推理。无强度档: 该 provider 的 wire 没有 effort_key,请求 max 当场被库"
"拒(百炼靠 thinking_budget 调深度,预算型控制本库不支持)。"
f"历史({_MEASURED}): enable_thinking=false 关闭(completion 5 token)"
),
),
"qwen3.7-max": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: 同 qwen3.7-plus——none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen3.6-plus": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen3.5-flash": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen-plus-latest": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
# —— deepseek ——
"deepseek-v4-pro": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,与 max 档锚点可分);"
"high / max 各 N=5 全部观测到推理(rt 59-73 / 56-69,推理正文 max 档明显更长: "
"135-186 vs 101-124 字符)。默认档按官方 thinking_mode 文档为 high"
),
),
"deepseek-v4-flash": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
"(rt 16-36 / 12-42)。与 v4-pro 同档,印证官方'与 deepseek-v4-pro 一致'的说法"
),
),
"deepseek-v4-flash-vision-exp": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
"(rt 12-17 / 17-34)"
),
),
# —— 智谱 ——
"glm-5.3": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **推理不可关闭已实测坐实**——请求 none(注入 thinking:{{type:disabled}})后 "
"5 轮里 4 轮仍观测到推理(rt 7、推理正文 12 字符),只有 1 轮 rt=0;"
"low / high / max 各 N=5 全部观测到推理(rt 55-77 / 47-63 / 48-60,三档分不出深浅)。"
"这一条了结了 issue #20 的核心争议: 当时短提示词下 rt≈1.2 看着像关掉了,实为采样噪声。"
f"文档侧三源一致({_DOC}): 智谱官方 thinking.type 只接受 enabled、迁移建议改用 "
"enabled + reasoning_effort=low,cherry-studio 标 toggle:false,OpenRouter 标 mandatory:true。"
"默认 max。**注意本渠道不校验档位值**: 未登记的 medium 也会被照单接受(实测 rt 62),"
"故'网关没报错'在这一路上不构成'该档受支持'的证据"
),
),
"glm-5.3-flash": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **推理不可关闭,且是判据②唯一的现役样本**——请求 none 时短提示词 5/5 轮"
"未观测到推理(看着完全像关掉了),换成 5000 token 长上下文后 3 轮里 2 轮露馅"
"(rt=2、有推理正文)。只跑短提示词的实测会在这个模型上得出相反结论。"
"low / high / max 各 N=5 全部观测到推理(rt 8-60 / 27-91 / 27-70)。默认 max"
),
),
"glm-5.2": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_DOC}: cherry-studio 登记 none/high/max(官方端点默认 max,百炼上默认 high)。"
"**T10 实测未覆盖——该渠道把本型号路由到了别的模型**: 请求 glm-5.2 时 5/5 轮回报 "
"model=glm-5.3(issue #20 记录的 6/6 复现),拿到的行为不属于本型号,故整组数据作废、"
"本行仍是文档推定。**下游风险**: 在本渠道上给 glm-5.2 配 none,库会照本行放行,"
"而真正服务请求的 glm-5.3 关不掉推理——运行期 reconcile 会喊,但那是事后"
),
),
"glm-5": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_DOC}: OpenRouter 登记只支持 reasoning 开关、无 reasoning_effort;cherry-studio 标 toggle:true。"
"**T10 实测未覆盖**: 与 glm-5.2 同因——5/5 轮回报 model=glm-5.3,数据不属于本型号"
),
),
"glm-5.1": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_DOC}: 同 glm-5(OpenRouter reasoning.mandatory=false 且无 supported_efforts)。"
"**T10 实测未覆盖**: 5/5 轮回报 model=glm-5.3,数据不属于本型号"
),
),
"glm-4.6v": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_T10}: none 关闭成立,且是全表**证据最硬**的一条——短 5/5 + 长 3/3 全部裁定 ABSENT"
"(上游明确上报 reasoning_tokens=0,不是'看不见'),无需锚点旁证;"
"auto 档 5/5 观测到推理(rt 57-153)。model_reported 与请求一致,未被路由"
),
),
# —— 月之暗面 ——
"kimi-k3": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **可关闭——推翻 T1 的保守登记**。请求 none(注入 thinking:{{type:disabled}})后"
"短 5/5 + 长上下文 3/3 轮无任何推理信号,completion 恒 9 token;同一模型 max 档 "
"completion 明显更大且带推理正文(rt 33-146),锚点可分——故'没看见'这次有正面证据支撑。"
"两源分歧由此了结: OpenRouter 的 mandatory:false 是对的,官方档位表没列 none 只是没列。"
"low / high / max 各 N=5 全部观测到推理(rt 21-53 / 38-60 / 33-146)。"
"**model_reported 是 `k3`**(别名,非串台)。官方提示切换档位会使 prefix cache 失效,"
"不宜在会话中途改档"
),
),
"kimi-for-coding": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: 本型号在 T1 时因'档位清单无直接证据'走 Phase 3 不登记(设计 §8 第三档),"
"现有它自己的实测证据故补登。none: 短 5/5 + 长 3/3 无推理信号、completion 恒 2 token,"
"与开启档锚点可分;low / high / max 各 N=3 全部观测到推理(rt 8-40 / 25-75 / 62-85)。"
"档位词汇沿用月之暗面官方的 low/high/max: 本渠道对 moonshot **不校验档位值**"
"(minimal/medium/xhigh 照样返回 200 并推理),故'没被拒'不构成'受支持',"
"登记一个厂商没声明的档等于替它做承诺"
),
),
# —— OpenAI ——
"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 不支持。"
"**T10 实测未覆盖**: 该渠道本型号所有账号限流(429 All available accounts are "
"currently rate-limited),5/5 轮失败。同代的 gpt-5.5 已实测且与本清单逐字相符"
),
),
"gpt-5.5": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH),
evidence=(
f"{_T10}: 清单**逐条对上**,是全表验证最完整的一行。none 关闭成立(短 5/5 + 长 3/3,"
"锚点可分);low/medium/high/xhigh 各 N=5 全部观测到推理,且 rt 随档位单调上升"
"(18-21 / 18-22 / 22-34 / 35-65)——本渠道上少见的、档位真的分得开的模型;"
"清单外的 max 与 minimal 各 N=3 全部被上游 400 拒("
"Unsupported value),说明这一路**会校验档位值**,与 zhipu/moonshot 的照单全收相反"
),
),
# —— Anthropic ——
"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 故可关。"
"**T10 实测未覆盖**: 该渠道 claude 全系返回 429「api key 7天限额已用完」,5/5 轮失败。"
"关闭档仍依赖 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 与默认档一致)。"
"**T10 实测未覆盖**: 同因 429「api key 7天限额已用完」"
),
),
# —— Google ——
"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)。"
"**T10 实测未覆盖**: 该渠道本型号上游报错(bad_response_status_code / openai_error),"
"5/5 轮失败,连默认档基线都没取到,两源分歧仍悬着"
),
),
}
)
"""在用模型的推理能力登记(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
@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 effective_effort(
*,
request_effort: Effort | None,
source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""求本次生效的档位: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
**收口成一个纯函数**是本函数存在的全部理由: 装配守卫(`client._guard_thinking`)
与请求热路径(`openai_compat._build_payload`)必须给出**同一个**判定,两处各写
一份就地转换迟早会分叉,而分叉的形态是"装配期放行、运行期报错"——最难查的那种。
**一律用 `is None` 判有没有表态,不靠真值性**: `Effort.NONE`(要求不推理)与
`enable_thinking=False` 都是**表态**而非缺省,`x or y` 式的回落会把后者当成没配
从而跳到下一层——那正是本次要消灭的静默失效。
语法糖排在最末且 `True → AUTO`(开启但不指定强度,不依赖能力表),不是旧版那个
硬编码的 `medium`: 那是库替下游做的档位判断,而 `medium` 在 GLM/kimi/deepseek 的
档位表里根本不存在(设计 §4.2 声明过的有意变更)。
同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾,已由
`SourceConfig.__post_init__` 在构造期报错,故这里不再判——两个字段说同一件事时,
矛盾是配置错误,不是优先级问题。
"""
if request_effort is not None:
return request_effort
if source_effort is not None:
return source_effort
if enable_thinking is None:
return None
return Effort.AUTO if enable_thinking else Effort.NONE
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
effort: Effort | str | None,
*,
model: str,
fallback: str = "error",
warn_unregistered: bool = True,
) -> ThinkingResolution:
"""档位 + 两层声明(形态/能力)→ 注入片段;不可满足时 `ThinkingUnsupportedError`。
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
`RequestRejectedError`(四分类之一)。**判定顺序即语义,不可调换**:
========== ================================================================
Phase 1 不表态 → 不注入。与 `Effort.NONE` 严格区分: 前者是"随模型默认",
后者是"要求不推理"
Phase 2 **该请求档所需的**形态未知 → 报错(判据见 `_wire_unknown_for`)。
无从注入时,模型能力如何都无关紧要,故必须先于 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(未登记)时无从从别处取得模型名。
`fallback="nearest"` 是 Phase 5 的逃生口,**默认关闭的理由是钱**: 一次静默的
`medium → max` 在 GLM-5.3 上是数倍账单(P5"严禁默认值掩盖错误")。
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次调用
再喊只会刷屏。判定结果不受此参数影响。
**裸字符串也收**(设计 §4.4 第 4 条入口): 本函数在 `__all__` 里,下游直调时
传的天然是从 JSON/配置读出来的 `"low"`,而第三参数本次由 `bool` 换成 `Effort`
正是这条入口冒出来的时机。签名照实写 `Effort | str`——下面每一关的判据都是
`is Effort.X` 的身份比较,`"none" is Effort.NONE` 恒假,不归一的后果不是报错
而是**静默判否**: Phase 2 按开启方向取字段、Phase 4 整条被绕过,最后在拼错误
文案时才以 `AttributeError` 现形(一个未文档化、也不属四分类的异常)。
"""
# Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举
if effort is not None:
effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})")
# Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档
if effort is None:
return ThinkingResolution({}, None)
wire = profile.thinking
# Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方
if _wire_unknown_for(wire, effort):
raise ThinkingUnsupportedError(
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, effort, model=model)
if warn_unregistered:
_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, applied, model=model), applied)
def _wire_unknown_for(wire: ThinkingWire, effort: Effort) -> bool:
"""Phase 2 的判据: **按请求档取相关字段**,不是一律看 `on_base`。
旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此。只看
`on_base` 会让"关闭形态已知、开启形态未知"的自定义 provider 在请求 `none` 时
被误拒,且指向它已经做过的 `register_provider`(设计 §2 处置表第 2 条)。
请求 `none` 时判据是**两者皆 None**,而不是单看 `off`: `ThinkingWire` 的三个
`None` 语义互不重叠——`off is None` 而 `on_base` 已知是"该 provider 关不掉"
(由 `_inject` 说清是缺了哪半边),只有两者皆 None 才是"整个形态未知",此时
指路 `register_provider` 才是对的方向。
"""
if effort is not Effort.NONE:
return wire.on_base is None
return wire.off is None and wire.on_base is None
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(
_tier_unsupported(model, effort, capability, fallback=fallback)
)
logger.warning(
"模型 {} 不支持 reasoning_effort={},按 effort_fallback=nearest 改用最近的 {} 档;"
"本次真正发出去的、以及遥测成功行记的都是后者,但**缓存 key 记的是前者**"
"(CacheMW 在洋葱里比 transport 更外,查缓存时映射尚未发生,拿不到实发档)",
model,
effort.value,
mapped.value,
)
return mapped
def _inject(profile: ProviderProfile, effort: Effort, *, model: str) -> Mapping[str, Any]:
"""按 wire 把档位写成请求体片段;wire 表达不了这一档时报错。
自己重读 `wire` 而不由调用方传 `on_base`: Phase 2 的判据按请求档取相关字段
(`none` 看 `off`,其余档看 `on_base`)之后,"on_base 一定不是 None"这条前提
只对非 `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
# 非 none 档的开启形态由 Phase 2 保证已知(内部不变量,不承担生产校验)
assert wire.on_base is not None
if effort is Effort.AUTO:
# auto = 开启但不指定强度: 逐字节等于升级前的 `thinking_on`
return wire.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 {**wire.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(payload),
effort.value,
)
def reconcile_thinking(
*,
effort: Effort | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None:
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
**判据是档位而非布尔**(2026-09-05,设计 §4.3): `Effort.NONE` 走"要求关闭"
一支,其余任何档走"要求开启"一支,`None`(不表态)仍沉默。判据必须写成
`is Effort.NONE` 的**身份比较**——它的取值是非空串 `"none"`,任何靠真值性
的写法(`if not effort`)都恒为假,会把每个强度档送进关闭分支,告警方向整个
颠倒。传入的应是**实际发出去**的那一档(`nearest` 映射后与请求档分叉),
否则文案会说一个从未发出过的档。
**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3/§11 第 1
条): 二者没有可判定的函数关系(实测同一档 rt 在 8~56 之间跳),拿它报警必然
是噪声,而噪声等于没有告警。该问题归 §11 的压测,不进库。
**只判定、不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容,
而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。
**不抛错**: 一次观测不足以否决一次成功的调用;可观测性属遥测方向,降级即
warning(P5 的"报错而非放行"只约束限流/熔断)。矛盾结果已随 `LLMResponse`
与遥测落地,处置权归下游。
"""
# Phase 1: 调用方不表态 —— 没提要求就无从谈"违背"
if effort is None:
return None
# Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警
# 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警
if effort is Effort.NONE:
if observation is not ThinkingObservation.OBSERVED:
return None
return _off_but_observed(model, capability)
# Phase 3: 要求开启(含 auto 与各强度档)—— ABSENT 是正面证伪,UNKNOWN 是
# "看不见",两者文案不可混。文案写出**是哪一档**: transport 的节流键正按档
# 分离,文案不分档的话,两条告警长得一模一样,看的人分不出是哪一档出的问题
if observation is ThinkingObservation.ABSENT:
return (
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 未生效: 已注入开启参数,"
f"上游却明确上报本次未推理(reasoning_tokens=0)"
)
if observation is ThinkingObservation.UNKNOWN:
return (
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 无法确认是否生效: "
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} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。"
f"请实测后用 register_capability 登记其真实能力"
)
return (
f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。"
f"能力表可能已过期——请复测后用 register_capability 更新登记"
)