diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 674f0a931..4a9bd97a2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,7 +1,7 @@ """Configuration schema using Pydantic.""" from pathlib import Path -from typing import Literal +from typing import Any, Literal from pydantic import AliasChoices, BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel @@ -116,6 +116,7 @@ class ProviderConfig(Base): api_key: str | None = None api_base: str | None = None extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) + extra_body: dict[str, Any] | None = None # Extra fields merged into every request body class ProvidersConfig(Base): diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index f4ec6a4c2..5f97b04a3 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -69,6 +69,7 @@ def make_provider(config: Config) -> LLMProvider: default_model=model, extra_headers=p.extra_headers if p else None, spec=spec, + extra_body=p.extra_body if p else None, ) defaults = config.agents.defaults diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index afd6a6b37..363a2e142 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -232,6 +232,25 @@ def _responses_circuit_key( return f"{model_name}:{effort}" +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*, returning a new dict. + + Nested dicts are merged key-by-key; all other types in *override* + replace the corresponding key in *base*. + """ + merged = dict(base) + for key, value in override.items(): + if ( + key in merged + and isinstance(merged[key], dict) + and isinstance(value, dict) + ): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + class OpenAICompatProvider(LLMProvider): """Unified provider for all OpenAI-compatible APIs. @@ -246,11 +265,13 @@ class OpenAICompatProvider(LLMProvider): default_model: str = "gpt-4o", extra_headers: dict[str, str] | None = None, spec: ProviderSpec | None = None, + extra_body: dict[str, Any] | None = None, ): super().__init__(api_key, api_base) self.default_model = default_model self.extra_headers = extra_headers or {} self._spec = spec + self._extra_body = extra_body or {} if api_key and spec and spec.env_key: self._setup_env(api_key, api_base) @@ -597,6 +618,15 @@ class OpenAICompatProvider(LLMProvider): if msg.get("role") == "assistant" and "reasoning_content" not in msg: msg["reasoning_content"] = "" + # Merge user-configured extra_body last so it can override or + # extend provider-specific defaults (e.g. chat_template_kwargs, + # guided_json, repetition_penalty). Uses recursive merge so + # nested dicts like {"chat_template_kwargs": {"enable_thinking": false}} + # do not clobber sibling keys already set by thinking-style logic. + if self._extra_body: + existing = kwargs.get("extra_body", {}) + kwargs["extra_body"] = _deep_merge(existing, self._extra_body) + return kwargs def _should_use_responses_api( diff --git a/tests/providers/test_extra_body_config.py b/tests/providers/test_extra_body_config.py new file mode 100644 index 000000000..08ca33408 --- /dev/null +++ b/tests/providers/test_extra_body_config.py @@ -0,0 +1,214 @@ +"""Tests for provider extra_body config injection into request payloads.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from nanobot.providers.openai_compat_provider import ( + OpenAICompatProvider, + _deep_merge, +) + +# --------------------------------------------------------------------------- +# _deep_merge unit tests +# --------------------------------------------------------------------------- + + +class TestDeepMerge: + """Verify recursive dict merge semantics.""" + + def test_flat_merge(self) -> None: + assert _deep_merge({"a": 1}, {"b": 2}) == {"a": 1, "b": 2} + + def test_override_scalar(self) -> None: + assert _deep_merge({"a": 1}, {"a": 2}) == {"a": 2} + + def test_nested_merge(self) -> None: + base = {"outer": {"a": 1, "b": 2}} + override = {"outer": {"b": 3, "c": 4}} + assert _deep_merge(base, override) == {"outer": {"a": 1, "b": 3, "c": 4}} + + def test_deeply_nested(self) -> None: + base = {"l1": {"l2": {"a": 1}}} + override = {"l1": {"l2": {"b": 2}}} + assert _deep_merge(base, override) == {"l1": {"l2": {"a": 1, "b": 2}}} + + def test_override_replaces_non_dict_with_dict(self) -> None: + assert _deep_merge({"a": 1}, {"a": {"nested": True}}) == {"a": {"nested": True}} + + def test_override_replaces_dict_with_scalar(self) -> None: + assert _deep_merge({"a": {"nested": True}}, {"a": "flat"}) == {"a": "flat"} + + def test_empty_base(self) -> None: + assert _deep_merge({}, {"a": 1}) == {"a": 1} + + def test_empty_override(self) -> None: + assert _deep_merge({"a": 1}, {}) == {"a": 1} + + def test_does_not_mutate_inputs(self) -> None: + base = {"a": {"x": 1}} + override = {"a": {"y": 2}} + _deep_merge(base, override) + assert base == {"a": {"x": 1}} + assert override == {"a": {"y": 2}} + + +# --------------------------------------------------------------------------- +# Provider construction +# --------------------------------------------------------------------------- + + +class TestExtraBodyInit: + """Verify the provider stores extra_body from config.""" + + def test_default_is_empty(self) -> None: + provider = OpenAICompatProvider(api_key="test") + assert provider._extra_body == {} + + def test_none_becomes_empty(self) -> None: + provider = OpenAICompatProvider(api_key="test", extra_body=None) + assert provider._extra_body == {} + + def test_dict_stored(self) -> None: + body = {"chat_template_kwargs": {"enable_thinking": False}} + provider = OpenAICompatProvider(api_key="test", extra_body=body) + assert provider._extra_body == body + + +# --------------------------------------------------------------------------- +# _build_kwargs integration +# --------------------------------------------------------------------------- + + +def _make_provider(extra_body: dict[str, Any] | None = None) -> OpenAICompatProvider: + return OpenAICompatProvider( + api_key="test-key", + default_model="test-model", + extra_body=extra_body, + ) + + +def _simple_messages() -> list[dict[str, Any]]: + return [{"role": "user", "content": "hello"}] + + +class TestBuildKwargsExtraBody: + """Verify extra_body flows into _build_kwargs output.""" + + def test_no_extra_body_no_key(self) -> None: + provider = _make_provider() + kwargs = provider._build_kwargs( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort=None, tool_choice=None, + ) + assert "extra_body" not in kwargs + + def test_extra_body_injected(self) -> None: + provider = _make_provider({"chat_template_kwargs": {"enable_thinking": False}}) + kwargs = provider._build_kwargs( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort=None, tool_choice=None, + ) + assert kwargs["extra_body"] == { + "chat_template_kwargs": {"enable_thinking": False}, + } + + def test_extra_body_merges_with_thinking(self) -> None: + """Config extra_body should merge with (and override) thinking params.""" + from nanobot.providers.registry import ProviderSpec + + spec = MagicMock(spec=ProviderSpec) + spec.thinking_style = "deepseek" + spec.supports_prompt_caching = False + spec.strip_model_prefix = False + spec.model_overrides = [] + spec.name = "custom" + spec.supports_max_completion_tokens = False + spec.env_key = None + spec.default_api_base = None + spec.is_local = True + spec.detect_by_base_keyword = None + + provider = OpenAICompatProvider( + api_key="test", + default_model="deepseek-v3", + spec=spec, + extra_body={"custom_param": "value"}, + ) + kwargs = provider._build_kwargs( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort="high", tool_choice=None, + ) + body = kwargs.get("extra_body", {}) + # Config param should be present + assert body.get("custom_param") == "value" + + def test_nested_extra_body_does_not_clobber_siblings(self) -> None: + """Nested dict merge should preserve sibling keys.""" + provider = _make_provider({ + "chat_template_kwargs": {"enable_thinking": False}, + }) + # Simulate internal code having set a sibling key + # by manually calling _build_kwargs — the internal logic + # doesn't set chat_template_kwargs, so we test the merge path + # by having extra_body itself contain nested keys + kwargs = provider._build_kwargs( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort=None, tool_choice=None, + ) + assert kwargs["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False + + def test_guided_json_injection(self) -> None: + """Real-world use case: vLLM guided decoding.""" + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + provider = _make_provider({"guided_json": schema}) + kwargs = provider._build_kwargs( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort=None, tool_choice=None, + ) + assert kwargs["extra_body"]["guided_json"] == schema + + def test_repetition_penalty_injection(self) -> None: + """Real-world use case: local model sampling param.""" + provider = _make_provider({"repetition_penalty": 1.15}) + kwargs = provider._build_kwargs( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort=None, tool_choice=None, + ) + assert kwargs["extra_body"]["repetition_penalty"] == 1.15 + + +# --------------------------------------------------------------------------- +# Schema validation +# --------------------------------------------------------------------------- + + +class TestSchemaConfig: + """Verify ProviderConfig accepts extra_body.""" + + def test_default_is_none(self) -> None: + from nanobot.config.schema import ProviderConfig + + config = ProviderConfig() + assert config.extra_body is None + + def test_accepts_dict(self) -> None: + from nanobot.config.schema import ProviderConfig + + config = ProviderConfig(extra_body={"guided_json": {"type": "object"}}) + assert config.extra_body == {"guided_json": {"type": "object"}} + + def test_nested_dict(self) -> None: + from nanobot.config.schema import ProviderConfig + + config = ProviderConfig( + extra_body={"chat_template_kwargs": {"enable_thinking": False}} + ) + assert config.extra_body["chat_template_kwargs"]["enable_thinking"] is False