fix: modernize dependency recovery guidance (#5282)

This commit is contained in:
chengyongru
2026-08-07 13:58:13 +08:00
committed by GitHub
parent 02a002a0e6
commit ff6deda178
15 changed files with 113 additions and 29 deletions
@@ -27,7 +27,7 @@ nanobot agent -m "Hello!"
Install Langfuse: Install Langfuse:
```bash ```bash
python -m pip install langfuse nanobot plugins enable langfuse
``` ```
## Minimal working example ## Minimal working example
+1 -1
View File
@@ -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: Install the optional package in the same Python environment that runs nanobot:
```bash ```bash
python -m pip install langfuse nanobot plugins enable langfuse
``` ```
Set the environment variables before starting nanobot: Set the environment variables before starting nanobot:
+4 -1
View File
@@ -458,7 +458,10 @@ class WebSearchTool(Tool):
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType] Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
) )
except ImportError: 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) async_olostep = cast(Any, AsyncOlostep)
olostep_base_error = cast(type[Exception], Olostep_BaseError) olostep_base_error = cast(type[Exception], Olostep_BaseError)
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
+8 -2
View File
@@ -1683,7 +1683,10 @@ def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
encryptor = cipher_obj.encryptor() encryptor = cipher_obj.encryptor()
return encryptor.update(padded) + encryptor.finalize() return encryptor.update(padded) + encryptor.finalize()
except ImportError: 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 return data
@@ -1715,7 +1718,10 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
decryptor = cipher_obj.decryptor() decryptor = cipher_obj.decryptor()
decrypted = decryptor.update(data) + decryptor.finalize() decrypted = decryptor.update(data) + decryptor.finalize()
except ImportError: 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 data
return _pkcs7_unpad_safe(decrypted) return _pkcs7_unpad_safe(decrypted)
@@ -1080,6 +1080,32 @@ def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None:
assert decrypted == plaintext 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: class _DummyDownloadResponse:
def __init__(self, content: bytes, status_code: int = 200) -> None: def __init__(self, content: bytes, status_code: int = 200) -> None:
self.content = content self.content = content
+2 -1
View File
@@ -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.loader import get_config_path, load_config, resolve_config_env_vars
from nanobot.config.schema import Config, ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
console = Console() console = Console()
@@ -1674,7 +1675,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
login_oauth_interactive, login_oauth_interactive,
) )
except ImportError: 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 return False
try: try:
+6 -5
View File
@@ -12,6 +12,7 @@ import typer
from rich.console import Console from rich.console import Console
from nanobot import __logo__ from nanobot import __logo__
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.providers.registry import ProviderSpec 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]: 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 ( return (
cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")), cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
cast( cast(
@@ -85,7 +86,7 @@ def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]
def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]: 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 ( return (
cast( cast(
_OAuthProviderConfig, _OAuthProviderConfig,
@@ -241,7 +242,7 @@ def _login_openai_codex() -> None:
f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]" f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
) )
except ImportError: 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) raise typer.Exit(1)
@@ -250,7 +251,7 @@ def _logout_openai_codex() -> None:
try: try:
provider_config, storage_factory = _load_openai_oauth_storage() provider_config, storage_factory = _load_openai_oauth_storage()
except ImportError: 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) raise typer.Exit(1)
storage = storage_factory(token_filename=provider_config.token_filename) storage = storage_factory(token_filename=provider_config.token_filename)
@@ -309,7 +310,7 @@ def _logout_github_copilot() -> None:
try: try:
from nanobot.providers.github_copilot_provider import get_storage from nanobot.providers.github_copilot_provider import get_storage
except ImportError: 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) raise typer.Exit(1)
storage = get_storage() storage = get_storage()
+6
View File
@@ -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."
)
+1 -1
View File
@@ -586,7 +586,7 @@ class OpenAICompatProvider(LLMProvider):
if os.environ.get("LANGFUSE_SECRET_KEY"): if os.environ.get("LANGFUSE_SECRET_KEY"):
logger.warning( logger.warning(
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; " "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 from openai import AsyncOpenAI as _AsyncOpenAI
AsyncOpenAI = _AsyncOpenAI AsyncOpenAI = _AsyncOpenAI
+6 -13
View File
@@ -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. 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 # pyright: reportMissingTypeStubs=false
from __future__ import annotations from __future__ import annotations
@@ -36,6 +36,7 @@ from nanobot.providers.image_generation import (
get_image_gen_provider, get_image_gen_provider,
image_gen_provider_names, 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.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
from nanobot.security.network import is_loopback_host from nanobot.security.network import is_loopback_host
from nanobot.security.workspace_access import workspace_sandbox_status from nanobot.security.workspace_access import workspace_sandbox_status
@@ -1794,9 +1795,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
try: try:
from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login
except ImportError: except ImportError:
raise WebUISettingsError( raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
try: try:
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None 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, login_github_copilot,
) )
except ImportError: except ImportError:
raise WebUISettingsError( raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
token = get_github_copilot_login_status() token = get_github_copilot_login_status()
if not token: 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.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage from oauth_cli_kit.storage import FileTokenStorage
except ImportError: except ImportError:
raise WebUISettingsError( raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
_clear_webui_oauth_flows(spec.name) _clear_webui_oauth_flows(spec.name)
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path() token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
elif spec.name == "github_copilot": elif spec.name == "github_copilot":
try: try:
from nanobot.providers.github_copilot_provider import get_storage from nanobot.providers.github_copilot_provider import get_storage
except ImportError: except ImportError:
raise WebUISettingsError( raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
token_path = get_storage().get_token_path() token_path = get_storage().get_token_path()
elif spec.name == "xai_grok": elif spec.name == "xai_grok":
from nanobot.providers.xai_oauth import logout_xai_oauth from nanobot.providers.xai_oauth import logout_xai_oauth
+17
View File
@@ -1092,6 +1092,23 @@ class TestMainMenuUpdate:
assert config.providers.openai.api_key == "${UNRELATED_MISSING_KEY}" assert config.providers.openai.api_key == "${UNRELATED_MISSING_KEY}"
assert config.providers.openai_codex.proxy == "${CODEX_PROXY}" 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( def test_quick_start_openai_codex_runs_interactive_login_for_bad_cached_token(
self, monkeypatch self, monkeypatch
): ):
+4 -1
View File
@@ -686,7 +686,10 @@ def test_provider_login_openai_codex_handles_missing_oauth_symbol(monkeypatch):
result = runner.invoke(app, ["provider", "login", "openai-codex"]) result = runner.invoke(app, ["provider", "login", "openai-codex"])
assert result.exit_code == 1 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 assert result.exception is not None
@@ -1,5 +1,6 @@
from unittest.mock import patch, sentinel from unittest.mock import patch, sentinel
from nanobot.providers import openai_compat_provider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import ProviderSpec 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() await provider._ensure_client()
assert mock_async_openai.call_args.kwargs["timeout"] == 45.0 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()
+3 -1
View File
@@ -820,4 +820,6 @@ async def test_olostep_package_missing_returns_install_hint(monkeypatch):
tool = _tool(provider="olostep", api_key="olostep-key") tool = _tool(provider="olostep", api_key="olostep-key")
result = await tool.execute(query="test query") 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`."
)
+8 -2
View File
@@ -1624,7 +1624,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
with pytest.raises(WebUISettingsError) as exc: with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["openai-codex"]}) 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( 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: with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["github-copilot"]}) 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( def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(