refactor: make capability a tier list, since "can it be off" is one entry

The boolean could say a model reasons or does not. It could not say what
GLM-5.3 and Gemini 3 Pro actually do: refuse to stop reasoning while
still letting you ask for less. So capability becomes the list of tiers a
model serves, and `none`'s presence in it is what "can_disable" now reads.

Effort carries `auto` alongside the strength tiers. Nine of the models on
our gateway are pure switches with no tier to name, and without `auto`
they would have to borrow a strength tier to mean "on" — which is the
exact bug this work exists to remove.

Tiers land as documented guesses from four registries that agree; every
entry says so in its evidence, and task 10 replaces them with measurements.
This commit is contained in:
2026-09-05 00:31:28 -04:00
parent de261e485d
commit 2a50ddcf12
4 changed files with 308 additions and 16 deletions
+4
View File
@@ -32,6 +32,8 @@ from polygateway.thinking import (
resolve_thinking,
)
from polygateway.types import (
EFFORT_ORDER,
Effort,
EmbeddingResponse,
LLMResponse,
OcrLayoutElement,
@@ -46,6 +48,8 @@ __version__ = "1.3.2"
__all__ = [
"DEFAULT_PROFILES",
"EFFORT_ORDER",
"Effort",
"AllSourcesExhausted",
"CircuitOpenError",
"EmbeddingClient",
+176 -12
View File
@@ -15,7 +15,7 @@ from typing import Any
from loguru import logger
from polygateway.providers import ProviderProfile
from polygateway.types import ThinkingObservation
from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation
def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation:
@@ -54,50 +54,214 @@ class ThinkingUnsupportedError(ValueError):
@dataclass(frozen=True)
class ThinkingCapability:
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
"""某个**具体模型**支持哪些推理档位(设计 §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/` 下六档)。
"""
can_disable: bool
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(
can_disable=True,
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 是真开关"
"② enable_thinking / thinking:{type:enabled} 对本模型无效,仅 reasoning_effort 是真开关"
"无强度档: 官方只有开/关两态(thinking.type disabled/adaptive)"
),
),
"MiniMax-M2.7": ThinkingCapability(
can_disable=False,
supported_efforts=(Effort.AUTO,),
evidence=(
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
"MiniMax 官方亦承认 M2.x 接受 disabled 但推理仍开着"
),
),
"MiniMax-M2.5": ThinkingCapability(
can_disable=False,
supported_efforts=(Effort.AUTO,),
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,无推理)",
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
"2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)。"
"无强度档: OpenRouter 登记本型号只支持 reasoning 开关,不支持 reasoning_effort"
),
),
"deepseek-v4-pro": ThinkingCapability(
can_disable=True,
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
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)",
),
}
)
+47
View File
@@ -167,6 +167,53 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
class Effort(StrEnum):
"""推理强度档位的封闭词汇(设计 §3.1)。
取值直接写进请求体(`reasoning_effort` 等键),**改名即改变发出去的字节**,
且会进缓存 key 与遥测落库,历史数据会断层。
八档而非六档: `none`(不推理)与 `auto`(推理,档位由模型自定)必须同时存在。
`auto` 不可省——newapi 上 26 个可调用模型里有 9 个是**纯开关型**(qwen 五个、
MiniMax-M3、glm-5/5.1/4.6v),它们能开推理却没有强度档可填;没有 `auto` 就只
能拿某个强度档冒充"",而那正是本次要修的病根(旧 `thinking_on` 硬编码
`medium`,可 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在)。
词汇取四家参考实现共同收敛的一套(cherry-studio 的 canonical selection、
OpenRouter 的 `supported_efforts`、LiteLLM 的 `reasoning_effort_levels`、
new-api 的 `relayconvert/reasoning`),不自创。
**枚举定义在最内层而非决策层**: 它是 `SourceConfig`/`ChatRequest`/
`LLMResponse` 的字段类型,放进 `thinking.py` 会让 `types.py` 反向 import
决策模块(P7 依赖铁律),与 `ThinkingObservation` 同一理由。
"""
NONE = "none"
AUTO = "auto"
MINIMAL = "minimal"
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
XHIGH = "xhigh"
MAX = "max"
EFFORT_ORDER: tuple[Effort, ...] = (
Effort.NONE,
Effort.MINIMAL,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
)
"""由弱到强的强度序;`AUTO` **不在其中**——它是"由模型自定",在强弱轴上没有位置。
供能力表求"最省的开启档"与 `nearest` 映射取最近档。公开(非 `_` 前缀)是因为
`thinking.py` 要跨模块消费它,跨模块引用私有名是坏味道。
"""
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。