From e9e1489ceece4bd6dfdff3879e936fc06bf5d604 Mon Sep 17 00:00:00 2001 From: wangjingguang002 Date: Fri, 17 Apr 2026 11:21:06 +0800 Subject: [PATCH] feat: support multiple custom OpenAI-compatible providers This change allows users to define arbitrary custom providers in config: providers: my_provider: api_base: ... api_key: sk-xxx Usage: nanobot /my_provider/gpt-4 hello nanobot --provider my_provider hello Changes: - ProvidersConfig: add extra=allow to accept arbitrary fields - _match_provider: check for custom provider by prefix and by fallback - registry: add create_dynamic_spec() for dynamic provider specs --- nanobot/config/schema.py | 44 +++++++++++++++++++++++++++++++++-- nanobot/providers/registry.py | 13 +++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ac69f8a28..9dda551d0 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -194,7 +194,13 @@ class BedrockProviderConfig(ProviderConfig): class ProvidersConfig(Base): - """Configuration for LLM providers.""" + """Configuration for LLM providers. + + Supports custom providers via extra fields — any additional field + becomes an OpenAI-compatible custom provider. + """ + + model_config = ConfigDict(extra="allow") custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name) @@ -245,6 +251,15 @@ class ProvidersConfig(Base): raise ValueError("providers..api_type is only supported for providers.openai") return self + @model_validator(mode="after") + def convert_extra_providers(self): + """Convert extra fields (custom providers) to ProviderConfig objects.""" + if self.model_extra: + for key, value in self.model_extra.items(): + if isinstance(value, dict): + self.model_extra[key] = ProviderConfig.model_validate(value) + return self + class HeartbeatConfig(Base): """Heartbeat service configuration (now backed by cron).""" @@ -381,7 +396,10 @@ class Config(BaseSettings): preset: ModelPresetConfig | None = None, ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" - from nanobot.providers.registry import PROVIDERS, find_by_name + from nanobot.providers.registry import ( + PROVIDERS, + find_by_name, + ) resolved = preset or self.resolve_preset() forced = resolved.provider @@ -390,6 +408,11 @@ class Config(BaseSettings): if spec: p = getattr(self.providers, spec.name, None) return (p, spec.name) if p else (None, None) + # Check for custom provider by name (try both original and normalized) + for name_to_try in (forced, forced.replace("-", "_")): + p = getattr(self.providers, name_to_try, None) + if p and isinstance(p, ProviderConfig): + return p, name_to_try return None, None model_lower = (model or resolved.model).lower() @@ -410,6 +433,14 @@ class Config(BaseSettings): if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key: return p, spec.name + # Check for custom provider by prefix (e.g., "myprovider/gpt-4") + # Try both original prefix and normalized (snake_case) prefix + if model_prefix: + for prefix_to_try in (model_prefix, normalized_prefix): + p = getattr(self.providers, prefix_to_try, None) + if p and isinstance(p, ProviderConfig) and p.api_base: + return p, prefix_to_try + # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: if spec.is_transcription_only: @@ -445,6 +476,15 @@ class Config(BaseSettings): p = getattr(self.providers, spec.name, None) if p and p.api_key: return p, spec.name + + # Final fallback: check for any configured custom provider + for attr_name in dir(self.providers): + if attr_name.startswith("_"): + continue + p = getattr(self.providers, attr_name, None) + if isinstance(p, ProviderConfig) and p.api_base: + return p, attr_name + return None, None def get_provider( diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 1beb14cdf..940a675f0 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -545,3 +545,16 @@ def find_by_name(name: str) -> ProviderSpec | None: if spec.name == normalized: return spec return None + + +def create_dynamic_spec(name: str) -> ProviderSpec: + """Create a dynamic ProviderSpec for custom user-defined providers.""" + normalized = to_snake(name.replace("-", "_")) + return ProviderSpec( + name=normalized, + keywords=(), + env_key="", + display_name=name.title(), + backend="openai_compat", + is_direct=True, + )