fix: enforce registered auto reasoning capabilities
This commit is contained in:
@@ -29,9 +29,8 @@ class ThinkingWire:
|
|||||||
``=None`` ``thinking_budget`` 调深度,不是档位)
|
``=None`` ``thinking_budget`` 调深度,不是档位)
|
||||||
============== ==========================================================
|
============== ==========================================================
|
||||||
|
|
||||||
`on_base={}` 与 `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于
|
`on_base={}` 与 `on_base=None` 不可混: 前者是协议无需额外开启字节,
|
||||||
开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加),
|
是否满足 AUTO 由模型能力清单决定;后者是“不知道怎么表达”。
|
||||||
后者是"不知道怎么表达"。
|
|
||||||
|
|
||||||
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
|
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
|
||||||
openai-responses / anthropic-messages / google-generate-content 四种端点协议,
|
openai-responses / anthropic-messages / google-generate-content 四种端点协议,
|
||||||
@@ -117,21 +116,12 @@ 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={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段
|
# 开启片段不代选强度;AUTO 可满足性由具体模型能力清单决定。
|
||||||
# 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。
|
|
||||||
# 之所以接受: 本次一度改成 `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"},
|
off={"reasoning_effort": "none"},
|
||||||
on_base={"reasoning_effort": "medium"},
|
on_base={},
|
||||||
effort_key="reasoning_effort",
|
effort_key="reasoning_effort",
|
||||||
),
|
),
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
|
|||||||
+16
-12
@@ -471,11 +471,8 @@ def resolve_thinking(
|
|||||||
信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是
|
信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是
|
||||||
issue #20 的成因。
|
issue #20 的成因。
|
||||||
|
|
||||||
**`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是
|
**已登记的 AUTO 同样受清单约束**: 开启形态不证明模型支持不指定强度。
|
||||||
"不写 `effort_key`",而不是写进 `effort_key` 的某个取值,故 Phase 5 放行它。
|
AUTO 不在强弱轴上,不允许 nearest 静默代选付费档位;未知模型仍尽力并告警。
|
||||||
反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`)在 deepseek-v4
|
|
||||||
与 glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量
|
|
||||||
配置继续可跑——那里唯一允许新报错的是"关闭一个官方不可关的模型"。
|
|
||||||
|
|
||||||
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
||||||
`capability` 为 None(未登记)时无从从别处取得模型名。
|
`capability` 为 None(未登记)时无从从别处取得模型名。
|
||||||
@@ -517,7 +514,7 @@ def resolve_thinking(
|
|||||||
# 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由)
|
# 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由)
|
||||||
if effort is Effort.NONE and not capability.can_disable:
|
if effort is Effort.NONE and not capability.can_disable:
|
||||||
raise ThinkingUnsupportedError(_cannot_disable(model, capability))
|
raise ThinkingUnsupportedError(_cannot_disable(model, capability))
|
||||||
# Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring)
|
# Phase 5: 已登记选择必须可满足;AUTO 不允许按强度距离映射
|
||||||
applied = _settle_tier(effort, capability, model=model, fallback=fallback)
|
applied = _settle_tier(effort, capability, model=model, fallback=fallback)
|
||||||
return ThinkingResolution(_inject(profile, applied, model=model), applied)
|
return ThinkingResolution(_inject(profile, applied, model=model), applied)
|
||||||
|
|
||||||
@@ -544,12 +541,15 @@ def _settle_tier(
|
|||||||
) -> Effort:
|
) -> Effort:
|
||||||
"""Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。
|
"""Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。
|
||||||
|
|
||||||
`auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key"
|
AUTO 与强度档统一检查成员,但不参与最近强度映射。
|
||||||
(理由见 `resolve_thinking` 的 docstring)。
|
|
||||||
"""
|
"""
|
||||||
if effort is Effort.AUTO or effort in capability.supported_efforts:
|
if effort in capability.supported_efforts:
|
||||||
return effort
|
return effort
|
||||||
mapped = _nearest_effort(effort, capability) if fallback == "nearest" else None
|
mapped = (
|
||||||
|
_nearest_effort(effort, capability)
|
||||||
|
if fallback == "nearest" and effort is not Effort.AUTO
|
||||||
|
else None
|
||||||
|
)
|
||||||
if mapped is None:
|
if mapped is None:
|
||||||
raise ThinkingUnsupportedError(
|
raise ThinkingUnsupportedError(
|
||||||
_tier_unsupported(model, effort, capability, fallback=fallback)
|
_tier_unsupported(model, effort, capability, fallback=fallback)
|
||||||
@@ -634,7 +634,11 @@ def _tier_unsupported(
|
|||||||
else f"该模型只有开关、没有强度档位,可用: {listed}"
|
else f"该模型只有开关、没有强度档位,可用: {listed}"
|
||||||
)
|
)
|
||||||
# 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话
|
# 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话
|
||||||
hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
|
hint = (
|
||||||
|
""
|
||||||
|
if fallback == "nearest" or effort is Effort.AUTO
|
||||||
|
else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
|
||||||
|
)
|
||||||
return f"{head}{body}{hint}"
|
return f"{head}{body}{hint}"
|
||||||
|
|
||||||
|
|
||||||
@@ -676,7 +680,7 @@ def _warn_unregistered(
|
|||||||
) -> None:
|
) -> None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});"
|
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});"
|
||||||
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
|
"不保证开启、关闭或强度生效。实测后请用 register_capability 登记",
|
||||||
model,
|
model,
|
||||||
profile.name,
|
profile.name,
|
||||||
dict(payload),
|
dict(payload),
|
||||||
|
|||||||
@@ -57,14 +57,10 @@ class TestDefaultProfiles:
|
|||||||
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):
|
def test_minimax_on_does_not_select_a_tier(self):
|
||||||
"""issue #21 的权宜之计: minimax 的"开"必须真写一个档位值,不能是空片段。
|
"""形态不代替模型能力,也不替调用者选择付费档位。"""
|
||||||
|
|
||||||
断言反复过一次: T2 按"这些模型默认就推理"的推定把它改成 `{}`,T10 真实
|
|
||||||
网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium。
|
|
||||||
"""
|
|
||||||
w = get_provider("minimax").thinking
|
w = get_provider("minimax").thinking
|
||||||
assert w.on_base == {"reasoning_effort": "medium"}
|
assert w.on_base == {}
|
||||||
assert w.off == {"reasoning_effort": "none"}
|
assert w.off == {"reasoning_effort": "none"}
|
||||||
assert w.effort_key == "reasoning_effort"
|
assert w.effort_key == "reasoning_effort"
|
||||||
|
|
||||||
|
|||||||
+41
-19
@@ -285,16 +285,13 @@ class TestResolveThinking:
|
|||||||
get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest"
|
get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_phase4_only_blocks_the_off_direction(self):
|
@pytest.mark.parametrize("model", ["MiniMax-M2.5", "MiniMax-M2.7"])
|
||||||
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。
|
def test_phase4_only_blocks_the_off_direction(self, model):
|
||||||
|
"""已登记 AUTO 只发开启片段,不由库代选 medium。"""
|
||||||
期望片段 2026-09-05 由 `{}` 改成 minimax 的 `on_base` 实际值: issue #21 把
|
got = resolve_thinking(
|
||||||
该段的"开"改回带 medium(T2 的"开档不注入"是推定,T10 实测推翻)。本用例守的
|
get_provider("minimax"), get_capability(model), Effort.AUTO, model=model
|
||||||
是 Phase 4 只拦关闭方向,注入什么由 wire 决定,故随 wire 走。
|
)
|
||||||
"""
|
assert got.payload == {}
|
||||||
cap = get_capability("MiniMax-M2.7")
|
|
||||||
got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7")
|
|
||||||
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):
|
||||||
@@ -351,17 +348,42 @@ class TestResolveThinking:
|
|||||||
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"}
|
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"}
|
||||||
assert got.applied_effort is Effort.MAX
|
assert got.applied_effort is Effort.MAX
|
||||||
|
|
||||||
def test_auto_never_trips_phase5(self):
|
@pytest.mark.parametrize(
|
||||||
"""`auto` = 不指定档位,可满足性只取决于 wire 有没有 on_base。
|
"provider,model", [("deepseek", "deepseek-v4-pro"), ("minimax", "MiniMax-M3")]
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("fallback", ["error", "nearest"])
|
||||||
|
def test_unregistered_auto_choice_is_rejected(self, provider, model, fallback):
|
||||||
|
"""有开启形态也不代表已登记模型支持 AUTO,nearest 不可代选。"""
|
||||||
|
with pytest.raises(ThinkingUnsupportedError) as exc:
|
||||||
|
resolve_thinking(
|
||||||
|
get_provider(provider),
|
||||||
|
get_capability(model),
|
||||||
|
Effort.AUTO,
|
||||||
|
model=model,
|
||||||
|
fallback=fallback,
|
||||||
|
)
|
||||||
|
assert model in str(exc.value)
|
||||||
|
assert "auto" in str(exc.value)
|
||||||
|
assert "EFFORT_FALLBACK=nearest" not in str(exc.value)
|
||||||
|
|
||||||
它不是写进 `effort_key` 的取值,故不受档位清单约束。反过来判会让存量的
|
def test_minimax_explicit_medium_restores_old_wire(self):
|
||||||
`ENABLE_THINKING=true`(T5 起等价于 auto)在 deepseek/glm-5.3 这类清单里
|
got = resolve_thinking(
|
||||||
没有 auto 的模型上当场报错——设计 §12 明确承诺存量配置继续可跑。
|
get_provider("minimax"), get_capability("MiniMax-M3"), Effort.MEDIUM, model="MiniMax-M3"
|
||||||
"""
|
)
|
||||||
cap = get_capability("deepseek-v4-pro") # (none, high, max),清单里没有 auto
|
assert got.payload == {"reasoning_effort": "medium"}
|
||||||
got = resolve_thinking(get_provider("deepseek"), cap, Effort.AUTO, model="deepseek-v4-pro")
|
assert got.applied_effort is Effort.MEDIUM
|
||||||
assert got.payload == {"thinking": {"type": "enabled"}}
|
|
||||||
|
@pytest.mark.parametrize("provider", ["openai", "qwen"])
|
||||||
|
def test_unknown_auto_warns_without_promising_effect(self, provider):
|
||||||
|
messages, sink = _warnings()
|
||||||
|
try:
|
||||||
|
got = resolve_thinking(
|
||||||
|
get_provider(provider), None, Effort.AUTO, model="unregistered-model"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink)
|
||||||
assert got.applied_effort is Effort.AUTO
|
assert got.applied_effort is Effort.AUTO
|
||||||
|
assert any("不保证" in str(message) for message in messages)
|
||||||
|
|
||||||
# —— nearest 映射(fallback 的逃生口)——
|
# —— nearest 映射(fallback 的逃生口)——
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user