diff --git a/nanobot/channels/registry.py b/nanobot/channels/registry.py index f2dd628d3..53d90e444 100644 --- a/nanobot/channels/registry.py +++ b/nanobot/channels/registry.py @@ -36,12 +36,14 @@ def load_channel_class(module_name: str) -> type[BaseChannel]: raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}") -def discover_plugins() -> dict[str, type[BaseChannel]]: +def discover_plugins(enabled_names: set[str] | None = None) -> dict[str, type[BaseChannel]]: """Discover external channel plugins registered via entry_points.""" from importlib.metadata import entry_points plugins: dict[str, type[BaseChannel]] = {} for ep in entry_points(group="nanobot.channels"): + if enabled_names is not None and ep.name not in enabled_names: + continue try: cls = ep.load() plugins[ep.name] = cls @@ -72,7 +74,7 @@ def discover_enabled( except ImportError as e: logger.debug("Skipping built-in channel '{}': {}", modname, e) - external = discover_plugins() + external = discover_plugins(None if _include_all_external else enabled_names) shadowed = set(external) & set(result) if shadowed: logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed) diff --git a/nanobot/providers/github_copilot_provider.py b/nanobot/providers/github_copilot_provider.py index bec7c11e1..35bd8a546 100644 --- a/nanobot/providers/github_copilot_provider.py +++ b/nanobot/providers/github_copilot_provider.py @@ -207,8 +207,9 @@ class GitHubCopilotProvider(OpenAICompatProvider): async def _refresh_client_api_key(self) -> str: token = await self._get_copilot_access_token() + client = await self._ensure_client() self.api_key = token - self._client.api_key = token + client.api_key = token return token async def chat( diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index e7f872525..b8112b529 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -307,14 +307,10 @@ class OpenAICompatProvider(LLMProvider): self._is_local = _is_local_endpoint(spec, effective_base) # Lazy-init: the OpenAI client and its httpx transport are expensive - # to create (~700 ms on Windows). Defer until first use — unless - # AsyncOpenAI has been patched (tests), in which case build eagerly. + # to create (~700 ms on Windows). Defer until first use. self._client: AsyncOpenAIType | None = None self._client_lock = asyncio.Lock() - if AsyncOpenAI is not None: - self._build_client() - # Responses API circuit breaker: skip after repeated failures, # probe again after _RESPONSES_PROBE_INTERVAL_S seconds. self._responses_failures: dict[str, int] = {} diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index ed315d5d0..38b3fbb7e 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -111,6 +111,23 @@ def test_discover_plugins_loads_entry_points(): assert result["line"] is _FakePlugin +def test_discover_plugins_skips_names_outside_enabled_set(): + from nanobot.channels.registry import discover_plugins + + loaded: list[str] = [] + + def _load_disabled(): + loaded.append("disabled") + return _FakePlugin + + ep = SimpleNamespace(name="disabled", load=_load_disabled) + with patch(_EP_TARGET, return_value=[ep]): + result = discover_plugins({"enabled"}) + + assert result == {} + assert loaded == [] + + def test_discover_plugins_handles_load_error(): from nanobot.channels.registry import discover_plugins @@ -152,6 +169,25 @@ def test_discover_all_includes_external_plugin(): assert result["line"] is _FakePlugin +def test_discover_enabled_imports_only_enabled_builtins(): + from nanobot.channels.registry import discover_enabled + + loaded: list[str] = [] + + def _load_channel(name: str): + loaded.append(name) + return _FakePlugin + + with ( + patch("nanobot.channels.registry.load_channel_class", side_effect=_load_channel), + patch(_EP_TARGET, return_value=[]), + ): + result = discover_enabled({"enabled"}, _names=["enabled", "disabled"]) + + assert result == {"enabled": _FakePlugin} + assert loaded == ["enabled"] + + def test_discover_all_builtin_shadows_plugin(): from nanobot.channels.registry import discover_all diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 2778ddbbb..1dede1e13 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -572,6 +572,7 @@ async def test_github_copilot_provider_refreshes_client_api_key_before_chat(): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client): provider = GitHubCopilotProvider(default_model="github-copilot/gpt-4") + await provider._ensure_client() provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token") @@ -611,7 +612,8 @@ def test_make_provider_passes_extra_headers_to_custom_provider(): ) with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai: - make_provider(config) + provider = make_provider(config) + asyncio.run(provider._ensure_client()) kwargs = mock_async_openai.call_args.kwargs assert kwargs["api_key"] == "test-key" diff --git a/tests/providers/test_github_copilot_routing.py b/tests/providers/test_github_copilot_routing.py index 90e4cb4d4..3b6c194d9 100644 --- a/tests/providers/test_github_copilot_routing.py +++ b/tests/providers/test_github_copilot_routing.py @@ -65,6 +65,7 @@ async def test_github_copilot_does_not_fall_back_from_responses_error(): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client): provider = GitHubCopilotProvider(default_model="github_copilot/gpt-5.4-mini") + await provider._ensure_client() provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token") response = await provider.chat( diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 3acb2e76c..5f2ffec59 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -449,27 +449,28 @@ def test_gemma_routes_to_gemini_provider() -> None: assert "gemma" in spec.keywords -def test_openrouter_sets_default_attribution_headers() -> None: +async def test_openrouter_sets_default_attribution_headers() -> None: spec = find_by_name("openrouter") - with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: - OpenAICompatProvider( + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls: + provider = OpenAICompatProvider( api_key="sk-or-test-key", api_base="https://openrouter.ai/api/v1", default_model="anthropic/claude-sonnet-4-5", spec=spec, ) + await provider._ensure_client() - headers = MockClient.call_args.kwargs["default_headers"] + headers = mock_client_cls.call_args.kwargs["default_headers"] assert headers["HTTP-Referer"] == "https://github.com/HKUDS/nanobot" assert headers["X-OpenRouter-Title"] == "nanobot" assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent" assert "x-session-affinity" in headers -def test_openrouter_user_headers_override_default_attribution() -> None: +async def test_openrouter_user_headers_override_default_attribution() -> None: spec = find_by_name("openrouter") - with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: - OpenAICompatProvider( + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls: + provider = OpenAICompatProvider( api_key="sk-or-test-key", api_base="https://openrouter.ai/api/v1", default_model="anthropic/claude-sonnet-4-5", @@ -480,8 +481,9 @@ def test_openrouter_user_headers_override_default_attribution() -> None: }, spec=spec, ) + await provider._ensure_client() - headers = MockClient.call_args.kwargs["default_headers"] + headers = mock_client_cls.call_args.kwargs["default_headers"] assert headers["HTTP-Referer"] == "https://nanobot.ai" assert headers["X-OpenRouter-Title"] == "Nanobot Pro" assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent" diff --git a/tests/providers/test_openai_compat_timeout.py b/tests/providers/test_openai_compat_timeout.py index dade6bee1..98241fcdc 100644 --- a/tests/providers/test_openai_compat_timeout.py +++ b/tests/providers/test_openai_compat_timeout.py @@ -8,16 +8,18 @@ def _assert_openai_compat_timeout(timeout) -> None: assert timeout == 120.0 -def test_openai_compat_provider_sets_sdk_timeout() -> None: +async def test_openai_compat_provider_defers_sdk_client_until_first_use() -> None: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai: - OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1") + provider = OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1") + mock_async_openai.assert_not_called() + await provider._ensure_client() kwargs = mock_async_openai.call_args.kwargs _assert_openai_compat_timeout(kwargs["timeout"]) assert kwargs["http_client"] is None -def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None: +async def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None: spec = ProviderSpec( name="local", keywords=(), @@ -33,7 +35,9 @@ def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None: return_value=sentinel.http_client, ) as mock_http_client, ): - OpenAICompatProvider(spec=spec) + provider = OpenAICompatProvider(spec=spec) + mock_async_openai.assert_not_called() + await provider._ensure_client() client_kwargs = mock_http_client.call_args.kwargs _assert_openai_compat_timeout(client_kwargs["timeout"]) @@ -44,10 +48,11 @@ def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None: assert openai_kwargs["http_client"] is sentinel.http_client -def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypatch) -> None: +async def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypatch) -> None: monkeypatch.setenv("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", "45") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai: - OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1") + provider = OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1") + await provider._ensure_client() assert mock_async_openai.call_args.kwargs["timeout"] == 45.0 diff --git a/tests/providers/test_provider_sdk_retry_defaults.py b/tests/providers/test_provider_sdk_retry_defaults.py index b73c50517..cbd8ba837 100644 --- a/tests/providers/test_provider_sdk_retry_defaults.py +++ b/tests/providers/test_provider_sdk_retry_defaults.py @@ -5,9 +5,10 @@ from nanobot.providers.azure_openai_provider import AzureOpenAIProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider -def test_openai_compat_disables_sdk_retries_by_default() -> None: +async def test_openai_compat_disables_sdk_retries_by_default() -> None: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client: - OpenAICompatProvider(api_key="sk-test", default_model="gpt-4o") + provider = OpenAICompatProvider(api_key="sk-test", default_model="gpt-4o") + await provider._ensure_client() kwargs = mock_client.call_args.kwargs assert kwargs["max_retries"] == 0