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}")
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)

View File

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

View File

@ -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] = {}

View File

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

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):
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"

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):
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(

View File

@ -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"

View File

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

View File

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