mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
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
114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
"""Create LLM providers from config."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from nanobot.config.schema import Config
|
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
|
from nanobot.providers.registry import find_by_name
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderSnapshot:
|
|
provider: LLMProvider
|
|
model: str
|
|
context_window_tokens: int
|
|
signature: tuple[object, ...]
|
|
|
|
|
|
def make_provider(config: Config) -> LLMProvider:
|
|
"""Create the LLM provider implied by config."""
|
|
model = config.agents.defaults.model
|
|
provider_name = config.get_provider_name(model)
|
|
p = config.get_provider(model)
|
|
spec = find_by_name(provider_name) if provider_name else None
|
|
backend = spec.backend if spec else "openai_compat"
|
|
|
|
if backend == "azure_openai":
|
|
if not p or not p.api_key or not p.api_base:
|
|
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
|
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
|
needs_key = not (p and p.api_key)
|
|
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
|
if needs_key and not exempt:
|
|
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
|
|
|
if backend == "openai_codex":
|
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
|
|
|
provider = OpenAICodexProvider(default_model=model)
|
|
elif backend == "azure_openai":
|
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
|
|
|
provider = AzureOpenAIProvider(
|
|
api_key=p.api_key,
|
|
api_base=p.api_base,
|
|
default_model=model,
|
|
)
|
|
elif backend == "github_copilot":
|
|
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
|
|
|
provider = GitHubCopilotProvider(default_model=model)
|
|
elif backend == "anthropic":
|
|
from nanobot.providers.anthropic_provider import AnthropicProvider
|
|
|
|
provider = AnthropicProvider(
|
|
api_key=p.api_key if p else None,
|
|
api_base=config.get_api_base(model),
|
|
default_model=model,
|
|
extra_headers=p.extra_headers if p else None,
|
|
)
|
|
else:
|
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
|
|
|
provider = OpenAICompatProvider(
|
|
api_key=p.api_key if p else None,
|
|
api_base=config.get_api_base(model),
|
|
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
|
|
provider.generation = GenerationSettings(
|
|
temperature=defaults.temperature,
|
|
max_tokens=defaults.max_tokens,
|
|
reasoning_effort=defaults.reasoning_effort,
|
|
)
|
|
return provider
|
|
|
|
|
|
def provider_signature(config: Config) -> tuple[object, ...]:
|
|
"""Return the config fields that affect the primary LLM provider."""
|
|
model = config.agents.defaults.model
|
|
defaults = config.agents.defaults
|
|
return (
|
|
model,
|
|
defaults.provider,
|
|
config.get_provider_name(model),
|
|
config.get_api_key(model),
|
|
config.get_api_base(model),
|
|
defaults.max_tokens,
|
|
defaults.temperature,
|
|
defaults.reasoning_effort,
|
|
defaults.context_window_tokens,
|
|
)
|
|
|
|
|
|
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
|
|
return ProviderSnapshot(
|
|
provider=make_provider(config),
|
|
model=config.agents.defaults.model,
|
|
context_window_tokens=config.agents.defaults.context_window_tokens,
|
|
signature=provider_signature(config),
|
|
)
|
|
|
|
|
|
def load_provider_snapshot(config_path: Path | None = None) -> ProviderSnapshot:
|
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
|
|
|
return build_provider_snapshot(resolve_config_env_vars(load_config(config_path)))
|