diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 84fe8be..f8f39ce 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -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", diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 7806d8c..153f9b3 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -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)", ), } ) diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 56d6af0..0234ad2 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -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)。 diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index c6fa423..711022a 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -18,7 +18,7 @@ from polygateway.thinking import ( register_capability, resolve_thinking, ) -from polygateway.types import ThinkingObservation +from polygateway.types import Effort, ThinkingObservation def _warnings(): @@ -125,7 +125,7 @@ class TestThinkingCapability: assert get_capability("some-brand-new-model") is None def test_register_capability_is_pure(self): - table = register_capability("x-1", ThinkingCapability(True, "实测")) + table = register_capability("x-1", ThinkingCapability((Effort.NONE, Effort.AUTO), "实测")) assert get_capability("x-1", table=table) is not None assert get_capability("x-1") is None # 默认表未被污染 @@ -175,7 +175,7 @@ class TestResolveThinking: def test_unknown_shape_beats_capability_check(self): """第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。""" - cap = ThinkingCapability(can_disable=False, evidence="构造") + cap = ThinkingCapability((Effort.AUTO,), "构造") with pytest.raises(ValueError, match="register_provider"): resolve_thinking(get_provider("openai"), cap, False, model="whatever") @@ -188,7 +188,7 @@ class TestReconcileThinking: """ _CAP = ThinkingCapability( - can_disable=True, evidence="2026-08-02 实测 reasoning_effort=none 可关闭" + (Effort.NONE, Effort.AUTO), "2026-08-02 实测 reasoning_effort=none 可关闭" ) def test_off_but_observed_with_a_registered_capability_blames_the_table(self): @@ -311,3 +311,80 @@ class TestReconcileThinking: ) is None ) + + +class TestEffortVocabulary: + """八档封闭词汇(设计 §3.1);`auto` 不可省——9 个纯开关型模型无强度档可填。""" + + def test_none_and_auto_are_distinct_members(self): + assert Effort.NONE != Effort.AUTO + assert Effort("none") is Effort.NONE + assert Effort("auto") is Effort.AUTO + + def test_vocabulary_is_exactly_eight(self): + assert len(list(Effort)) == 8 + + def test_values_are_wire_literals(self): + # 档位值直接写进请求体,改名即改变发出去的字节 + assert [e.value for e in Effort] == [ + "none", + "auto", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ] + + +class TestCapabilityTierList: + """能力表从 bool 变成档位清单(设计 §3.2);三个派生量不存字段,存了必漂移。""" + + def test_capability_derives_can_disable(self): + assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").can_disable is True + assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").can_disable is False + + def test_cheapest_effort_skips_none(self): + # 「关不掉时的可执行替代」取的是除 none 外最弱的一档 + assert ( + ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "实测").cheapest_effort + is Effort.LOW + ) + assert ( + ThinkingCapability((Effort.NONE, Effort.HIGH, Effort.MAX), "实测").cheapest_effort + is Effort.HIGH + ) + assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").cheapest_effort is Effort.AUTO + assert ThinkingCapability((Effort.AUTO,), "实测").cheapest_effort is Effort.AUTO + + def test_cheapest_effort_is_none_when_only_none(self): + # 只能关不能开: 没有可推荐的「最省的开启档」 + assert ThinkingCapability((Effort.NONE,), "实测").cheapest_effort is None + + def test_is_tiered_excludes_none_and_auto(self): + # 纯开关型模型不该被告知「可选档位」——它没有档位 + assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").is_tiered is False + assert ThinkingCapability((Effort.AUTO,), "实测").is_tiered is False + assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").is_tiered is True + + def test_empty_efforts_rejected(self): + with pytest.raises(ValueError, match="至少"): + ThinkingCapability((), "实测") + + def test_duplicate_efforts_rejected(self): + with pytest.raises(ValueError, match="重复"): + ThinkingCapability((Effort.LOW, Effort.LOW), "实测") + + def test_glm53_cannot_be_disabled(self): + # 三源一致(智谱官方文档/cherry-studio/OpenRouter): thinking.type 只接受 enabled + cap = get_capability("glm-5.3") + assert cap is not None + assert cap.can_disable is False + assert cap.cheapest_effort is Effort.LOW + + def test_m2_series_still_cannot_be_disabled(self): + # 迁移回归: 旧表用 can_disable=False 表达的事实,新表用「none 不在清单里」表达 + assert get_capability("MiniMax-M2.7").can_disable is False + assert get_capability("MiniMax-M2.5").can_disable is False + assert get_capability("MiniMax-M3").can_disable is True