Skip to content

Commit 3cecc89

Browse files
committed
fix: honor null reasoning effort disable
1 parent fde530d commit 3cecc89

6 files changed

Lines changed: 130 additions & 2 deletions

File tree

nanobot/config/schema.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ class AgentDefaults(Base):
103103
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
104104
dream: DreamConfig = Field(default_factory=DreamConfig)
105105

106+
def effective_reasoning_effort(self) -> str | None:
107+
"""Return the provider-facing reasoning setting.
108+
109+
An omitted field preserves provider defaults. An explicit JSON/YAML null
110+
means "turn reasoning off", represented internally by the existing
111+
``"none"`` semantic so downstream code can distinguish it from omitted.
112+
"""
113+
if (
114+
self.reasoning_effort is None
115+
and "reasoning_effort" in self.model_fields_set
116+
):
117+
return "none"
118+
return self.reasoning_effort
119+
106120

107121
class AgentsConfig(Base):
108122
"""Agent configuration."""

nanobot/providers/factory.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def make_provider(config: Config) -> LLMProvider:
8787
provider.generation = GenerationSettings(
8888
temperature=defaults.temperature,
8989
max_tokens=defaults.max_tokens,
90-
reasoning_effort=defaults.reasoning_effort,
90+
reasoning_effort=defaults.effective_reasoning_effort(),
9191
)
9292
return provider
9393

@@ -97,6 +97,7 @@ def provider_signature(config: Config) -> tuple[object, ...]:
9797
model = config.agents.defaults.model
9898
defaults = config.agents.defaults
9999
p = config.get_provider(model)
100+
reasoning_effort = defaults.effective_reasoning_effort()
100101
return (
101102
model,
102103
defaults.provider,
@@ -109,7 +110,7 @@ def provider_signature(config: Config) -> tuple[object, ...]:
109110
getattr(p, "profile", None) if p else None,
110111
defaults.max_tokens,
111112
defaults.temperature,
112-
defaults.reasoning_effort,
113+
reasoning_effort,
113114
defaults.context_window_tokens,
114115
)
115116

nanobot/providers/openai_compat_provider.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
"kimi-k2.6",
6161
"k2.6-code-preview",
6262
})
63+
_NULL_REASONING_DISABLE_MARKERS = frozenset({"mimo", "xiaomi", "xiaomimimo"})
64+
_NULL_REASONING_DISABLE_PREFIXES = ("mimo-", "mimo_", "mimo.")
6365
_OPENAI_COMPAT_REQUEST_TIMEOUT_S = 120.0
6466

6567
# Maps ProviderSpec.thinking_style → extra_body builder.
@@ -91,6 +93,29 @@ def _is_kimi_thinking_model(model_name: str) -> bool:
9193
return False
9294

9395

