mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 00:11:39 +08:00
Compare commits
2 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f054e92d8 | ||
|
|
a2fbb1eea3 |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
106
tests/hermes_cli/test_terminal_menu_fallbacks.py
Normal file
106
tests/hermes_cli/test_terminal_menu_fallbacks.py
Normal 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"
|
||||||
Reference in New Issue
Block a user