Compare commits

...

2 Commits

Author SHA1 Message Date
Carlos
3f054e92d8 Refresh OpenRouter model catalog
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 02:49:27 -07:00
Carlos
a2fbb1eea3 Harden setup provider flows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 02:49:23 -07:00
11 changed files with 534 additions and 76 deletions

View File

@@ -2581,7 +2581,7 @@ def _prompt_model_selection(
custom = input("Enter model name: ").strip() custom = input("Enter model name: ").strip()
return custom if custom else None return custom if custom else None
return None return None
except (ImportError, NotImplementedError): except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass pass
# Fallback: numbered list # Fallback: numbered list

View File

@@ -858,7 +858,6 @@ def cmd_whatsapp(args):
def cmd_setup(args): def cmd_setup(args):
"""Interactive setup wizard.""" """Interactive setup wizard."""
_require_tty("setup")
from hermes_cli.setup import run_setup_wizard from hermes_cli.setup import run_setup_wizard
run_setup_wizard(args) run_setup_wizard(args)
@@ -968,10 +967,11 @@ def select_provider_and_model(args=None):
("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), ("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
] ]
# Add user-defined custom providers from config.yaml def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
custom_providers_cfg = config.get("custom_providers") or [] custom_providers_cfg = cfg.get("custom_providers") or []
_custom_provider_map = {} # key → {name, base_url, api_key} custom_provider_map = {}
if isinstance(custom_providers_cfg, list): if not isinstance(custom_providers_cfg, list):
return custom_provider_map
for entry in custom_providers_cfg: for entry in custom_providers_cfg:
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
@@ -980,16 +980,23 @@ def select_provider_and_model(args=None):
if not name or not base_url: if not name or not base_url:
continue continue
key = "custom:" + name.lower().replace(" ", "-") key = "custom:" + name.lower().replace(" ", "-")
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/") custom_provider_map[key] = {
saved_model = entry.get("model", "")
model_hint = f"{saved_model}" if saved_model else ""
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
_custom_provider_map[key] = {
"name": name, "name": name,
"base_url": base_url, "base_url": base_url,
"api_key": entry.get("api_key", ""), "api_key": entry.get("api_key", ""),
"model": saved_model, "model": entry.get("model", ""),
} }
return custom_provider_map
# Add user-defined custom providers from config.yaml
_custom_provider_map = _named_custom_provider_map(config) # key → {name, base_url, api_key}
for key, provider_info in _custom_provider_map.items():
name = provider_info["name"]
base_url = provider_info["base_url"]
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
saved_model = provider_info.get("model", "")
model_hint = f"{saved_model}" if saved_model else ""
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
top_keys = {k for k, _ in top_providers} top_keys = {k for k, _ in top_providers}
extended_keys = {k for k, _ in extended_providers} extended_keys = {k for k, _ in extended_providers}
@@ -1054,8 +1061,15 @@ def select_provider_and_model(args=None):
_model_flow_copilot(config, current_model) _model_flow_copilot(config, current_model)
elif selected_provider == "custom": elif selected_provider == "custom":
_model_flow_custom(config) _model_flow_custom(config)
elif selected_provider.startswith("custom:") and selected_provider in _custom_provider_map: elif selected_provider.startswith("custom:"):
_model_flow_named_custom(config, _custom_provider_map[selected_provider]) provider_info = _named_custom_provider_map(load_config()).get(selected_provider)
if provider_info is None:
print(
"Warning: the selected saved custom provider is no longer available. "
"It may have been removed from config.yaml. No change."
)
return
_model_flow_named_custom(config, provider_info)
elif selected_provider == "remove-custom": elif selected_provider == "remove-custom":
_remove_custom_provider(config) _remove_custom_provider(config)
elif selected_provider == "anthropic": elif selected_provider == "anthropic":
@@ -1128,10 +1142,10 @@ def _model_flow_openrouter(config, current_model=""):
print() print()
from hermes_cli.models import model_ids, get_pricing_for_provider from hermes_cli.models import model_ids, get_pricing_for_provider
openrouter_models = model_ids() openrouter_models = model_ids(force_refresh=True)
# Fetch live pricing (non-blocking — returns empty dict on failure) # Fetch live pricing (non-blocking — returns empty dict on failure)
pricing = get_pricing_for_provider("openrouter") pricing = get_pricing_for_provider("openrouter", force_refresh=True)
selected = _prompt_model_selection(openrouter_models, current_model=current_model, pricing=pricing) selected = _prompt_model_selection(openrouter_models, current_model=current_model, pricing=pricing)
if selected: if selected:
@@ -1659,7 +1673,7 @@ def _remove_custom_provider(config):
) )
idx = menu.show() idx = menu.show()
print() print()
except (ImportError, NotImplementedError): except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
for i, c in enumerate(choices, 1): for i, c in enumerate(choices, 1):
print(f" {i}. {c}") print(f" {i}. {c}")
print() print()
@@ -1740,7 +1754,7 @@ def _model_flow_named_custom(config, provider_info):
print("Cancelled.") print("Cancelled.")
return return
model_name = models[idx] model_name = models[idx]
except (ImportError, NotImplementedError): except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
for i, m in enumerate(models, 1): for i, m in enumerate(models, 1):
print(f" {i}. {m}") print(f" {i}. {m}")
print(f" {len(models) + 1}. Cancel") print(f" {len(models) + 1}. Cancel")
@@ -1861,7 +1875,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
if idx == len(ordered): if idx == len(ordered):
return "none" return "none"
return None return None
except (ImportError, NotImplementedError): except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass pass
print("Select reasoning effort:") print("Select reasoning effort:")
@@ -4472,12 +4486,12 @@ For more help on a command:
"setup", "setup",
help="Interactive setup wizard", help="Interactive setup wizard",
description="Configure Hermes Agent with an interactive wizard. " description="Configure Hermes Agent with an interactive wizard. "
"Run a specific section: hermes setup model|terminal|gateway|tools|agent" "Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent"
) )
setup_parser.add_argument( setup_parser.add_argument(
"section", "section",
nargs="?", nargs="?",
choices=["model", "terminal", "gateway", "tools", "agent"], choices=["model", "tts", "terminal", "gateway", "tools", "agent"],
default=None, default=None,
help="Run a specific setup section instead of the full wizard" help="Run a specific setup section instead of the full wizard"
) )

