refactor: preserve cold-start lazy boundaries

This commit is contained in:
Xubin Ren 2026-05-20 11:56:14 +08:00
parent af9f8d54b8
commit 38a5f09f02
9 changed files with 71 additions and 25 deletions

View File

@ -36,12 +36,14 @@ def load_channel_class(module_name: str) -> type[BaseChannel]:
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}") 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.""" """Discover external channel plugins registered via entry_points."""
from importlib.metadata import entry_points from importlib.metadata import entry_points
plugins: dict[str, type[BaseChannel]] = {} plugins: dict[str, type[BaseChannel]] = {}
for ep in entry_points(group="nanobot.channels"): for ep in entry_points(group="nanobot.channels"):
if enabled_names is not None and ep.name not in enabled_names:
continue
try: try:
cls = ep.load() cls = ep.load()
plugins[ep.name] = cls plugins[ep.name] = cls
@ -72,7 +74,7 @@ def discover_enabled(
except ImportError as e: except ImportError as e:
logger.debug("Skipping built-in channel '{}': {}", modname, 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) shadowed = set(external) & set(result)
if shadowed: if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed) logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)

View File

@ -207,8 +207,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
async def _refresh_client_api_key(self) -> str: async def _refresh_client_api_key(self) -> str:
token = await self._get_copilot_access_token() token = await self._get_copilot_access_token()
client = await self._ensure_client()
self.api_key = token self.api_key = token
self._client.api_key = token client.api_key = token
return token return token
async def chat( async def chat(

View File

@ -307,14 +307,10 @@ class OpenAICompatProvider(LLMProvider):
self._is_local = _is_local_endpoint(spec, effective_base) self._is_local = _is_local_endpoint(spec, effective_base)
# Lazy-init: the OpenAI client and its httpx transport are expensive # Lazy-init: the OpenAI client and its httpx transport are expensive
# to create (~700 ms on Windows). Defer until first use — unless # to create (~700 ms on Windows). Defer until first use.
# AsyncOpenAI has been patched (tests), in which case build eagerly.
self._client: AsyncOpenAIType | None = None self._client: AsyncOpenAIType | None = None
self._client_lock = asyncio.Lock() self._client_lock = asyncio.Lock()
if AsyncOpenAI is not None:
self._build_client()
# Responses API circuit breaker: skip after repeated failures, # Responses API circuit breaker: skip after repeated failures,
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds. # probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
self._responses_failures: dict[str, int] = {} self._responses_failures: dict[str, int] = {}

View File

@ -111,6 +111,23 @@ def test_discover_plugins_loads_entry_points():
assert result["line"] is _FakePlugin 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(): def test_discover_plugins_handles_load_error():
from nanobot.channels.registry import discover_plugins from nanobot.channels.registry import discover_plugins
@ -152,6 +169,25 @@ def test_discover_all_includes_external_plugin():
assert result["line"] is _FakePlugin 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(): def test_discover_all_builtin_shadows_plugin():
from nanobot.channels.registry import discover_all from nanobot.channels.registry import discover_all

View File

@ -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): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client):
provider = GitHubCopilotProvider(default_model="github-copilot/gpt-4") provider = GitHubCopilotProvider(default_model="github-copilot/gpt-4")
await provider._ensure_client()
provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token") 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: 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 kwargs = mock_async_openai.call_args.kwargs
assert kwargs["api_key"] == "test-key" assert kwargs["api_key"] == "test-key"

View File

@ -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): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client):
provider = GitHubCopilotProvider(default_model="github_copilot/gpt-5.4-mini") 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") provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token")
response = await provider.chat( response = await provider.chat(

View File

@ -449,27 +449,28 @@ def test_gemma_routes_to_gemini_provider() -> None:
assert "gemma" in spec.keywords 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") spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls:
OpenAICompatProvider( provider = OpenAICompatProvider(
api_key="sk-or-test-key", api_key="sk-or-test-key",
api_base="https://openrouter.ai/api/v1", api_base="https://openrouter.ai/api/v1",
default_model="anthropic/claude-sonnet-4-5", default_model="anthropic/claude-sonnet-4-5",
spec=spec, 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["HTTP-Referer"] == "https://github.com/HKUDS/nanobot"
assert headers["X-OpenRouter-Title"] == "nanobot" assert headers["X-OpenRouter-Title"] == "nanobot"
assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent" assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent"
assert "x-session-affinity" in headers 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") spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls:
OpenAICompatProvider( provider = OpenAICompatProvider(
api_key="sk-or-test-key", api_key="sk-or-test-key",
api_base="https://openrouter.ai/api/v1", api_base="https://openrouter.ai/api/v1",
default_model="anthropic/claude-sonnet-4-5", default_model="anthropic/claude-sonnet-4-5",
@ -480,8 +481,9 @@ def test_openrouter_user_headers_override_default_attribution() -> None:
}, },
spec=spec, 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["HTTP-Referer"] == "https://nanobot.ai"
assert headers["X-OpenRouter-Title"] == "Nanobot Pro" assert headers["X-OpenRouter-Title"] == "Nanobot Pro"
assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent" assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent"

View File

@ -8,16 +8,18 @@ def _assert_openai_compat_timeout(timeout) -> None:
assert timeout == 120.0 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: 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 kwargs = mock_async_openai.call_args.kwargs
_assert_openai_compat_timeout(kwargs["timeout"]) _assert_openai_compat_timeout(kwargs["timeout"])
assert kwargs["http_client"] is None 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( spec = ProviderSpec(
name="local", name="local",
keywords=(), keywords=(),
@ -33,7 +35,9 @@ def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None:
return_value=sentinel.http_client, return_value=sentinel.http_client,
) as mock_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 client_kwargs = mock_http_client.call_args.kwargs
_assert_openai_compat_timeout(client_kwargs["timeout"]) _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 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") monkeypatch.setenv("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", "45")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai: 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 assert mock_async_openai.call_args.kwargs["timeout"] == 45.0

View File

@ -5,9 +5,10 @@ from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider 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: 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 kwargs = mock_client.call_args.kwargs
assert kwargs["max_retries"] == 0 assert kwargs["max_retries"] == 0