diff --git a/research-wiki/designs/2026-09-04-reasoning-effort-design.md b/research-wiki/designs/2026-09-04-reasoning-effort-design.md index ec98d51..774faec 100644 --- a/research-wiki/designs/2026-09-04-reasoning-effort-design.md +++ b/research-wiki/designs/2026-09-04-reasoning-effort-design.md @@ -163,9 +163,12 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 | provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 | |---|---|---|---| | `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** | -| 靠档位表达「开」(minimax/openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** | +| 靠档位表达「开」(openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** | +| 同上但默认不推理(minimax) | `{"reasoning_effort": "medium"}` | 同左(2026-09-05 回退) | **逐字节等价** | -后者是**有意的**: 旧版那个 `medium` 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这些模型默认即推理),只是不再强制一个档;要指定强度请显式配 `REASONING_EFFORT`。须进 CHANGELOG 的行为变更条目。 +第一行是**有意的**: 旧版那个 `medium` 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这三家的模型经 OpenRouter 登记默认即推理),只是不再强制一个档;要指定强度请显式配 `REASONING_EFFORT`。须进 CHANGELOG 的行为变更条目。 + +**第二行是 2026-09-05 的回退(issue #21,人类拍板的最小修复)**: 上述「语义仍是开」依赖「模型默认就推理」这个前提,T10 真实网关实测证明 MiniMax-M3 不满足它——不发任何推理参数时 5/5 轮不推理。故 `minimax` 段的 `on_base` 改回 `{"reasoning_effort": "medium"}`,存量 `ENABLE_THINKING=true` 的行为逐字恢复。这是权宜之计: 正解是让 `auto` 受能力表约束(模型不支持「由模型自定」时报错并指路显式档位),属公共行为变更,下一版处理。 | `enable_thinking=None` | 不表态 | **同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾**(如 `True` + `none`)→ **构造期 `ValueError`**。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。 @@ -339,7 +342,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语 **兼容策略**: 保留 `can_disable` 为只读派生属性(`Effort.NONE in supported_efforts`),**读侧代码一律不改**;位置参数构造无法兼容,库内三处随实现同步修改。 -**下游影响面: 推断而非核实**。三项目尚未迁移接入本库(M4 才做),`ThinkingCapability` 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。**须人类在审批时确认**,或在实现计划里加一步「三项目可读时复验调用点」。属公共 API 破坏性变更,走 minor 版本号(1.4.0)。 +**下游影响面: 推断而非核实**。三项目尚未迁移接入本库(M4 才做),`ThinkingCapability` 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。**须人类在审批时确认**,或在实现计划里加一步「三项目可读时复验调用点」。版本号取 **1.3.3**(2026-09-05 人类指令,不走 minor)。 **非破坏**: `SourceConfig.enable_thinking` 保留,行为等价(§4.2);`.env` 的 `ENABLE_THINKING` 键保留;新增键 `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。 diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index 9427635..644f1bb 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -117,13 +117,22 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( # 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。 # enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线 # 194),reasoning_effort 才是真开关——本段形态据此成立。 - # `on_base={}`: "开"本身不需要任何参数,档位(若模型支持)由 effort_key 附加。 - # 旧版此处硬编码 {"reasoning_effort": "medium"},那是替下游做的档位判断, - # 且 medium 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本次要消灭的 + # `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段 + # 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。 + # 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖 + # "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何 + # 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。 + # 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。 + # 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价, + # 故选哪档对效果无差别。 + # 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位), + # 属公共行为变更,已记入 gitea issue #21 待下一版处理。 "minimax": ProviderProfile( name="minimax", thinking=ThinkingWire( - off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort" + off={"reasoning_effort": "none"}, + on_base={"reasoning_effort": "medium"}, + effort_key="reasoning_effort", ), strip_think_tags=False, ), diff --git a/src/polygateway/thinking.py b/src/polygateway/thinking.py index 41c8f32..c6ddedd 100644 --- a/src/polygateway/thinking.py +++ b/src/polygateway/thinking.py @@ -145,10 +145,12 @@ DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType( "全部观测到推理,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,故存量 ENABLE_THINKING=true 仍会" - "静默落到不推理——那是 wire 的缺口,能力表堵不住,已记入 T10 报告待单独立项。" + "**`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'本次**不再成立**" diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index e0f29a9..a359b60 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -771,9 +771,10 @@ async def _anchor_off_against_on( 锚点取 `completion_tokens` 的相对比较(关闭档最大值 < 开启档最小值),**不含 任何魔数**: 推理段计在 completion 里,真开着时两档差一个数量级(实测 kimi-k3 - 关闭档恒 9 token)。取 `max` 档而非 `auto`: 后者对 minimax 一路等于"什么都不注入" - (`on_base={}`),那是模型默认档而不是"开",拿它当对照组会把 M3 这种默认不推理的 - 模型判成"分不开"。`max` 打不通时才退到 `auto`。 + 关闭档恒 9 token)。取 `max` 档而非 `auto`: 后者在 `on_base={}` 的 provider 上 + 等于"什么都不注入",那是模型默认档而不是"开",拿它当对照组会把 M3 这种默认不推理的 + 模型判成"分不开"(minimax 段已按 issue #21 改回带 medium,openai/anthropic/google + 三段仍是空片段,故该风险仍在)。`max` 打不通时才退到 `auto`。 """ off_usable = [o for o in off_observations if _probe_ok(o)] for tier in (Effort.MAX, Effort.AUTO): diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index a92c7c0..92467b1 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -258,10 +258,12 @@ class TestReasoningEffortPriority: async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment): """存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。 - **只覆盖 `on_base` 自己就说全了"开"的四段**。minimax/openai/anthropic/google - 的开档旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位——那是 - 设计 §4.2 声明过的**有意变更**(medium 在 GLM/kimi/deepseek 的档位表里根本 - 不存在,是库替下游做的档位判断),不是本门要守的不变量。 + **只覆盖 `on_base` 自己就说全了"开"的四段**。openai/anthropic/google 的开档 + 旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位——那是设计 + §4.2 声明过的**有意变更**(medium 在 GLM/kimi/deepseek 的档位表里根本不存在, + 是库替下游做的档位判断),不是本门要守的不变量;这三家的模型经 OpenRouter + 登记均为默认推理,不注入也仍是"开"。minimax 不在此列: 它的模型不满足该前提, + 已按 issue #21 改回 medium,由下一条用例单独守。 qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`; zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态。 @@ -275,6 +277,23 @@ class TestReasoningEffortPriority: # `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档 assert "reasoning_effort" not in body + async def test_legacy_minimax_on_tier_actually_turns_reasoning_on(self): + """回归门(issue #21): minimax 段的存量 `ENABLE_THINKING=true` 必须真开推理。 + + 本次换代一度把这段的开启形态改成 `on_base={}`(什么参数都不注入),依据是 + "这些模型默认就推理,不注入也仍是'开'"。T10 真实网关实测推翻了该前提: + MiniMax-M3 不带任何推理参数时 5/5 轮**不推理**(六个强度值则全部生效)。 + 于是存量下游从"真开推理"静默变成"不推理",而 `resolve_thinking` 的 Phase 5 + 无条件放行 `auto`、能力表也堵不住这条路。 + + 断言落在**发出去的字节**上而非中间态: 静默不推理这件事只有在请求体里才看得见。 + """ + captured = [] + source = _source(provider="minimax", model="MiniMax-M3", enable_thinking=True) + async with self._capturing_client(captured, sources=[source]) as client: + await client.chat([{"role": "user", "content": "hi"}]) + assert captured[0]["reasoning_effort"] == "medium" + class TestEffortFallbackWiring: """源级 `effort_fallback` 必须真的走到 `resolve_thinking`(issue #20)。 diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index e882c05..a36b7a2 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -724,9 +724,11 @@ class TestRequestShaping: @pytest.mark.parametrize( ("enable_thinking", "expected"), - # 开档不再附 medium(2026-09-04): 那是替下游做的档位判断,且 medium 不在 - # GLM/kimi/deepseek 的档位表里。MiniMax 开启档本就无需参数,要强度请配档位 - [(True, None), (False, "none")], + # 本条断言反复过一次,记下原委以免第三次改回去: + # T2(2026-09-04)按"MiniMax 开启档本就无需参数"的**推定**把 medium 改成不注入; + # T10(2026-09-05)真实网关实测推翻该推定——M3 不发任何推理参数时 5/5 轮不推理, + # 故 medium 回归(issue #21 的权宜之计,正解是让 auto 受能力表约束) + [(True, "medium"), (False, "none")], ) async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected): """issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。""" @@ -750,9 +752,9 @@ class TestRequestShaping: """注入顺序即优先级: profile → extra_body → overlay,两行不可调换。 固定用 **zhipu + glm-5.3 + 源级 low** 这组: 判据必须落在一个 profile - **真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁。用 minimax + - `enable_thinking=True` 是测不出来的——T5 起 `True ≡ auto`,而 auto 的注入 - 片段就是 minimax 的 `on_base`(空字典),两行怎么调换结果都一样,断言恒真。 + **真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁。不挑 minimax 是因为 + 它的 `on_base` 只写 `reasoning_effort` 一个键(issue #21 的权宜之计), + 覆盖发生后看不见"profile 独有的那半边仍在",判据少一半。 """ seen = {} diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 6b1e5b5..7f90d38 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -46,13 +46,28 @@ class TestDefaultProfiles: assert w.effort_key == "reasoning_effort" def test_openai_family_sends_the_standard_field_only(self): - """gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。""" - for name in ("openai", "anthropic", "google", "minimax"): + """gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。 + + minimax 2026-09-05 起不在本组: 它的形态相同,但"开"这一档被迫带上了一个 + 档位值(见 `test_minimax_on_tier_carries_a_tier_value`)。 + """ + for name in ("openai", "anthropic", "google"): w = get_provider(name).thinking assert w.on_base == {}, name assert w.off == {"reasoning_effort": "none"}, name assert w.effort_key == "reasoning_effort", name + def test_minimax_on_tier_carries_a_tier_value(self): + """issue #21 的权宜之计: minimax 的"开"必须真写一个档位值,不能是空片段。 + + 断言反复过一次: T2 按"这些模型默认就推理"的推定把它改成 `{}`,T10 真实 + 网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium。 + """ + w = get_provider("minimax").thinking + assert w.on_base == {"reasoning_effort": "medium"} + assert w.off == {"reasoning_effort": "none"} + assert w.effort_key == "reasoning_effort" + def test_unknown_provider_fails_loudly(self): """消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。""" with pytest.raises(ValueError, match="glm"): @@ -82,8 +97,12 @@ class TestWireNoneSemantics: assert get_provider("qwen").thinking.effort_key is None def test_empty_on_base_is_not_none(self): - """`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。""" - w = get_provider("minimax").thinking + """`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。 + + 样本 2026-09-05 由 minimax 换成 openai: minimax 的 `on_base` 因 issue #21 + 改回带值,不再是空片段;openai 段是现存 `{}` 语义的代表。 + """ + w = get_provider("openai").thinking assert w.on_base == {} and w.on_base is not None diff --git a/tests/unit/test_thinking.py b/tests/unit/test_thinking.py index 6090f63..77fa8c6 100644 --- a/tests/unit/test_thinking.py +++ b/tests/unit/test_thinking.py @@ -286,10 +286,15 @@ class TestResolveThinking: ) def test_phase4_only_blocks_the_off_direction(self): - """关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。""" + """关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。 + + 期望片段 2026-09-05 由 `{}` 改成 minimax 的 `on_base` 实际值: issue #21 把 + 该段的"开"改回带 medium(T2 的"开档不注入"是推定,T10 实测推翻)。本用例守的 + 是 Phase 4 只拦关闭方向,注入什么由 wire 决定,故随 wire 走。 + """ cap = get_capability("MiniMax-M2.7") got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7") - assert got.payload == {} + assert got.payload == {"reasoning_effort": "medium"} assert got.applied_effort is Effort.AUTO def test_phase4_passes_when_none_is_registered(self):