View File

@@ -24,18 +24,19 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
GITHUB_MODELS_BASE_URL = COPILOT_BASE_URL GITHUB_MODELS_BASE_URL = COPILOT_BASE_URL
GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus) # (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [ OPENROUTER_MODELS: list[tuple[str, str]] = [
("anthropic/claude-opus-4.6", "recommended"), ("anthropic/claude-opus-4.6", "recommended"),
("anthropic/claude-sonnet-4.6", ""), ("anthropic/claude-sonnet-4.6", ""),
("qwen/qwen3.6-plus:free", "free"), ("qwen/qwen3.6-plus", ""),
("anthropic/claude-sonnet-4.5", ""), ("anthropic/claude-sonnet-4.5", ""),
("anthropic/claude-haiku-4.5", ""), ("anthropic/claude-haiku-4.5", ""),
("openai/gpt-5.4", ""), ("openai/gpt-5.4", ""),
("openai/gpt-5.4-mini", ""), ("openai/gpt-5.4-mini", ""),
("xiaomi/mimo-v2-pro", ""), ("xiaomi/mimo-v2-pro", ""),
("openai/gpt-5.3-codex", ""), ("openai/gpt-5.3-codex", ""),
("google/gemini-3-pro-preview", ""), ("google/gemini-3-pro-image-preview", ""),
("google/gemini-3-flash-preview", ""), ("google/gemini-3-flash-preview", ""),
("google/gemini-3.1-pro-preview", ""), ("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.1-flash-lite-preview", ""), ("google/gemini-3.1-flash-lite-preview", ""),
@@ -47,7 +48,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("z-ai/glm-5.1", ""), ("z-ai/glm-5.1", ""),
("z-ai/glm-5-turbo", ""), ("z-ai/glm-5-turbo", ""),
("moonshotai/kimi-k2.5", ""), ("moonshotai/kimi-k2.5", ""),
("x-ai/grok-4.20-beta", ""), ("x-ai/grok-4.20", ""),
("nvidia/nemotron-3-super-120b-a12b", ""), ("nvidia/nemotron-3-super-120b-a12b", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"), ("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("arcee-ai/trinity-large-preview:free", "free"), ("arcee-ai/trinity-large-preview:free", "free"),
@@ -56,6 +57,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("openai/gpt-5.4-nano", ""), ("openai/gpt-5.4-nano", ""),
] ]
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
_PROVIDER_MODELS: dict[str, list[str]] = { _PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [ "nous": [
"anthropic/claude-opus-4.6", "anthropic/claude-opus-4.6",
@@ -530,15 +533,79 @@ _PROVIDER_ALIASES = {
} }
def model_ids() -> list[str]: def _openrouter_model_is_free(pricing: Any) -> bool:
"""Return True when both prompt and completion pricing are zero."""
if not isinstance(pricing, dict):
return False
try:
return float(pricing.get("prompt", "0")) == 0 and float(pricing.get("completion", "0")) == 0
except (TypeError, ValueError):
return False
def fetch_openrouter_models(
timeout: float = 8.0,
*,
force_refresh: bool = False,
) -> list[tuple[str, str]]:
"""Return the curated OpenRouter picker list, refreshed from the live catalog when possible."""
global _openrouter_catalog_cache
if _openrouter_catalog_cache is not None and not force_refresh:
return list(_openrouter_catalog_cache)
fallback = list(OPENROUTER_MODELS)
preferred_ids = [mid for mid, _ in fallback]
try:
req = urllib.request.Request(
"https://openrouter.ai/api/v1/models",
headers={"Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except Exception:
return list(_openrouter_catalog_cache or fallback)
live_items = payload.get("data", [])
if not isinstance(live_items, list):
return list(_openrouter_catalog_cache or fallback)
live_by_id: dict[str, dict[str, Any]] = {}
for item in live_items:
if not isinstance(item, dict):
continue
mid = str(item.get("id") or "").strip()
if not mid:
continue
live_by_id[mid] = item
curated: list[tuple[str, str]] = []
for preferred_id in preferred_ids:
live_item = live_by_id.get(preferred_id)
if live_item is None:
continue
desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else ""
curated.append((preferred_id, desc))
if not curated:
return list(_openrouter_catalog_cache or fallback)
first_id, _ = curated[0]
curated[0] = (first_id, "recommended")
_openrouter_catalog_cache = curated
return list(curated)
def model_ids(*, force_refresh: bool = False) -> list[str]:
"""Return just the OpenRouter model-id strings.""" """Return just the OpenRouter model-id strings."""
return [mid for mid, _ in OPENROUTER_MODELS] return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
def menu_labels() -> list[str]: def menu_labels(*, force_refresh: bool = False) -> list[str]:
"""Return display labels like 'anthropic/claude-opus-4.6 (recommended)'.""" """Return display labels like 'anthropic/claude-opus-4.6 (recommended)'."""
labels = [] labels = []
for mid, desc in OPENROUTER_MODELS: for mid, desc in fetch_openrouter_models(force_refresh=force_refresh):
labels.append(f"{mid} ({desc})" if desc else mid) labels.append(f"{mid} ({desc})" if desc else mid)
return labels return labels
@@ -727,13 +794,14 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
return ("", "") return ("", "")
def get_pricing_for_provider(provider: str) -> dict[str, dict[str, str]]: def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
"""Return live pricing for providers that support it (openrouter, nous).""" """Return live pricing for providers that support it (openrouter, nous)."""
normalized = normalize_provider(provider) normalized = normalize_provider(provider)
if normalized == "openrouter": if normalized == "openrouter":
return fetch_models_with_pricing( return fetch_models_with_pricing(
api_key=_resolve_openrouter_api_key(), api_key=_resolve_openrouter_api_key(),
base_url="https://openrouter.ai/api", base_url="https://openrouter.ai/api",
force_refresh=force_refresh,
) )
if normalized == "nous": if normalized == "nous":
api_key, base_url = _resolve_nous_pricing_credentials() api_key, base_url = _resolve_nous_pricing_credentials()
@@ -746,6 +814,7 @@ def get_pricing_for_provider(provider: str) -> dict[str, dict[str, str]]:
return fetch_models_with_pricing( return fetch_models_with_pricing(
api_key=api_key, api_key=api_key,
base_url=stripped, base_url=stripped,
force_refresh=force_refresh,
) )
return {} return {}
@@ -854,7 +923,11 @@ def _get_custom_base_url() -> str:
return "" return ""
def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]]: def curated_models_for_provider(
provider: Optional[str],
*,
force_refresh: bool = False,
) -> list[tuple[str, str]]:
"""Return ``(model_id, description)`` tuples for a provider's model list. """Return ``(model_id, description)`` tuples for a provider's model list.
Tries to fetch the live model list from the provider's API first, Tries to fetch the live model list from the provider's API first,
@@ -863,7 +936,7 @@ def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]
""" """
normalized = normalize_provider(provider) normalized = normalize_provider(provider)
if normalized == "openrouter": if normalized == "openrouter":
return list(OPENROUTER_MODELS) return fetch_openrouter_models(force_refresh=force_refresh)
# Try live API first (Codex, Nous, etc. all support /models) # Try live API first (Codex, Nous, etc. all support /models)
live = provider_model_ids(normalized) live = provider_model_ids(normalized)
@@ -982,12 +1055,12 @@ def _find_openrouter_slug(model_name: str) -> Optional[str]:
return None return None
# Exact match (already has provider/ prefix) # Exact match (already has provider/ prefix)
for mid, _ in OPENROUTER_MODELS: for mid in model_ids():
if name_lower == mid.lower(): if name_lower == mid.lower():
return mid return mid
# Try matching just the model part (after the /) # Try matching just the model part (after the /)
for mid, _ in OPENROUTER_MODELS: for mid in model_ids():
if "/" in mid: if "/" in mid:
_, model_part = mid.split("/", 1) _, model_part = mid.split("/", 1)
if name_lower == model_part.lower(): if name_lower == model_part.lower():
@@ -1101,7 +1174,7 @@ def _resolve_copilot_catalog_api_key() -> str:
return "" return ""
def provider_model_ids(provider: Optional[str]) -> list[str]: def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
"""Return the best known model catalog for a provider. """Return the best known model catalog for a provider.
Tries live API endpoints for providers that support them (Codex, Nous), Tries live API endpoints for providers that support them (Codex, Nous),
@@ -1109,7 +1182,7 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
""" """
normalized = normalize_provider(provider) normalized = normalize_provider(provider)
if normalized == "openrouter": if normalized == "openrouter":
return model_ids() return model_ids(force_refresh=force_refresh)
if normalized == "openai-codex": if normalized == "openai-codex":
from hermes_cli.codex_models import get_codex_model_ids from hermes_cli.codex_models import get_codex_model_ids

View File

@@ -16,6 +16,7 @@ import logging
import os import os
import shutil import shutil
import sys import sys
import copy
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
@@ -316,6 +317,7 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
# Import config helpers # Import config helpers
from hermes_cli.config import ( from hermes_cli.config import (
DEFAULT_CONFIG,
get_hermes_home, get_hermes_home,
get_config_path, get_config_path,
get_env_path, get_env_path,
@@ -921,8 +923,10 @@ def setup_model_provider(config: dict, *, quick: bool = False):
# changes with stale values (#4172). # changes with stale values (#4172).
_refreshed = load_config() _refreshed = load_config()
config["model"] = _refreshed.get("model", config.get("model")) config["model"] = _refreshed.get("model", config.get("model"))
if _refreshed.get("custom_providers"): if "custom_providers" in _refreshed:
config["custom_providers"] = _refreshed["custom_providers"] config["custom_providers"] = _refreshed["custom_providers"]
else:
config.pop("custom_providers", None)
# Derive the selected provider for downstream steps (vision setup). # Derive the selected provider for downstream steps (vision setup).
selected_provider = None selected_provider = None
@@ -1006,8 +1010,6 @@ def setup_model_provider(config: dict, *, quick: bool = False):
strategy_value = ["fill_first", "round_robin", "random"][strategy_idx] strategy_value = ["fill_first", "round_robin", "random"][strategy_idx]
_set_credential_pool_strategy(config, selected_provider, strategy_value) _set_credential_pool_strategy(config, selected_provider, strategy_value)
print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}") print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}")
else:
_set_credential_pool_strategy(config, selected_provider, "fill_first")
except Exception as exc: except Exception as exc:
logger.debug("Could not configure same-provider fallback in setup: %s", exc) logger.debug("Could not configure same-provider fallback in setup: %s", exc)
@@ -2844,6 +2846,7 @@ def run_setup_wizard(args):
Supports full, quick, and section-specific setup: Supports full, quick, and section-specific setup:
hermes setup — full or quick (auto-detected) hermes setup — full or quick (auto-detected)
hermes setup model — just model/provider hermes setup model — just model/provider
hermes setup tts — just text-to-speech
hermes setup terminal — just terminal backend hermes setup terminal — just terminal backend
hermes setup gateway — just messaging platforms hermes setup gateway — just messaging platforms
hermes setup tools — just tool configuration hermes setup tools — just tool configuration
@@ -2855,6 +2858,11 @@ def run_setup_wizard(args):
return return
ensure_hermes_home() ensure_hermes_home()
reset_requested = bool(getattr(args, "reset", False))
if reset_requested:
save_config(copy.deepcopy(DEFAULT_CONFIG))
print_success("Configuration reset to defaults.")
config = load_config() config = load_config()
hermes_home = get_hermes_home() hermes_home = get_hermes_home()
@@ -2955,18 +2963,13 @@ def run_setup_wizard(args):
menu_choices = [ menu_choices = [
"Quick Setup - configure missing items only", "Quick Setup - configure missing items only",
"Full Setup - reconfigure everything", "Full Setup - reconfigure everything",
"---",
"Model & Provider", "Model & Provider",
"Terminal Backend", "Terminal Backend",
"Messaging Platforms (Gateway)", "Messaging Platforms (Gateway)",
"Tools", "Tools",
"Agent Settings", "Agent Settings",
"---",
"Exit", "Exit",
] ]
# Separator indices (not selectable, but prompt_choice doesn't filter them,
# so we handle them below)
choice = prompt_choice("What would you like to do?", menu_choices, 0) choice = prompt_choice("What would you like to do?", menu_choices, 0)
if choice == 0: if choice == 0:
@@ -2976,18 +2979,14 @@ def run_setup_wizard(args):
elif choice == 1: elif choice == 1:
# Full setup — fall through to run all sections # Full setup — fall through to run all sections
pass pass
elif choice in (2, 8): elif choice == 7:
# Separator — treat as exit
print_info("Exiting. Run 'hermes setup' again when ready.") print_info("Exiting. Run 'hermes setup' again when ready.")
return return
elif choice == 9: elif 2 <= choice <= 6:
print_info("Exiting. Run 'hermes setup' again when ready.")
return
elif 3 <= choice <= 7:
# Individual section — map by key, not by position. # Individual section — map by key, not by position.
# SETUP_SECTIONS includes TTS but the returning-user menu skips it, # SETUP_SECTIONS includes TTS but the returning-user menu skips it,
# so positional indexing (choice - 3) would dispatch the wrong section. # so positional indexing (choice - 2) would dispatch the wrong section.
section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 3] section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 2]
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None) section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
if section: if section:
_, label, func = section _, label, func = section

