diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c9fe5f9ec..7737639b2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -134,6 +134,25 @@ In practice: - Prefer focused patches over broad rewrites - If a new abstraction is introduced, it should clearly reduce complexity rather than move it around +### Provider and Configuration Changes + +Provider behavior should have a single source of truth. When a provider needs +special request wiring, model routing, or compatibility behavior, prefer a +declarative `ProviderSpec` field in `nanobot/providers/registry.py` over a +parallel `if provider == ...` chain or model-name heuristic in request-building +code. + +Before adding provider-specific fallback logic, check whether the behavior can +be represented as metadata and handled by the shared provider path. Avoid adding +two mechanisms for the same decision. If a heuristic is unavoidable, keep it +narrow, document why metadata cannot express it, and add both positive and +negative tests for the matching boundary. + +Tests should verify observable behavior rather than tuple positions or other +incidental structure. For example, prefer asserting generated provider settings +or outgoing request kwargs over `provider_signature(...)[n]` unless the tuple +shape itself is the behavior under test. + ## Questions? If you have questions, ideas, or half-formed insights, you are warmly welcome here. diff --git a/docs/configuration.md b/docs/configuration.md index ec889c7589..557263dc67 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -653,6 +653,7 @@ That's it! Environment variables, model routing, config matching, and `nanobot s | `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` | | `strip_model_prefix` | Strip provider prefix before sending to gateway | `True` (for AiHubMix) | | `supports_max_completion_tokens` | Use `max_completion_tokens` instead of `max_tokens`; required for providers that reject both being set simultaneously (e.g. VolcEngine) | `True` | +| `reasoning_disable_style` | Provider-specific payload for explicitly disabling reasoning; prefer this metadata over model-name heuristics | `"reasoning_effort_null"` | diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index bb7ed0731e..b993758140 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -103,6 +103,20 @@ class AgentDefaults(Base): ) # Consolidation target ratio (0.5 = 50% of budget retained after compression) dream: DreamConfig = Field(default_factory=DreamConfig) + def effective_reasoning_effort(self) -> str | None: + """Return the provider-facing reasoning setting. + + An omitted field preserves provider defaults. An explicit JSON/YAML null + means "turn reasoning off", represented internally by the existing + ``"none"`` semantic so downstream code can distinguish it from omitted. + """ + if ( + self.reasoning_effort is None + and "reasoning_effort" in self.model_fields_set + ): + return "none" + return self.reasoning_effort + class AgentsConfig(Base): """Agent configuration.""" diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index d71390940a..606acf4e72 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -87,7 +87,7 @@ def make_provider(config: Config) -> LLMProvider: provider.generation = GenerationSettings( temperature=defaults.temperature, max_tokens=defaults.max_tokens, - reasoning_effort=defaults.reasoning_effort, + reasoning_effort=defaults.effective_reasoning_effort(), ) return provider @@ -97,6 +97,7 @@ def provider_signature(config: Config) -> tuple[object, ...]: model = config.agents.defaults.model defaults = config.agents.defaults p = config.get_provider(model) + reasoning_effort = defaults.effective_reasoning_effort() return ( model, defaults.provider, @@ -109,7 +110,7 @@ def provider_signature(config: Config) -> tuple[object, ...]: getattr(p, "profile", None) if p else None, defaults.max_tokens, defaults.temperature, - defaults.reasoning_effort, + reasoning_effort, defaults.context_window_tokens, ) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index decdccb3a6..66ea4e2a7b 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -91,6 +91,11 @@ def _is_kimi_thinking_model(model_name: str) -> bool: return False +def _needs_null_reasoning_disable(spec: ProviderSpec | None) -> bool: + """Return True for OpenAI-compatible routes that disable thinking via JSON null.""" + return bool(spec and spec.reasoning_disable_style == "reasoning_effort_null") + + def _openai_compat_timeout_s() -> float: """Return the bounded request timeout used for OpenAI-compatible providers.""" return _float_env("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", _OPENAI_COMPAT_REQUEST_TIMEOUT_S) @@ -585,6 +590,10 @@ def _build_kwargs( if wire_effort and semantic_effort != "none": kwargs["reasoning_effort"] = wire_effort + elif semantic_effort == "none" and _needs_null_reasoning_disable(spec): + # Put provider-specific null disables in extra_body so the OpenAI + # SDK cannot treat a top-level None as an omitted argument. + kwargs.setdefault("extra_body", {})["reasoning_effort"] = None # Provider-specific thinking parameters. # Only sent when reasoning_effort is explicitly configured so that diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 2e2bdbc50d..6d108d92f0 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -71,6 +71,12 @@ class ProviderSpec: # "reasoning_split" — {"reasoning_split": true/false} (MiniMax) thinking_style: str = "" + # How to explicitly disable provider-default reasoning when + # reasoning_effort resolves to "none". + # "" — no provider-specific disable payload + # "reasoning_effort_null" — {"reasoning_effort": null} in extra_body + reasoning_disable_style: str = "" + # When True, treat the "reasoning" response field as formal content # when "content" is empty. Only set this for providers (e.g. StepFun) # whose API returns the actual answer in "reasoning" instead of "content". @@ -375,6 +381,7 @@ def label(self) -> str: display_name="Xiaomi MIMO", backend="openai_compat", default_api_base="https://api.xiaomimimo.com/v1", + reasoning_disable_style="reasoning_effort_null", ), # LongCat: OpenAI-compatible API ProviderSpec( diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index a3b624171b..29e03ab6be 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -1134,6 +1134,19 @@ def test_dashscope_thinking_disabled_for_none_string() -> None: assert "reasoning_effort" not in kw +def test_xiaomi_mimo_none_sends_explicit_null_disable() -> None: + """MiMo needs an explicit JSON null to override its thinking-on default.""" + kw = _build_kwargs_for("xiaomi_mimo", "mimo-pro", reasoning_effort="none") + assert kw.get("extra_body") == {"reasoning_effort": None} + assert "reasoning_effort" not in kw + + +def test_openrouter_none_preserves_provider_default_without_disable_style() -> None: + kw = _build_kwargs_for("openrouter", "openai/gpt-4o", reasoning_effort="none") + assert "extra_body" not in kw + assert "reasoning_effort" not in kw + + def test_deepseek_no_backfill_when_reasoning_effort_none_string() -> None: """reasoning_effort='none' must NOT trigger reasoning_content backfill (thinking inactive).""" spec = find_by_name("deepseek") diff --git a/tests/providers/test_provider_factory.py b/tests/providers/test_provider_factory.py new file mode 100644 index 0000000000..b252c2bf30 --- /dev/null +++ b/tests/providers/test_provider_factory.py @@ -0,0 +1,39 @@ +from unittest.mock import patch + +from nanobot.config.schema import Config +from nanobot.providers.factory import make_provider + + +def test_explicit_null_reasoning_effort_becomes_provider_disable_signal() -> None: + config = Config.model_validate({ + "providers": {"xiaomiMimo": {"apiKey": "sk-test"}}, + "agents": { + "defaults": { + "provider": "xiaomi_mimo", + "model": "mimo-pro", + "reasoningEffort": None, + } + }, + }) + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = make_provider(config) + + assert provider.generation.reasoning_effort == "none" + + +def test_omitted_reasoning_effort_preserves_provider_default() -> None: + config = Config.model_validate({ + "providers": {"xiaomiMimo": {"apiKey": "sk-test"}}, + "agents": { + "defaults": { + "provider": "xiaomi_mimo", + "model": "mimo-pro", + } + }, + }) + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = make_provider(config) + + assert provider.generation.reasoning_effort is None