diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9dda551d0..0ecd450f4 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -241,6 +241,15 @@ class ProvidersConfig(Base): qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆) nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys) + @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 + @model_validator(mode="after") def _validate_api_type_scope(self) -> "ProvidersConfig": for name in self.__class__.model_fields: @@ -249,15 +258,9 @@ class ProvidersConfig(Base): provider = getattr(self, name, None) if isinstance(provider, ProviderConfig) and provider.api_type != "auto": 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) + for provider in (self.model_extra or {}).values(): + if isinstance(provider, ProviderConfig) and provider.api_type != "auto": + raise ValueError("providers..api_type is only supported for providers.openai") return self @@ -478,10 +481,7 @@ class Config(BaseSettings): 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) + for attr_name, p in (self.providers.model_extra or {}).items(): if isinstance(p, ProviderConfig) and p.api_base: return p, attr_name diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index d4371bd10..37e90bbe5 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -8,7 +8,7 @@ from pathlib import Path from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig from nanobot.providers.base import LLMProvider from nanobot.providers.fallback_provider import FallbackProvider -from nanobot.providers.registry import find_by_name +from nanobot.providers.registry import create_dynamic_spec, find_by_name @dataclass(frozen=True) @@ -41,6 +41,8 @@ def _make_provider_core( provider_name = config.get_provider_name(model, preset=resolved) p = config.get_provider(model, preset=resolved) spec = find_by_name(provider_name) if provider_name else None + if provider_name and not spec and p: + spec = create_dynamic_spec(provider_name) if spec and spec.is_transcription_only: raise ValueError(f"Provider '{provider_name}' only supports transcription.") backend = spec.backend if spec else "openai_compat" diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 3e30de858..b107a2c25 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -664,6 +664,30 @@ def test_make_provider_passes_extra_headers_to_custom_provider(): assert kwargs["default_headers"]["x-session-affinity"] == "sticky-session" +def test_make_provider_treats_dynamic_custom_provider_as_direct(): + config = Config.model_validate( + { + "agents": {"defaults": {"provider": "my-company-api", "model": "gpt-4o-mini"}}, + "providers": { + "my-company-api": { + "apiBase": "https://example.com/v1", + } + }, + } + ) + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai: + provider = make_provider(config) + asyncio.run(provider._ensure_client()) + + assert provider.get_default_model() == "gpt-4o-mini" + assert provider._spec.name == "my_company_api" + assert provider._spec.is_direct is True + kwargs = mock_async_openai.call_args.kwargs + assert kwargs["api_key"] == "no-key" + assert kwargs["base_url"] == "https://example.com/v1" + + @pytest.fixture def mock_agent_runtime(tmp_path): """Mock agent command dependencies for focused CLI tests.""" diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py index d36127df9..933f46119 100644 --- a/tests/config/test_model_presets.py +++ b/tests/config/test_model_presets.py @@ -1,3 +1,5 @@ +import warnings + import pytest from nanobot.config.schema import Config @@ -47,6 +49,35 @@ def test_provider_api_type_is_openai_only() -> None: } }) + with pytest.raises(ValueError, match="only supported"): + Config.model_validate({ + "providers": { + "my-company-api": { + "apiBase": "https://example.test/v1", + "apiType": "responses", + } + } + }) + + +def test_custom_provider_fallback_uses_model_extra_without_pydantic_warnings() -> None: + config = Config.model_validate({ + "agents": { + "defaults": { + "model": "unmatched-model", + } + }, + "providers": { + "my-company-api": { + "apiBase": "https://example.test/v1", + } + }, + }) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert config.get_provider_name() == "my-company-api" + def test_legacy_defaults_config_without_presets_still_resolves() -> None: config = Config.model_validate({