96+
def _model_looks_like_null_reasoning_route(model_name: str) -> bool:
97+
"""Return True for model ids that are known to disable reasoning via null.
98+
99+
Gateway models usually arrive as publisher/model slugs. Match whole path
100+
parts or known MiMo prefixes instead of arbitrary substrings so unrelated
101+
names like "mimosa-pro" do not receive Xiaomi-specific payloads.
102+
"""
103+
parts = tuple(part for part in model_name.lower().replace(":", "/").split("/") if part)
104+
for part in parts:
105+
if part in _NULL_REASONING_DISABLE_MARKERS:
106+
return True
107+
if part.startswith(_NULL_REASONING_DISABLE_PREFIXES):
108+
return True
109+
return False
110+
111+
112+
def _needs_null_reasoning_disable(spec: ProviderSpec | None, model_name: str) -> bool:
113+
"""Return True for OpenAI-compatible routes that disable thinking via JSON null."""
114+
if spec and spec.reasoning_disable_style == "reasoning_effort_null":
115+
return True
116+
return _model_looks_like_null_reasoning_route(model_name)
117+
118+
94119
def _openai_compat_timeout_s() -> float:
95120
"""Return the bounded request timeout used for OpenAI-compatible providers."""
96121
return _float_env("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", _OPENAI_COMPAT_REQUEST_TIMEOUT_S)
@@ -585,6 +610,12 @@ def _build_kwargs(
585610

586611
if wire_effort and semantic_effort != "none":
587612
kwargs["reasoning_effort"] = wire_effort
613+
elif semantic_effort == "none" and _needs_null_reasoning_disable(spec, model_name):
614+
# Some OpenAI-compatible thinking models, notably Xiaomi MiMo
615+
# directly and through routers, require an explicit JSON null to
616+
# override the provider/model default. Put it in extra_body so the
617+
# OpenAI SDK cannot treat a top-level None as an omitted argument.
618+
kwargs.setdefault("extra_body", {})["reasoning_effort"] = None
588619

589620
# Provider-specific thinking parameters.
590621
# Only sent when reasoning_effort is explicitly configured so that

nanobot/providers/registry.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ class ProviderSpec:
7171
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
7272
thinking_style: str = ""
7373

74+
# How to explicitly disable provider-default reasoning when
75+
# reasoning_effort resolves to "none".
76+
# "" — no provider-specific disable payload
77+
# "reasoning_effort_null" — {"reasoning_effort": null} in extra_body
78+
reasoning_disable_style: str = ""
79+
7480
# When True, treat the "reasoning" response field as formal content
7581
# when "content" is empty. Only set this for providers (e.g. StepFun)
7682
# whose API returns the actual answer in "reasoning" instead of "content".
@@ -375,6 +381,7 @@ def label(self) -> str:
375381
display_name="Xiaomi MIMO",
376382
backend="openai_compat",
377383
default_api_base="https://api.xiaomimimo.com/v1",
384+
reasoning_disable_style="reasoning_effort_null",
378385
),
379386
# LongCat: OpenAI-compatible API
380387
ProviderSpec(

tests/providers/test_litellm_kwargs.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,40 @@ def test_dashscope_thinking_disabled_for_none_string() -> None:
11341134
assert "reasoning_effort" not in kw
11351135

11361136

1137+
def test_xiaomi_mimo_none_sends_explicit_null_disable() -> None:
1138+
"""MiMo needs an explicit JSON null to override its thinking-on default."""
1139+
kw = _build_kwargs_for("xiaomi_mimo", "mimo-pro", reasoning_effort="none")
1140+
assert kw.get("extra_body") == {"reasoning_effort": None}
1141+
assert "reasoning_effort" not in kw
1142+
1143+
1144+
def test_openrouter_mimo_none_sends_explicit_null_disable() -> None:
1145+
"""OpenRouter MiMo routes also need the null disable signal passed through."""
1146+
kw = _build_kwargs_for("openrouter", "xiaomi/mimo-pro", reasoning_effort="none")
1147+
assert kw.get("extra_body") == {"reasoning_effort": None}
1148+
assert "reasoning_effort" not in kw
1149+
1150+
1151+
def test_openrouter_non_mimo_none_preserves_provider_default() -> None:
1152+
kw = _build_kwargs_for("openrouter", "openai/gpt-4o", reasoning_effort="none")
1153+
assert "extra_body" not in kw
1154+
assert "reasoning_effort" not in kw
1155+
1156+
1157+
def test_custom_mimo_none_sends_explicit_null_disable() -> None:
1158+
"""Custom Xiaomi-compatible routes still get the MiMo disable signal by model id."""
1159+
kw = _build_kwargs_for("custom", "mimo-pro", reasoning_effort="none")
1160+
assert kw.get("extra_body") == {"reasoning_effort": None}
1161+
assert "reasoning_effort" not in kw
1162+
1163+
1164+
def test_mimosa_model_name_does_not_match_mimo_defense() -> None:
1165+
"""Avoid sending Xiaomi-specific null payloads for substring-only matches."""
1166+
kw = _build_kwargs_for("openrouter", "example/mimosa-pro", reasoning_effort="none")
1167+
assert "extra_body" not in kw
1168+
assert "reasoning_effort" not in kw
1169+
1170+
11371171
def test_deepseek_no_backfill_when_reasoning_effort_none_string() -> None:
11381172
"""reasoning_effort='none' must NOT trigger reasoning_content backfill (thinking inactive)."""
11391173
spec = find_by_name("deepseek")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from unittest.mock import patch
2+
3+
from nanobot.config.schema import Config
4+
from nanobot.providers.factory import make_provider, provider_signature
5+
6+
7+
def test_explicit_null_reasoning_effort_becomes_provider_disable_signal() -> None:
8+
config = Config.model_validate({
9+
"providers": {"xiaomiMimo": {"apiKey": "sk-test"}},
10+
"agents": {
11+
"defaults": {
12+
"provider": "xiaomi_mimo",
13+
"model": "mimo-pro",
14+
"reasoningEffort": None,
15+
}
16+
},
17+
})
18+
19+
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
20+
provider = make_provider(config)
21+
22+
assert provider.generation.reasoning_effort == "none"
23+
assert provider_signature(config)[11] == "none"
24+
25+
26+
def test_omitted_reasoning_effort_preserves_provider_default() -> None:
27+
config = Config.model_validate({
28+
"providers": {"xiaomiMimo": {"apiKey": "sk-test"}},
29+
"agents": {
30+
"defaults": {
31+
"provider": "xiaomi_mimo",
32+
"model": "mimo-pro",
33+
}
34+
},
35+
})
36+
37+
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
38+
provider = make_provider(config)
39+
40+
assert provider.generation.reasoning_effort is None
41+
assert provider_signature(config)[11] is None

0 commit comments

Comments
 (0)