View File

@@ -633,6 +633,7 @@ class TestHasAnyProviderConfigured:
hermes_home.mkdir() hermes_home.mkdir()
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
monkeypatch.setattr("hermes_cli.copilot_auth.resolve_copilot_token", lambda: ("", ""))
# Clear all provider env vars so earlier checks don't short-circuit # Clear all provider env vars so earlier checks don't short-circuit
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", _all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"} "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
@@ -727,6 +728,7 @@ class TestHasAnyProviderConfigured:
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr("hermes_cli.copilot_auth.resolve_copilot_token", lambda: ("", ""))
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", _all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"} "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
for pconfig in PROVIDER_REGISTRY.values(): for pconfig in PROVIDER_REGISTRY.values():

View File

@@ -124,6 +124,13 @@ class TestParseModelInput:
class TestCuratedModelsForProvider: class TestCuratedModelsForProvider:
def test_openrouter_returns_curated_list(self): def test_openrouter_returns_curated_list(self):
with patch(
"hermes_cli.models.fetch_openrouter_models",
return_value=[
("anthropic/claude-opus-4.6", "recommended"),
("qwen/qwen3.6-plus", ""),
],
):
models = curated_models_for_provider("openrouter") models = curated_models_for_provider("openrouter")
assert len(models) > 0 assert len(models) > 0
assert any("claude" in m[0] for m in models) assert any("claude" in m[0] for m in models)
@@ -169,6 +176,13 @@ class TestProviderLabel:
class TestProviderModelIds: class TestProviderModelIds:
def test_openrouter_returns_curated_list(self): def test_openrouter_returns_curated_list(self):
with patch(
"hermes_cli.models.fetch_openrouter_models",
return_value=[
("anthropic/claude-opus-4.6", "recommended"),
("qwen/qwen3.6-plus", ""),
],
):
ids = provider_model_ids("openrouter") ids = provider_model_ids("openrouter")
assert len(ids) > 0 assert len(ids) > 0
assert all("/" in mid for mid in ids) assert all("/" in mid for mid in ids)

