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:
@@ -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
@@ -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)",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user