diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 064b1d68d1e2..62aac9703a51 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -116,6 +116,55 @@ def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: return updated_available, updated_unavailable +def _filter_doctor_tool_availability_for_platform( + available: list[str], + unavailable: list[dict], + config: dict, + platform: str = "cli", +) -> tuple[list[str], list[dict]]: + """Hide disabled or irrelevant optional toolsets from doctor output.""" + try: + from hermes_cli.tools_config import ( + CONFIGURABLE_TOOLSETS, + _get_platform_tools, + _get_plugin_toolset_keys, + ) + except Exception: + return available, unavailable + + enabled_toolsets = _get_platform_tools(config, platform) + configurable_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + configurable_toolsets.update(_get_plugin_toolset_keys()) + + def _should_keep_available(toolset_name: str) -> bool: + if toolset_name in configurable_toolsets: + return toolset_name in enabled_toolsets + if toolset_name.startswith("mcp-"): + mcp_name = toolset_name[4:] + return mcp_name in enabled_toolsets or toolset_name in enabled_toolsets + return True + + def _should_keep_unavailable(item: dict) -> bool: + toolset_name = item.get("name", "") + env_vars = item.get("missing_vars") or item.get("env_vars") or [] + if toolset_name == "web": + return False + if toolset_name in configurable_toolsets: + return toolset_name in enabled_toolsets + if toolset_name.startswith("mcp-"): + mcp_name = toolset_name[4:] + return mcp_name in enabled_toolsets or toolset_name in enabled_toolsets + if toolset_name not in enabled_toolsets: + return False + if env_vars and toolset_name not in enabled_toolsets: + return False + return True + + filtered_available = [toolset_name for toolset_name in available if _should_keep_available(toolset_name)] + filtered_unavailable = [item for item in unavailable if _should_keep_unavailable(item)] + return filtered_available, filtered_unavailable + + def check_ok(text: str, detail: str = ""): print(f" {color('✓', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else "")) @@ -1031,11 +1080,14 @@ def run_doctor(args): try: # Add project root to path for imports sys.path.insert(0, str(PROJECT_ROOT)) + from hermes_cli.config import load_config from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS - + + config = load_config() available, unavailable = check_tool_availability() available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable) - + available, unavailable = _filter_doctor_tool_availability_for_platform(available, unavailable, config) + for tid in available: info = TOOLSET_REQUIREMENTS.get(tid, {}) check_ok(info.get("name", tid)) @@ -1084,8 +1136,6 @@ def run_doctor(args): github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN") if github_token: check_ok("GitHub token configured (authenticated API access)") - else: - check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") # ========================================================================= # Memory Provider (only check the active provider, if any) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 41fbe36deb8b..7e64448024c9 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1242,6 +1242,18 @@ def list_authenticated_providers( ) if _pair[0] and _pair[1]: _section3_emitted_pairs.add(_pair) + # Also track the provider dict key + base URL. This covers the + # common case where `providers:` uses a friendly display name + # (e.g. "Ollama Local (WSL Bridge)") while compatibility-expanded + # `custom_providers:` entries use the provider slug as their name + # (e.g. "ollama-bridge"). Without this extra pair, /model emits + # two rows for the same endpoint. + _key_pair = ( + str(ep_name).strip().lower(), + str(api_url).strip().rstrip("/").lower(), + ) + if _key_pair[0] and _key_pair[1]: + _section3_emitted_pairs.add(_key_pair) # --- 4. Saved custom providers from config --- # Each ``custom_providers`` entry represents one model under a named diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 948cafaf71cf..fdd25d280ce0 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -74,6 +74,54 @@ def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch): assert unavailable == [honcho_entry] +class TestDoctorToolAvailabilityPlatformFiltering: + def test_hides_explicitly_disabled_configurable_toolsets(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", + lambda config, platform: {"web", "terminal", "notebooklm"}, + ) + monkeypatch.setattr( + "hermes_cli.tools_config._get_plugin_toolset_keys", + lambda: set(), + ) + + available, unavailable = doctor._filter_doctor_tool_availability_for_platform( + ["web", "terminal", "mcp-notebooklm"], + [ + {"name": "moa", "env_vars": ["OPENROUTER_API_KEY"], "tools": ["mixture_of_agents"]}, + {"name": "homeassistant", "env_vars": [], "tools": ["ha_call_service"]}, + {"name": "discord", "env_vars": ["DISCORD_BOT_TOKEN"], "tools": ["discord_send"]}, + ], + {"platform_toolsets": {"cli": ["web", "terminal", "notebooklm"]}}, + ) + + assert available == ["web", "terminal", "mcp-notebooklm"] + assert unavailable == [] + + def test_hides_enabled_web_api_warning_when_not_needed(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", + lambda config, platform: {"web", "terminal"}, + ) + monkeypatch.setattr( + "hermes_cli.tools_config._get_plugin_toolset_keys", + lambda: set(), + ) + + available, unavailable = doctor._filter_doctor_tool_availability_for_platform( + ["terminal"], + [ + {"name": "web", "env_vars": ["EXA_API_KEY"], "tools": ["web_search"]}, + {"name": "discord", "env_vars": ["DISCORD_BOT_TOKEN"], "tools": ["discord_send"]}, + {"name": "browser-cdp", "env_vars": [], "tools": ["browser_cdp"]}, + ], + {"platform_toolsets": {"cli": ["web", "terminal"]}}, + ) + + assert available == ["terminal"] + assert unavailable == [] + + class TestHonchoDoctorConfigDetection: def test_reports_configured_when_enabled_with_api_key(self, monkeypatch): fake_config = SimpleNamespace(enabled=True, api_key="***") @@ -188,6 +236,7 @@ def _run_doctor_and_capture(self, monkeypatch, tmp_path, provider=""): TOOLSET_REQUIREMENTS={}, ) monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "") # Stub auth checks to avoid real API calls try: @@ -210,6 +259,7 @@ def test_no_provider_shows_builtin_ok(self, monkeypatch, tmp_path): # Should NOT mention Honcho or Mem0 errors assert "Honcho API key" not in out assert "Mem0" not in out + assert "No GITHUB_TOKEN" not in out def test_honcho_provider_not_installed_shows_fail(self, monkeypatch, tmp_path): # Make honcho import fail diff --git a/tests/hermes_cli/test_user_providers_model_switch.py b/tests/hermes_cli/test_user_providers_model_switch.py index 989a6cbedc8b..94027a4d8654 100644 --- a/tests/hermes_cli/test_user_providers_model_switch.py +++ b/tests/hermes_cli/test_user_providers_model_switch.py @@ -352,6 +352,40 @@ def test_list_authenticated_providers_no_duplicate_labels_across_schemas(monkeyp ) +def test_list_authenticated_providers_no_duplicate_when_user_key_and_legacy_name_differ(monkeypatch): + """If ``providers:`` uses a friendly display name but compatibility-expanded + ``custom_providers:`` uses the provider dict key as its name, /model should still + emit a single row for that endpoint. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + current_provider="none", + user_providers={ + "ollama-bridge": { + "name": "Ollama Local (WSL Bridge)", + "base_url": "http://127.0.0.1:11500/v1", + "default_model": "glm-5.1:cloud", + } + }, + custom_providers=[ + { + "name": "ollama-bridge", + "base_url": "http://127.0.0.1:11500/v1", + "model": "glm-5.1:cloud", + } + ], + max_models=50, + ) + + user_rows = [p for p in providers if p.get("source") == "user-config"] + assert len(user_rows) == 1, user_rows + assert user_rows[0]["slug"] == "ollama-bridge" + assert user_rows[0]["name"] == "Ollama Local (WSL Bridge)" + assert user_rows[0]["models"] == ["glm-5.1:cloud"] + + # ============================================================================= # Tests for _get_named_custom_provider with providers: dict # =============================================================================