From ff6deda178824f9d90f1753caa88afa42f2fd3fd Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:58:13 +0800 Subject: [PATCH] fix: modernize dependency recovery guidance (#5282) --- .../configure-langfuse-observability.md | 2 +- docs/provider-cookbook.md | 2 +- nanobot/agent/tools/web.py | 5 +++- nanobot/channels/weixin/runtime.py | 10 +++++-- .../weixin/tests/test_weixin_channel.py | 26 +++++++++++++++++++ nanobot/cli/onboard.py | 3 ++- nanobot/cli/provider.py | 11 ++++---- nanobot/providers/oauth_guidance.py | 6 +++++ nanobot/providers/openai_compat_provider.py | 2 +- nanobot/webui/settings_api.py | 19 +++++--------- tests/agent/test_onboard_logic.py | 17 ++++++++++++ tests/cli/test_commands.py | 5 +++- tests/providers/test_openai_compat_timeout.py | 20 ++++++++++++++ tests/tools/test_web_search_tool.py | 4 ++- tests/webui/test_settings_api.py | 10 +++++-- 15 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 nanobot/providers/oauth_guidance.py diff --git a/docs/guides/configure-langfuse-observability.md b/docs/guides/configure-langfuse-observability.md index 140a24d05..b979f4c99 100644 --- a/docs/guides/configure-langfuse-observability.md +++ b/docs/guides/configure-langfuse-observability.md @@ -27,7 +27,7 @@ nanobot agent -m "Hello!" Install Langfuse: ```bash -python -m pip install langfuse +nanobot plugins enable langfuse ``` ## Minimal working example diff --git a/docs/provider-cookbook.md b/docs/provider-cookbook.md index f7a6b40db..3eb2ef509 100644 --- a/docs/provider-cookbook.md +++ b/docs/provider-cookbook.md @@ -549,7 +549,7 @@ This recipe applies after the agent works and you want observability for OpenAI- Install the optional package in the same Python environment that runs nanobot: ```bash -python -m pip install langfuse +nanobot plugins enable langfuse ``` Set the environment variables before starting nanobot: diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index c2d8e011c..ec4523a86 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -458,7 +458,10 @@ class WebSearchTool(Tool): Olostep_BaseError, # pyright: ignore[reportUnknownVariableType] ) except ImportError: - return ToolResult.error("Error: olostep package not installed. Run: pip install olostep") + return ToolResult.error( + "Error: Olostep support is not installed. " + "Run `nanobot plugins enable olostep`." + ) async_olostep = cast(Any, AsyncOlostep) olostep_base_error = cast(type[Exception], Olostep_BaseError) api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") diff --git a/nanobot/channels/weixin/runtime.py b/nanobot/channels/weixin/runtime.py index bd2e49166..3d1e3af01 100644 --- a/nanobot/channels/weixin/runtime.py +++ b/nanobot/channels/weixin/runtime.py @@ -1683,7 +1683,10 @@ def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes: encryptor = cipher_obj.encryptor() return encryptor.update(padded) + encryptor.finalize() except ImportError: - logger.warning("Cannot encrypt media: install 'pycryptodome' or 'cryptography'") + logger.warning( + "Cannot encrypt media. Run `nanobot plugins enable weixin` " + "to install WeChat support." + ) return data @@ -1715,7 +1718,10 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes: decryptor = cipher_obj.decryptor() decrypted = decryptor.update(data) + decryptor.finalize() except ImportError: - logger.warning("Cannot decrypt media: install 'pycryptodome' or 'cryptography'") + logger.warning( + "Cannot decrypt media. Run `nanobot plugins enable weixin` " + "to install WeChat support." + ) return data return _pkcs7_unpad_safe(decrypted) diff --git a/nanobot/channels/weixin/tests/test_weixin_channel.py b/nanobot/channels/weixin/tests/test_weixin_channel.py index 1338a7162..46ba4e058 100644 --- a/nanobot/channels/weixin/tests/test_weixin_channel.py +++ b/nanobot/channels/weixin/tests/test_weixin_channel.py @@ -1080,6 +1080,32 @@ def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None: assert decrypted == plaintext +def test_missing_aes_dependency_recommends_weixin_plugin(monkeypatch) -> None: + real_import = __import__ + + def fake_import(name, *args, **kwargs): + if name.startswith(("Crypto", "cryptography")): + raise ImportError("missing AES dependency") + return real_import(name, *args, **kwargs) + + warnings: list[str] = [] + monkeypatch.setattr("builtins.__import__", fake_import) + monkeypatch.setattr( + weixin_mod.logger, + "warning", + lambda message, *args: warnings.append(message.format(*args)), + ) + key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg==" + data = b"unencrypted media" + + assert _encrypt_aes_ecb(data, key_b64) == data + assert _decrypt_aes_ecb(data, key_b64) == data + assert warnings == [ + "Cannot encrypt media. Run `nanobot plugins enable weixin` to install WeChat support.", + "Cannot decrypt media. Run `nanobot plugins enable weixin` to install WeChat support.", + ] + + class _DummyDownloadResponse: def __init__(self, content: bytes, status_code: int = 200) -> None: self.content = content diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 27571a438..b28647f7c 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -32,6 +32,7 @@ from nanobot.cli.models import ( ) from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars from nanobot.config.schema import Config, ModelPresetConfig +from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE console = Console() @@ -1674,7 +1675,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool: login_oauth_interactive, ) except ImportError: - console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]") return False try: diff --git a/nanobot/cli/provider.py b/nanobot/cli/provider.py index 3d2c0a626..d0769bc99 100644 --- a/nanobot/cli/provider.py +++ b/nanobot/cli/provider.py @@ -12,6 +12,7 @@ import typer from rich.console import Console from nanobot import __logo__ +from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE if TYPE_CHECKING: from nanobot.providers.registry import ProviderSpec @@ -74,7 +75,7 @@ def _required_module_attribute(module_name: str, attribute: str) -> object: def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]: - """Load the optional untyped OAuth client behind a typed boundary.""" + """Load the untyped OAuth client behind a typed boundary.""" return ( cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")), cast( @@ -85,7 +86,7 @@ def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive] def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]: - """Load the optional untyped OAuth storage API behind a typed boundary.""" + """Load the untyped OAuth storage API behind a typed boundary.""" return ( cast( _OAuthProviderConfig, @@ -241,7 +242,7 @@ def _login_openai_codex() -> None: f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]" ) except ImportError: - console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]") raise typer.Exit(1) @@ -250,7 +251,7 @@ def _logout_openai_codex() -> None: try: provider_config, storage_factory = _load_openai_oauth_storage() except ImportError: - console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]") raise typer.Exit(1) storage = storage_factory(token_filename=provider_config.token_filename) @@ -309,7 +310,7 @@ def _logout_github_copilot() -> None: try: from nanobot.providers.github_copilot_provider import get_storage except ImportError: - console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]") raise typer.Exit(1) storage = get_storage() diff --git a/nanobot/providers/oauth_guidance.py b/nanobot/providers/oauth_guidance.py new file mode 100644 index 000000000..1f3315402 --- /dev/null +++ b/nanobot/providers/oauth_guidance.py @@ -0,0 +1,6 @@ +"""Shared recovery guidance for OAuth dependency failures.""" + +OAUTH_CLI_KIT_MISSING_MESSAGE = ( + "This nanobot installation is missing the required oauth-cli-kit package. " + "Reinstall or upgrade nanobot-ai using the same installation method." +) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index e66f98d3d..d6e7e47bd 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -586,7 +586,7 @@ class OpenAICompatProvider(LLMProvider): if os.environ.get("LANGFUSE_SECRET_KEY"): logger.warning( "LANGFUSE_SECRET_KEY is set but langfuse is not installed; " - "install with `pip install langfuse` to enable tracing" + "run `nanobot plugins enable langfuse` to enable tracing" ) from openai import AsyncOpenAI as _AsyncOpenAI AsyncOpenAI = _AsyncOpenAI diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 877c35b03..1c77a293e 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -4,7 +4,7 @@ The WebSocket channel owns transport/authentication. This module owns the settings payload shape and the allowlisted config mutations exposed to WebUI. """ -# oauth-cli-kit is an optional dependency and does not publish type stubs. +# oauth-cli-kit does not publish type stubs. # pyright: reportMissingTypeStubs=false from __future__ import annotations @@ -36,6 +36,7 @@ from nanobot.providers.image_generation import ( get_image_gen_provider, image_gen_provider_names, ) +from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name from nanobot.security.network import is_loopback_host from nanobot.security.workspace_access import workspace_sandbox_status @@ -1794,9 +1795,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: try: from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login except ImportError: - raise WebUISettingsError( - "oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500 - ) from None + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None try: proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None @@ -1834,9 +1833,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: login_github_copilot, ) except ImportError: - raise WebUISettingsError( - "oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500 - ) from None + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None token = get_github_copilot_login_status() if not token: @@ -1934,18 +1931,14 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]: from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER from oauth_cli_kit.storage import FileTokenStorage except ImportError: - raise WebUISettingsError( - "oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500 - ) from None + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None _clear_webui_oauth_flows(spec.name) token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path() elif spec.name == "github_copilot": try: from nanobot.providers.github_copilot_provider import get_storage except ImportError: - raise WebUISettingsError( - "oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500 - ) from None + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None token_path = get_storage().get_token_path() elif spec.name == "xai_grok": from nanobot.providers.xai_oauth import logout_xai_oauth diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 7a28133ec..e075f4511 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -1092,6 +1092,23 @@ class TestMainMenuUpdate: assert config.providers.openai.api_key == "${UNRELATED_MISSING_KEY}" assert config.providers.openai_codex.proxy == "${CODEX_PROXY}" + def test_quick_start_openai_codex_reports_incomplete_installation(self, monkeypatch): + import oauth_cli_kit + + messages: list[str] = [] + monkeypatch.delattr(oauth_cli_kit, "get_token") + monkeypatch.setattr( + onboard_wizard.console, + "print", + lambda message, *args, **kwargs: messages.append(str(message)), + ) + + assert onboard_wizard._quick_start_oauth_login(Config(), "openai_codex") is False + assert messages == [ + "[red]This nanobot installation is missing the required oauth-cli-kit package. " + "Reinstall or upgrade nanobot-ai using the same installation method.[/red]" + ] + def test_quick_start_openai_codex_runs_interactive_login_for_bad_cached_token( self, monkeypatch ): diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 510d59164..4c79c91a0 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -686,7 +686,10 @@ def test_provider_login_openai_codex_handles_missing_oauth_symbol(monkeypatch): result = runner.invoke(app, ["provider", "login", "openai-codex"]) assert result.exit_code == 1 - assert "oauth_cli_kit not installed" in result.stdout + assert ( + "This nanobot installation is missing the required oauth-cli-kit package. " + "Reinstall or upgrade nanobot-ai using the same installation method." + ) in re.sub(r"\s+", " ", result.stdout) assert result.exception is not None diff --git a/tests/providers/test_openai_compat_timeout.py b/tests/providers/test_openai_compat_timeout.py index 519d22ec2..fd9a38752 100644 --- a/tests/providers/test_openai_compat_timeout.py +++ b/tests/providers/test_openai_compat_timeout.py @@ -1,5 +1,6 @@ from unittest.mock import patch, sentinel +from nanobot.providers import openai_compat_provider from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.registry import ProviderSpec @@ -59,3 +60,22 @@ async def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypat await provider._ensure_client() assert mock_async_openai.call_args.kwargs["timeout"] == 45.0 + + +async def test_missing_langfuse_warning_recommends_plugin_command(monkeypatch) -> None: + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret") + monkeypatch.setattr(openai_compat_provider, "AsyncOpenAI", None) + + with ( + patch("importlib.util.find_spec", return_value=None), + patch("openai.AsyncOpenAI") as mock_async_openai, + patch("nanobot.providers.openai_compat_provider.logger.warning") as mock_warning, + ): + provider = OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1") + await provider._ensure_client() + + mock_warning.assert_called_once_with( + "LANGFUSE_SECRET_KEY is set but langfuse is not installed; " + "run `nanobot plugins enable langfuse` to enable tracing" + ) + mock_async_openai.assert_called_once() diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index c9130b5d3..82eb68c1c 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -820,4 +820,6 @@ async def test_olostep_package_missing_returns_install_hint(monkeypatch): tool = _tool(provider="olostep", api_key="olostep-key") result = await tool.execute(query="test query") - assert result == "Error: olostep package not installed. Run: pip install olostep" + assert result == ( + "Error: Olostep support is not installed. Run `nanobot plugins enable olostep`." + ) diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 3d92b0356..1151d902f 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -1624,7 +1624,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit( with pytest.raises(WebUISettingsError) as exc: login_oauth_provider({"provider": ["openai-codex"]}) - assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value) + assert str(exc.value) == ( + "This nanobot installation is missing the required oauth-cli-kit package. " + "Reinstall or upgrade nanobot-ai using the same installation method." + ) def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit( @@ -1642,7 +1645,10 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit( with pytest.raises(WebUISettingsError) as exc: login_oauth_provider({"provider": ["github-copilot"]}) - assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value) + assert str(exc.value) == ( + "This nanobot installation is missing the required oauth-cli-kit package. " + "Reinstall or upgrade nanobot-ai using the same installation method." + ) def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(