View File

@@ -3,7 +3,7 @@
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from hermes_cli.models import ( from hermes_cli.models import (
OPENROUTER_MODELS, menu_labels, model_ids, detect_provider_for_model, OPENROUTER_MODELS, fetch_openrouter_models, menu_labels, model_ids, detect_provider_for_model,
filter_nous_free_models, _NOUS_ALLOWED_FREE_MODELS, filter_nous_free_models, _NOUS_ALLOWED_FREE_MODELS,
is_nous_free_tier, partition_nous_models_by_tier, is_nous_free_tier, partition_nous_models_by_tier,
check_nous_free_tier, clear_nous_free_tier_cache, check_nous_free_tier, clear_nous_free_tier_cache,
@@ -11,42 +11,56 @@ from hermes_cli.models import (
) )
import hermes_cli.models as _models_mod import hermes_cli.models as _models_mod
LIVE_OPENROUTER_MODELS = [
("anthropic/claude-opus-4.6", "recommended"),
("qwen/qwen3.6-plus", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
]
class TestModelIds: class TestModelIds:
def test_returns_non_empty_list(self): def test_returns_non_empty_list(self):
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
ids = model_ids() ids = model_ids()
assert isinstance(ids, list) assert isinstance(ids, list)
assert len(ids) > 0 assert len(ids) > 0
def test_ids_match_models_list(self): def test_ids_match_fetched_catalog(self):
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
ids = model_ids() ids = model_ids()
expected = [mid for mid, _ in OPENROUTER_MODELS] expected = [mid for mid, _ in LIVE_OPENROUTER_MODELS]
assert ids == expected assert ids == expected
def test_all_ids_contain_provider_slash(self): def test_all_ids_contain_provider_slash(self):
"""Model IDs should follow the provider/model format.""" """Model IDs should follow the provider/model format."""
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
for mid in model_ids(): for mid in model_ids():
assert "/" in mid, f"Model ID '{mid}' missing provider/ prefix" assert "/" in mid, f"Model ID '{mid}' missing provider/ prefix"
def test_no_duplicate_ids(self): def test_no_duplicate_ids(self):
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
ids = model_ids() ids = model_ids()
assert len(ids) == len(set(ids)), "Duplicate model IDs found" assert len(ids) == len(set(ids)), "Duplicate model IDs found"
class TestMenuLabels: class TestMenuLabels:
def test_same_length_as_model_ids(self): def test_same_length_as_model_ids(self):
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
assert len(menu_labels()) == len(model_ids()) assert len(menu_labels()) == len(model_ids())
def test_first_label_marked_recommended(self): def test_first_label_marked_recommended(self):
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
labels = menu_labels() labels = menu_labels()
assert "recommended" in labels[0].lower() assert "recommended" in labels[0].lower()
def test_each_label_contains_its_model_id(self): def test_each_label_contains_its_model_id(self):
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
for label, mid in zip(menu_labels(), model_ids()): for label, mid in zip(menu_labels(), model_ids()):
assert mid in label, f"Label '{label}' doesn't contain model ID '{mid}'" assert mid in label, f"Label '{label}' doesn't contain model ID '{mid}'"
def test_non_recommended_labels_have_no_tag(self): def test_non_recommended_labels_have_no_tag(self):
"""Only the first model should have (recommended).""" """Only the first model should have (recommended)."""
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
labels = menu_labels() labels = menu_labels()
for label in labels[1:]: for label in labels[1:]:
assert "recommended" not in label.lower(), f"Unexpected 'recommended' in '{label}'" assert "recommended" not in label.lower(), f"Unexpected 'recommended' in '{label}'"
@@ -65,29 +79,64 @@ class TestOpenRouterModels:
assert len(OPENROUTER_MODELS) >= 5 assert len(OPENROUTER_MODELS) >= 5
class TestFetchOpenRouterModels:
def test_live_fetch_recomputes_free_tags(self, monkeypatch):
class _Resp:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b'{"data":[{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
models = fetch_openrouter_models(force_refresh=True)
assert models == [
("anthropic/claude-opus-4.6", "recommended"),
("qwen/qwen3.6-plus", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
]
def test_falls_back_to_static_snapshot_on_fetch_failure(self, monkeypatch):
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch("hermes_cli.models.urllib.request.urlopen", side_effect=OSError("boom")):
models = fetch_openrouter_models(force_refresh=True)
assert models == OPENROUTER_MODELS
class TestFindOpenrouterSlug: class TestFindOpenrouterSlug:
def test_exact_match(self): def test_exact_match(self):
from hermes_cli.models import _find_openrouter_slug from hermes_cli.models import _find_openrouter_slug
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
assert _find_openrouter_slug("anthropic/claude-opus-4.6") == "anthropic/claude-opus-4.6" assert _find_openrouter_slug("anthropic/claude-opus-4.6") == "anthropic/claude-opus-4.6"
def test_bare_name_match(self): def test_bare_name_match(self):
from hermes_cli.models import _find_openrouter_slug from hermes_cli.models import _find_openrouter_slug
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
result = _find_openrouter_slug("claude-opus-4.6") result = _find_openrouter_slug("claude-opus-4.6")
assert result == "anthropic/claude-opus-4.6" assert result == "anthropic/claude-opus-4.6"
def test_case_insensitive(self): def test_case_insensitive(self):
from hermes_cli.models import _find_openrouter_slug from hermes_cli.models import _find_openrouter_slug
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
result = _find_openrouter_slug("Anthropic/Claude-Opus-4.6") result = _find_openrouter_slug("Anthropic/Claude-Opus-4.6")
assert result is not None assert result is not None
def test_unknown_returns_none(self): def test_unknown_returns_none(self):
from hermes_cli.models import _find_openrouter_slug from hermes_cli.models import _find_openrouter_slug
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
assert _find_openrouter_slug("totally-fake-model-xyz") is None assert _find_openrouter_slug("totally-fake-model-xyz") is None
class TestDetectProviderForModel: class TestDetectProviderForModel:
def test_anthropic_model_detected(self): def test_anthropic_model_detected(self):
"""claude-opus-4-6 should resolve to anthropic provider.""" """claude-opus-4-6 should resolve to anthropic provider."""
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
result = detect_provider_for_model("claude-opus-4-6", "openai-codex") result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
assert result is not None assert result is not None
assert result[0] == "anthropic" assert result[0] == "anthropic"
@@ -105,6 +154,7 @@ class TestDetectProviderForModel:
def test_openrouter_slug_match(self): def test_openrouter_slug_match(self):
"""Models in the OpenRouter catalog should be found.""" """Models in the OpenRouter catalog should be found."""
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
result = detect_provider_for_model("anthropic/claude-opus-4.6", "openai-codex") result = detect_provider_for_model("anthropic/claude-opus-4.6", "openai-codex")
assert result is not None assert result is not None
assert result[0] == "openrouter" assert result[0] == "openrouter"
@@ -119,6 +169,7 @@ class TestDetectProviderForModel:
): ):
monkeypatch.delenv(env_var, raising=False) monkeypatch.delenv(env_var, raising=False)
"""Bare model names should get mapped to full OpenRouter slugs.""" """Bare model names should get mapped to full OpenRouter slugs."""
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
result = detect_provider_for_model("claude-opus-4.6", "openai-codex") result = detect_provider_for_model("claude-opus-4.6", "openai-codex")
assert result is not None assert result is not None
# Should find it on OpenRouter with full slug # Should find it on OpenRouter with full slug
@@ -126,10 +177,12 @@ class TestDetectProviderForModel:
def test_unknown_model_returns_none(self): def test_unknown_model_returns_none(self):
"""Completely unknown model names should return None.""" """Completely unknown model names should return None."""
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
assert detect_provider_for_model("nonexistent-model-xyz", "openai-codex") is None assert detect_provider_for_model("nonexistent-model-xyz", "openai-codex") is None
def test_aggregator_not_suggested(self): def test_aggregator_not_suggested(self):
"""nous/openrouter should never be auto-suggested as target provider.""" """nous/openrouter should never be auto-suggested as target provider."""
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
result = detect_provider_for_model("claude-opus-4-6", "openai-codex") result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
assert result is not None assert result is not None
assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested

View File

@@ -142,6 +142,31 @@ def test_setup_custom_providers_synced(tmp_path, monkeypatch):
assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}] assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch):
"""Removing the last custom provider in model setup should persist."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
_stub_tts(monkeypatch)
config = load_config()
config["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
save_config(config)
def fake_select():
cfg = load_config()
cfg["model"] = {"provider": "openrouter", "default": "anthropic/claude-opus-4.6"}
cfg["custom_providers"] = []
save_config(cfg)
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config)
save_config(config)
reloaded = load_config()
assert reloaded.get("custom_providers") == []
def test_setup_cancel_preserves_existing_config(tmp_path, monkeypatch): def test_setup_cancel_preserves_existing_config(tmp_path, monkeypatch):
"""When the user cancels provider selection, existing config is preserved.""" """When the user cancels provider selection, existing config is preserved."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@@ -201,6 +226,38 @@ def test_setup_keyboard_interrupt_gracefully_handled(tmp_path, monkeypatch):
setup_model_provider(config) setup_model_provider(config)
def test_select_provider_and_model_warns_if_named_custom_provider_disappears(
tmp_path, monkeypatch, capsys
):
"""If a saved custom provider is deleted mid-selection, show a warning instead of silently doing nothing."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
cfg = load_config()
cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
save_config(cfg)
def fake_prompt_provider_choice(choices, default=0):
current = load_config()
current["custom_providers"] = []
save_config(current)
return next(i for i, label in enumerate(choices) if label.startswith("Local (localhost:8080/v1)"))
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda provider: None)
monkeypatch.setattr("hermes_cli.main._prompt_provider_choice", fake_prompt_provider_choice)
monkeypatch.setattr(
"hermes_cli.main._model_flow_named_custom",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("named custom flow should not run")),
)
from hermes_cli.main import select_provider_and_model
select_provider_and_model()
out = capsys.readouterr().out
assert "selected saved custom provider is no longer available" in out
def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch): def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
"""Codex model list fetching uses the runtime access token.""" """Codex model list fetching uses the runtime access token."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))

