mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-05 09:15:58 +00:00
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:
parent
58f8c04bd5
commit
415e617398
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
214
tests/providers/test_extra_body_config.py
Normal file
214
tests/providers/test_extra_body_config.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user