Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""))

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions tests/hermes_cli/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="***")
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/hermes_cli/test_user_providers_model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down