fix: put the tier back where "on" by itself is not on

The minimax wire lost its tier value on the assumption that these models
reason by default, so injecting nothing still reads as "on". T10 measured
the real gateway and the assumption does not hold: MiniMax-M3 with no
reasoning parameter did not reason in 5 of 5 rounds, while all six
strength values worked. Existing downstreams on ENABLE_THINKING=true
went from reasoning to silently not reasoning, and the capability table
cannot catch it because phase 5 lets auto through unconditionally.

Restore on_base to the old {"reasoning_effort": "medium"} verbatim. This
is a stopgap - it hands the tier choice back to the library, which this
work set out to remove. The real fix is to constrain auto by the
capability table, a public behaviour change tracked as issue #21.

The assertions that said "minimax injects no tier on the on-tier" go
back with it; each carries a note on why it moved twice.
This commit is contained in:
2026-09-05 10:48:08 -04:00
parent f5e6fafe8d
commit a194f4326e
8 changed files with 90 additions and 30 deletions
@@ -163,9 +163,12 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
| provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 | | provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 |
|---|---|---|---| |---|---|---|---|
| `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** | | `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=None` | 不表态 |
**同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾**(如 `True` + `none`)→ **构造期 `ValueError`**。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。 **同源同时配 `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`),**读侧代码一律不改**;位置参数构造无法兼容,库内三处随实现同步修改。 **兼容策略**: 保留 `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`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。 **非破坏**: `SourceConfig.enable_thinking` 保留,行为等价(§4.2);`.env``ENABLE_THINKING` 键保留;新增键 `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。
+13 -4
View File
@@ -117,13 +117,22 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
# 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。 # 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。
# enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线 # enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线
# 194),reasoning_effort 才是真开关——本段形态据此成立。 # 194),reasoning_effort 才是真开关——本段形态据此成立。
# `on_base={}`: "开"本身不需要任何参数,档位(若模型支持)由 effort_key 附加。 # `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段
# 旧版此处硬编码 {"reasoning_effort": "medium"},那是替下游做的档位判断, # 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。
# 且 medium 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本次要消灭的 # 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖
# "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何
# 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。
# 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。
# 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价,
# 故选哪档对效果无差别。
# 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位),
# 属公共行为变更,已记入 gitea issue #21 待下一版处理。
"minimax": ProviderProfile( "minimax": ProviderProfile(
name="minimax", name="minimax",
thinking=ThinkingWire( 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, strip_think_tags=False,
), ),
+6 -4
View File
@@ -145,10 +145,12 @@ DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
"全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / " "全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / "
"high 62-128 / xhigh 58-118 / max 57-170)——**它们是''的六种写法,不是六个深度档**," "high 62-128 / xhigh 58-118 / max 57-170)——**它们是''的六种写法,不是六个深度档**,"
"MiniMax 官方只有开/关两态,配哪一个都一样贵。" "MiniMax 官方只有开/关两态,配哪一个都一样贵。"
"**`auto` 已从清单移除**: minimax 的''在 wire 上是 on_base={}(什么参数都不注入)," "**`auto` 已从清单移除**: 实测当时 minimax 的「开」在 wire 上是 on_base={}"
"而 M3 的默认档实测不推理,故 auto 在这条路上表达不了''(N=5 全部未观测到推理)。" "(什么参数都不注入),而 M3 的默认档实测不推理,故 auto 在这条路上表达不了「开」"
"注意 `resolve_thinking` 的 Phase 5 无条件放行 auto,故存量 ENABLE_THINKING=true 仍会" "(N=5 全部未观测到推理)。`resolve_thinking` 的 Phase 5 无条件放行 auto,能力表"
"静默落到不推理——那是 wire 的缺口,能力表堵不住,已记入 T10 报告待单独立项。" "堵不住这条,故 2026-09-05 由 wire 侧兜住: on_base 改回 {'reasoning_effort': 'medium'},"
"存量 ENABLE_THINKING=true 恢复真开推理(权宜之计,正解见 issue #21)。"
"本清单仍不含 auto——它记的是实测结论,不随 wire 的权宜之计变动。"
f"历史: {_MEASURED} N=10 同样成立;enable_thinking / thinking:{{type}} 两种写法对本模型" f"历史: {_MEASURED} N=10 同样成立;enable_thinking / thinking:{{type}} 两种写法对本模型"
"无效,reasoning_effort 才是真开关(findings 2026-08-25 §3.1/§5)。" "无效,reasoning_effort 才是真开关(findings 2026-08-25 §3.1/§5)。"
"另: 2026-08-25 记录的'MiniMax 这一路已停报 completion_tokens_details'本次**不再成立**" "另: 2026-08-25 记录的'MiniMax 这一路已停报 completion_tokens_details'本次**不再成立**"
+4 -3
View File
@@ -771,9 +771,10 @@ async def _anchor_off_against_on(
锚点取 `completion_tokens` 的相对比较(关闭档最大值 < 开启档最小值),**不含 锚点取 `completion_tokens` 的相对比较(关闭档最大值 < 开启档最小值),**不含
任何魔数**: 推理段计在 completion 里,真开着时两档差一个数量级(实测 kimi-k3 任何魔数**: 推理段计在 completion 里,真开着时两档差一个数量级(实测 kimi-k3
关闭档恒 9 token)。取 `max` 档而非 `auto`: 后者对 minimax 一路等于"什么都不注入" 关闭档恒 9 token)。取 `max` 档而非 `auto`: 后者在 `on_base={}` 的 provider 上
(`on_base={}`),那是模型默认档而不是"",拿它当对照组会把 M3 这种默认不推理的 等于"什么都不注入",那是模型默认档而不是"",拿它当对照组会把 M3 这种默认不推理的
模型判成"分不开"。`max` 打不通时才退到 `auto`。 模型判成"分不开"(minimax 段已按 issue #21 改回带 medium,openai/anthropic/google
三段仍是空片段,故该风险仍在)。`max` 打不通时才退到 `auto`。
""" """
off_usable = [o for o in off_observations if _probe_ok(o)] off_usable = [o for o in off_observations if _probe_ok(o)]
for tier in (Effort.MAX, Effort.AUTO): for tier in (Effort.MAX, Effort.AUTO):
+23 -4
View File
@@ -258,10 +258,12 @@ class TestReasoningEffortPriority:
async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment): async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment):
"""存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。 """存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。
**只覆盖 `on_base` 自己就说全了""的四段**。minimax/openai/anthropic/google **只覆盖 `on_base` 自己就说全了""的四段**。openai/anthropic/google 的开档
的开档旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位——那是 旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位——那是设计
设计 §4.2 声明过的**有意变更**(medium 在 GLM/kimi/deepseek 的档位表里根本 §4.2 声明过的**有意变更**(medium 在 GLM/kimi/deepseek 的档位表里根本不存在,
不存在,是库替下游做的档位判断),不是本门要守的不变量 是库替下游做的档位判断),不是本门要守的不变量;这三家的模型经 OpenRouter
登记均为默认推理,不注入也仍是""。minimax 不在此列: 它的模型不满足该前提,
已按 issue #21 改回 medium,由下一条用例单独守。
qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`; qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`;
zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态。 zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态。
@@ -275,6 +277,23 @@ class TestReasoningEffortPriority:
# `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档 # `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档
assert "reasoning_effort" not in body 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: class TestEffortFallbackWiring:
"""源级 `effort_fallback` 必须真的走到 `resolve_thinking`(issue #20)。 """源级 `effort_fallback` 必须真的走到 `resolve_thinking`(issue #20)。
+8 -6
View File
@@ -724,9 +724,11 @@ class TestRequestShaping:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("enable_thinking", "expected"), ("enable_thinking", "expected"),
# 开档不再附 medium(2026-09-04): 那是替下游做的档位判断,且 medium 不在 # 本条断言反复过一次,记下原委以免第三次改回去:
# GLM/kimi/deepseek 的档位表里。MiniMax 开启档本就无需参数,要强度请配档位 # T2(2026-09-04)按"MiniMax 开启档本就无需参数"的**推定**把 medium 改成不注入;
[(True, None), (False, "none")], # 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): async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
"""issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。""" """issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。"""
@@ -750,9 +752,9 @@ class TestRequestShaping:
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。 """注入顺序即优先级: profile → extra_body → overlay,两行不可调换。
固定用 **zhipu + glm-5.3 + 源级 low** 这组: 判据必须落在一个 profile 固定用 **zhipu + glm-5.3 + 源级 low** 这组: 判据必须落在一个 profile
**真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁。 minimax + **真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁。不挑 minimax 是因为
`enable_thinking=True` 是测不出来的——T5 起 `True ≡ auto`,而 auto 的注入 它的 `on_base` 只写 `reasoning_effort` 一个键(issue #21 的权宜之计),
片段就是 minimax 的 `on_base`(空字典),两行怎么调换结果都一样,断言恒真 覆盖发生后看不见"profile 独有的那半边仍在",判据少一半
""" """
seen = {} seen = {}
+23 -4
View File
@@ -46,13 +46,28 @@ class TestDefaultProfiles:
assert w.effort_key == "reasoning_effort" assert w.effort_key == "reasoning_effort"
def test_openai_family_sends_the_standard_field_only(self): def test_openai_family_sends_the_standard_field_only(self):
"""gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。""" """gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。
for name in ("openai", "anthropic", "google", "minimax"):
minimax 2026-09-05 起不在本组: 它的形态相同,但""这一档被迫带上了一个
档位值(见 `test_minimax_on_tier_carries_a_tier_value`)。
"""
for name in ("openai", "anthropic", "google"):
w = get_provider(name).thinking w = get_provider(name).thinking
assert w.on_base == {}, name assert w.on_base == {}, name
assert w.off == {"reasoning_effort": "none"}, name assert w.off == {"reasoning_effort": "none"}, name
assert w.effort_key == "reasoning_effort", 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): def test_unknown_provider_fails_loudly(self):
"""消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。""" """消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。"""
with pytest.raises(ValueError, match="glm"): with pytest.raises(ValueError, match="glm"):
@@ -82,8 +97,12 @@ class TestWireNoneSemantics:
assert get_provider("qwen").thinking.effort_key is None assert get_provider("qwen").thinking.effort_key is None
def test_empty_on_base_is_not_none(self): def test_empty_on_base_is_not_none(self):
"""`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。""" """`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。
w = get_provider("minimax").thinking
样本 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 assert w.on_base == {} and w.on_base is not None
+7 -2
View File
@@ -286,10 +286,15 @@ class TestResolveThinking:
) )
def test_phase4_only_blocks_the_off_direction(self): 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") cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="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 assert got.applied_effort is Effort.AUTO
def test_phase4_passes_when_none_is_registered(self): def test_phase4_passes_when_none_is_registered(self):