feat(providers): add extra_body config for OpenAI-compatible endpoints

Add an `extra_body` field to `ProviderConfig` that merges arbitrary
key-value pairs into every OpenAI-compatible request body. This is the
escape hatch for provider-specific features that nanobot does not have
first-class fields for.

Real-world use cases this unblocks via config alone (no code changes):
- vLLM/TGI `chat_template_kwargs` (e.g. `enable_thinking: false`)
- vLLM guided decoding (`guided_json`, `guided_regex`)
- Local model sampling params (`repetition_penalty`, `top_k`, `min_p`)
- Any future provider-specific param without a new PR each time

The config extra_body is applied last via recursive deep-merge, so it
can extend or override provider-specific defaults (e.g. thinking
params) without clobbering sibling keys set by internal logic.

Changes:
- Add `extra_body: dict[str, Any] | None` to `ProviderConfig`
- Pass it through `factory.py` to `OpenAICompatProvider.__init__`
- Deep-merge into `_build_kwargs` after all internal extra_body entries
- Add `_deep_merge` helper (recursive dict merge, does not mutate inputs)
- 21 tests: deep-merge semantics, provider init, _build_kwargs
  integration, thinking coexistence, real-world patterns (guided_json,
  repetition_penalty), and schema validation
This commit is contained in:
hussein1362 2026-04-27 21:48:28 +03:00 committed by Xubin Ren
parent 58f8c04bd5
commit 415e617398
4 changed files with 247 additions and 1 deletions

View File

@ -1,7 +1,7 @@
"""Configuration schema using Pydantic.""" """Configuration schema using Pydantic."""
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Any, Literal
from pydantic import AliasChoices, BaseModel, ConfigDict, Field from pydantic import AliasChoices, BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
@ -116,6 +116,7 @@ class ProviderConfig(Base):
api_key: str | None = None api_key: str | None = None
api_base: str | None = None api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) 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): class ProvidersConfig(Base):

View File

@ -69,6 +69,7 @@ def make_provider(config: Config) -> LLMProvider:
default_model=model, default_model=model,
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
spec=spec, spec=spec,
extra_body=p.extra_body if p else None,
) )
defaults = config.agents.defaults defaults = config.agents.defaults

View File

@ -232,6 +232,25 @@ def _responses_circuit_key(
return f"{model_name}:{effort}" 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): class OpenAICompatProvider(LLMProvider):
"""Unified provider for all OpenAI-compatible APIs. """Unified provider for all OpenAI-compatible APIs.
@ -246,11 +265,13 @@ class OpenAICompatProvider(LLMProvider):
default_model: str = "gpt-4o", default_model: str = "gpt-4o",
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
spec: ProviderSpec | None = None, spec: ProviderSpec | None = None,
extra_body: dict[str, Any] | None = None,
): ):
super().__init__(api_key, api_base) super().__init__(api_key, api_base)
self.default_model = default_model self.default_model = default_model
self.extra_headers = extra_headers or {} self.extra_headers = extra_headers or {}
self._spec = spec self._spec = spec
self._extra_body = extra_body or {}
if api_key and spec and spec.env_key: if api_key and spec and spec.env_key:
self._setup_env(api_key, api_base) 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: if msg.get("role") == "assistant" and "reasoning_content" not in msg:
msg["reasoning_content"] = "" 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 return kwargs
def _should_use_responses_api( def _should_use_responses_api(

View File

@ -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