Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"` |

</details>

Expand Down
14 changes: 14 additions & 0 deletions nanobot/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
5 changes: 3 additions & 2 deletions nanobot/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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,
)

Expand Down
9 changes: 9 additions & 0 deletions nanobot/providers/openai_compat_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions nanobot/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions tests/providers/test_litellm_kwargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
39 changes: 39 additions & 0 deletions tests/providers/test_provider_factory.py
Original file line number Diff line number Diff line change
@@ -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
Loading