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
This commit is contained in:
wangjingguang002 2026-04-17 11:21:06 +08:00 committed by Xubin Ren
parent 2d9260cb9f
commit e9e1489cee
2 changed files with 55 additions and 2 deletions

View File

@ -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.<name>.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(

View File

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