View File

@@ -230,6 +230,39 @@ def test_setup_same_provider_fallback_can_add_another_credential(tmp_path, monke
assert config.get("credential_pool_strategies", {}).get("openrouter") == "fill_first" assert config.get("credential_pool_strategies", {}).get("openrouter") == "fill_first"
def test_setup_same_provider_single_credential_keeps_existing_rotation_strategy(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
save_env_value("OPENROUTER_API_KEY", "or-key")
_write_model_config("openrouter", "", "anthropic/claude-opus-4.6")
config = load_config()
config["credential_pool_strategies"] = {"openrouter": "round_robin"}
save_config(config)
class _Entry:
def __init__(self, label):
self.label = label
class _Pool:
def entries(self):
return [_Entry("primary")]
def fake_select():
pass
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
_stub_tts(monkeypatch)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
setup_model_provider(config)
assert config.get("credential_pool_strategies", {}).get("openrouter") == "round_robin"
def test_setup_pool_step_shows_manual_vs_auto_detected_counts(tmp_path, monkeypatch, capsys): def test_setup_pool_step_shows_manual_vs_auto_detected_counts(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch) _clear_provider_env(monkeypatch)

View File

@@ -4,6 +4,7 @@ from argparse import Namespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from hermes_cli.config import DEFAULT_CONFIG, load_config, save_config
def _make_setup_args(**overrides): def _make_setup_args(**overrides):
@@ -34,6 +35,36 @@ def _make_chat_args(**overrides):
class TestNonInteractiveSetup: class TestNonInteractiveSetup:
"""Verify setup paths exit cleanly in headless/non-interactive environments.""" """Verify setup paths exit cleanly in headless/non-interactive environments."""
def test_cmd_setup_allows_noninteractive_flag_without_tty(self):
"""The CLI entrypoint should not block --non-interactive before setup.py handles it."""
from hermes_cli.main import cmd_setup
args = _make_setup_args(non_interactive=True)
with (
patch("hermes_cli.setup.run_setup_wizard") as mock_run_setup,
patch("sys.stdin") as mock_stdin,
):
mock_stdin.isatty.return_value = False
cmd_setup(args)
mock_run_setup.assert_called_once_with(args)
def test_cmd_setup_defers_no_tty_handling_to_setup_wizard(self):
"""Bare `hermes setup` should reach setup.py, which prints headless guidance."""
from hermes_cli.main import cmd_setup
args = _make_setup_args(non_interactive=False)
with (
patch("hermes_cli.setup.run_setup_wizard") as mock_run_setup,
patch("sys.stdin") as mock_stdin,
):
mock_stdin.isatty.return_value = False
cmd_setup(args)
mock_run_setup.assert_called_once_with(args)
def test_non_interactive_flag_skips_wizard(self, capsys): def test_non_interactive_flag_skips_wizard(self, capsys):
"""--non-interactive should print guidance and not enter the wizard.""" """--non-interactive should print guidance and not enter the wizard."""
from hermes_cli.setup import run_setup_wizard from hermes_cli.setup import run_setup_wizard
@@ -72,6 +103,26 @@ class TestNonInteractiveSetup:
out = capsys.readouterr().out out = capsys.readouterr().out
assert "hermes config set model.provider custom" in out assert "hermes config set model.provider custom" in out
def test_reset_flag_rewrites_config_before_noninteractive_exit(self, tmp_path, monkeypatch, capsys):
"""--reset should rewrite config.yaml even when the wizard cannot run interactively."""
from hermes_cli.setup import run_setup_wizard
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg = load_config()
cfg["model"] = {"provider": "custom", "base_url": "http://localhost:8080/v1", "default": "llama3"}
cfg["agent"]["max_turns"] = 12
save_config(cfg)
args = _make_setup_args(non_interactive=True, reset=True)
run_setup_wizard(args)
reloaded = load_config()
assert reloaded["model"] == DEFAULT_CONFIG["model"]
assert reloaded["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"]
out = capsys.readouterr().out
assert "Configuration reset to defaults." in out
def test_chat_first_run_headless_skips_setup_prompt(self, capsys): def test_chat_first_run_headless_skips_setup_prompt(self, capsys):
"""Bare `hermes` should not prompt for input when no provider exists and stdin is headless.""" """Bare `hermes` should not prompt for input when no provider exists and stdin is headless."""
from hermes_cli.main import cmd_chat from hermes_cli.main import cmd_chat
@@ -117,7 +168,7 @@ class TestNonInteractiveSetup:
side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "", side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "",
), ),
patch("hermes_cli.auth.get_active_provider", return_value=None), patch("hermes_cli.auth.get_active_provider", return_value=None),
patch.object(setup_mod, "prompt_choice", return_value=4), patch.object(setup_mod, "prompt_choice", return_value=3),
patch.object( patch.object(
setup_mod, setup_mod,
"SETUP_SECTIONS", "SETUP_SECTIONS",
@@ -137,3 +188,59 @@ class TestNonInteractiveSetup:
terminal_section.assert_called_once_with(config) terminal_section.assert_called_once_with(config)
tts_section.assert_not_called() tts_section.assert_not_called()
def test_returning_user_menu_does_not_show_separator_rows(self, tmp_path):
"""Returning-user menu should only show selectable actions."""
from hermes_cli import setup as setup_mod
args = _make_setup_args()
captured = {}
def fake_prompt_choice(question, choices, default=0):
captured["question"] = question
captured["choices"] = list(choices)
return len(choices) - 1
with (
patch.object(setup_mod, "ensure_hermes_home"),
patch.object(setup_mod, "load_config", return_value={}),
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch.object(
setup_mod,
"get_env_value",
side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "",
),
patch("hermes_cli.auth.get_active_provider", return_value=None),
patch.object(setup_mod, "prompt_choice", side_effect=fake_prompt_choice),
):
setup_mod.run_setup_wizard(args)
assert captured["question"] == "What would you like to do?"
assert "---" not in captured["choices"]
assert captured["choices"] == [
"Quick Setup - configure missing items only",
"Full Setup - reconfigure everything",
"Model & Provider",
"Terminal Backend",
"Messaging Platforms (Gateway)",
"Tools",
"Agent Settings",
"Exit",
]
def test_main_accepts_tts_setup_section(self, monkeypatch):
"""`hermes setup tts` should parse and dispatch like other setup sections."""
from hermes_cli import main as main_mod
received = {}
def fake_cmd_setup(args):
received["section"] = args.section
monkeypatch.setattr(main_mod, "cmd_setup", fake_cmd_setup)
monkeypatch.setattr("sys.argv", ["hermes", "setup", "tts"])
main_mod.main()
assert received["section"] == "tts"

View File

@@ -0,0 +1,106 @@
"""Regression tests for numbered fallbacks when TerminalMenu cannot initialize."""
import subprocess
import sys
import types
from hermes_cli.config import load_config, save_config
class _BrokenTerminalMenu:
def __init__(self, *args, **kwargs):
raise subprocess.CalledProcessError(2, ["tput", "clear"])
def test_prompt_model_selection_falls_back_on_terminalmenu_runtime_error(monkeypatch):
from hermes_cli.auth import _prompt_model_selection
monkeypatch.setitem(
sys.modules,
"simple_term_menu",
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
)
responses = iter(["2"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_model_selection(["model-a", "model-b"])
assert selected == "model-b"
def test_prompt_reasoning_effort_falls_back_on_terminalmenu_runtime_error(monkeypatch):
from hermes_cli.main import _prompt_reasoning_effort_selection
monkeypatch.setitem(
sys.modules,
"simple_term_menu",
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
)
responses = iter(["3"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_reasoning_effort_selection(["low", "medium", "high"], current_effort="")
assert selected == "high"
def test_remove_custom_provider_falls_back_on_terminalmenu_runtime_error(tmp_path, monkeypatch):
from hermes_cli.main import _remove_custom_provider
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setitem(
sys.modules,
"simple_term_menu",
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
)
cfg = load_config()
cfg["custom_providers"] = [
{"name": "Local A", "base_url": "http://localhost:8001/v1"},
{"name": "Local B", "base_url": "http://localhost:8002/v1"},
]
save_config(cfg)
responses = iter(["1"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
_remove_custom_provider(cfg)
reloaded = load_config()
assert reloaded["custom_providers"] == [
{"name": "Local B", "base_url": "http://localhost:8002/v1"},
]
def test_named_custom_provider_model_picker_falls_back_on_terminalmenu_runtime_error(tmp_path, monkeypatch):
from hermes_cli.main import _model_flow_named_custom
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setitem(
sys.modules,
"simple_term_menu",
types.SimpleNamespace(TerminalMenu=_BrokenTerminalMenu),
)
monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *args, **kwargs: ["model-a", "model-b"])
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
cfg = load_config()
save_config(cfg)
responses = iter(["2"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
_model_flow_named_custom(
cfg,
{
"name": "Local",
"base_url": "http://localhost:8000/v1",
"api_key": "",
"model": "",
},
)
reloaded = load_config()
assert reloaded["model"]["provider"] == "custom"
assert reloaded["model"]["base_url"] == "http://localhost:8000/v1"
assert reloaded["model"]["default"] == "model-b"