feat: enhance README and CLI with multi-provider model selection
- Added a comprehensive "Getting Started" section in the README to guide users through selecting inference providers. - Implemented an interactive model selection feature in the CLI, allowing users to choose from available models or enter a custom model name. - Improved user experience by displaying the current model and active provider during selection, with clear instructions for each provider type. - Updated the model selection process to prioritize the currently active model, enhancing usability and clarity.
This commit is contained in:
64
README.md
64
README.md
@@ -31,6 +31,33 @@ hermes # Start chatting!
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
Hermes supports multiple inference providers. Pick one to get going:
|
||||
|
||||
**Option A — Nous Portal (subscription):**
|
||||
```bash
|
||||
hermes login # Opens browser to authenticate with Nous Portal
|
||||
hermes # Start chatting!
|
||||
```
|
||||
|
||||
**Option B — OpenRouter (pay-per-use, 100+ models):**
|
||||
```bash
|
||||
hermes model # Interactive provider & model selector
|
||||
# → choose OpenRouter, paste your API key, pick a model
|
||||
hermes # Start chatting!
|
||||
```
|
||||
|
||||
**Option C — Custom endpoint (VLLM, SGLang, etc.):**
|
||||
```bash
|
||||
hermes model # → choose Custom endpoint, enter URL + API key + model name
|
||||
hermes # Start chatting!
|
||||
```
|
||||
|
||||
You can switch providers and models at any time with `hermes model`.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
**Quick update (installer version):**
|
||||
@@ -117,23 +144,15 @@ hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env
|
||||
|
||||
### Inference Providers
|
||||
|
||||
You need at least one way to connect to an LLM:
|
||||
You need at least one way to connect to an LLM. Use `hermes model` to switch providers and models interactively, or configure directly:
|
||||
|
||||
| Method | Description | Setup |
|
||||
|--------|-------------|-------|
|
||||
| **Nous Portal** | Nous Research subscription with OAuth login | `hermes login` |
|
||||
| **OpenRouter** (recommended for flexibility) | Pay-per-use access to 100+ models | `OPENROUTER_API_KEY` in `.env` |
|
||||
| **Custom Endpoint** | Any OpenAI-compatible API (VLLM, SGLang, etc.) | `OPENAI_BASE_URL` + `OPENAI_API_KEY` in `.env` |
|
||||
| Provider | Setup |
|
||||
|----------|-------|
|
||||
| **Nous Portal** | `hermes login` (OAuth, subscription-based) |
|
||||
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
|
||||
| **Custom Endpoint** | `OPENAI_BASE_URL` + `OPENAI_API_KEY` in `~/.hermes/.env` |
|
||||
|
||||
The setup wizard (`hermes setup`) walks you through choosing a provider. You can also log in directly:
|
||||
|
||||
```bash
|
||||
hermes login # Authenticate with Nous Portal
|
||||
hermes login --provider nous # Same, explicit
|
||||
hermes logout # Clear stored credentials
|
||||
```
|
||||
|
||||
**Note:** Even when using Nous Portal or a custom endpoint as your main provider, some tools (vision analysis, web summarization, Mixture of Agents) use OpenRouter independently. Adding an `OPENROUTER_API_KEY` enables these tools.
|
||||
**Note:** Even when using Nous Portal or a custom endpoint, some tools (vision, web summarization, MoA) use OpenRouter independently. An `OPENROUTER_API_KEY` enables these tools.
|
||||
|
||||
### Optional API Keys
|
||||
|
||||
@@ -281,19 +300,28 @@ See [docs/messaging.md](docs/messaging.md) for WhatsApp and advanced setup.
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Chat
|
||||
hermes # Interactive chat (default)
|
||||
hermes chat -q "Hello" # Single query mode
|
||||
hermes chat --provider nous # Chat using Nous Portal
|
||||
hermes setup # Configure provider, API keys, and settings
|
||||
|
||||
# Provider & model management
|
||||
hermes model # Switch provider and model interactively
|
||||
hermes login # Authenticate with Nous Portal (OAuth)
|
||||
hermes logout # Clear stored OAuth credentials
|
||||
|
||||
# Configuration
|
||||
hermes setup # Full setup wizard (provider, terminal, messaging, etc.)
|
||||
hermes config # View/edit configuration
|
||||
hermes config check # Check for missing config (useful after updates)
|
||||
hermes config migrate # Interactively add missing options
|
||||
hermes status # Show configuration status (incl. auth)
|
||||
hermes doctor # Diagnose issues
|
||||
hermes update # Update to latest version (prompts for new config)
|
||||
|
||||
# Maintenance
|
||||
hermes update # Update to latest version
|
||||
hermes uninstall # Uninstall (can keep configs for later reinstall)
|
||||
|
||||
# Messaging, skills, cron
|
||||
hermes gateway # Start messaging gateway
|
||||
hermes skills search k8s # Search skill registries
|
||||
hermes skills install ... # Install a skill (with security scan)
|
||||
|
||||
@@ -851,24 +851,34 @@ def _reset_config_provider() -> Path:
|
||||
return config_path
|
||||
|
||||
|
||||
def _prompt_model_selection(model_ids: List[str]) -> Optional[str]:
|
||||
"""Interactive model selection after login. Returns chosen model ID or None."""
|
||||
print(f"Available models ({len(model_ids)}):")
|
||||
for i, mid in enumerate(model_ids, 1):
|
||||
print(f" {i}. {mid}")
|
||||
print(f" {len(model_ids) + 1}. Custom model name")
|
||||
print(f" {len(model_ids) + 2}. Skip (keep current)")
|
||||
print()
|
||||
def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Optional[str]:
|
||||
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None."""
|
||||
# Reorder: current model first, then the rest (deduplicated)
|
||||
ordered = []
|
||||
if current_model and current_model in model_ids:
|
||||
ordered.append(current_model)
|
||||
for mid in model_ids:
|
||||
if mid not in ordered:
|
||||
ordered.append(mid)
|
||||
|
||||
# Build display labels with marker on current
|
||||
def _label(mid):
|
||||
if mid == current_model:
|
||||
return f"{mid} ← currently in use"
|
||||
return mid
|
||||
|
||||
# Default cursor on the current model (index 0 if it was reordered to top)
|
||||
default_idx = 0
|
||||
|
||||
# Try arrow-key menu first, fall back to number input
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
choices = [f" {mid}" for mid in model_ids]
|
||||
choices.append(" Custom model name")
|
||||
choices = [f" {_label(mid)}" for mid in ordered]
|
||||
choices.append(" Enter custom model name")
|
||||
choices.append(" Skip (keep current)")
|
||||
menu = TerminalMenu(
|
||||
choices,
|
||||
cursor_index=0,
|
||||
cursor_index=default_idx,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
@@ -880,30 +890,38 @@ def _prompt_model_selection(model_ids: List[str]) -> Optional[str]:
|
||||
if idx is None:
|
||||
return None
|
||||
print()
|
||||
if idx < len(model_ids):
|
||||
return model_ids[idx]
|
||||
elif idx == len(model_ids):
|
||||
if idx < len(ordered):
|
||||
return ordered[idx]
|
||||
elif idx == len(ordered):
|
||||
custom = input("Enter model name: ").strip()
|
||||
return custom if custom else None
|
||||
return None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: number-based selection
|
||||
# Fallback: numbered list
|
||||
print("Select default model:")
|
||||
for i, mid in enumerate(ordered, 1):
|
||||
print(f" {i}. {_label(mid)}")
|
||||
n = len(ordered)
|
||||
print(f" {n + 1}. Enter custom model name")
|
||||
print(f" {n + 2}. Skip (keep current)")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"Select model [1-{len(model_ids) + 2}] (default: skip): ").strip()
|
||||
choice = input(f"Choice [1-{n + 2}] (default: skip): ").strip()
|
||||
if not choice:
|
||||
return None
|
||||
idx = int(choice)
|
||||
if 1 <= idx <= len(model_ids):
|
||||
return model_ids[idx - 1]
|
||||
elif idx == len(model_ids) + 1:
|
||||
if 1 <= idx <= n:
|
||||
return ordered[idx - 1]
|
||||
elif idx == n + 1:
|
||||
custom = input("Enter model name: ").strip()
|
||||
return custom if custom else None
|
||||
elif idx == len(model_ids) + 2:
|
||||
elif idx == n + 2:
|
||||
return None
|
||||
print(f"Please enter a number between 1 and {len(model_ids) + 2}")
|
||||
print(f"Please enter 1-{n + 2}")
|
||||
except ValueError:
|
||||
print("Please enter a number")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
|
||||
@@ -73,6 +73,271 @@ def cmd_setup(args):
|
||||
run_setup_wizard(args)
|
||||
|
||||
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
from hermes_cli.auth import (
|
||||
resolve_provider, get_provider_auth_state, PROVIDER_REGISTRY,
|
||||
_prompt_model_selection, _save_model_choice, _update_config_for_provider,
|
||||
resolve_nous_runtime_credentials, fetch_nous_models, AuthError, format_auth_error,
|
||||
_login_nous, ProviderConfig,
|
||||
)
|
||||
from hermes_cli.config import load_config, save_config, get_env_value, save_env_value
|
||||
|
||||
config = load_config()
|
||||
current_model = config.get("model")
|
||||
if isinstance(current_model, dict):
|
||||
current_model = current_model.get("default", "")
|
||||
current_model = current_model or "(not set)"
|
||||
|
||||
active = resolve_provider("auto")
|
||||
|
||||
# Map active provider to a display name
|
||||
provider_labels = {
|
||||
"openrouter": "OpenRouter",
|
||||
"nous": "Nous Portal",
|
||||
}
|
||||
active_label = provider_labels.get(active, "Custom endpoint")
|
||||
|
||||
print()
|
||||
print(f" Current model: {current_model}")
|
||||
print(f" Active provider: {active_label}")
|
||||
print()
|
||||
|
||||
# Step 1: Provider selection — put active provider first with marker
|
||||
providers = [
|
||||
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
("nous", "Nous Portal (Nous Research subscription)"),
|
||||
("custom", "Custom endpoint (self-hosted / VLLM / etc.)"),
|
||||
]
|
||||
|
||||
# Reorder so the active provider is at the top
|
||||
active_key = active if active in ("openrouter", "nous") else "custom"
|
||||
ordered = []
|
||||
for key, label in providers:
|
||||
if key == active_key:
|
||||
ordered.insert(0, (key, f"{label} ← currently active"))
|
||||
else:
|
||||
ordered.append((key, label))
|
||||
ordered.append(("cancel", "Cancel"))
|
||||
|
||||
provider_idx = _prompt_provider_choice([label for _, label in ordered])
|
||||
if provider_idx is None or ordered[provider_idx][0] == "cancel":
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
selected_provider = ordered[provider_idx][0]
|
||||
|
||||
# Step 2: Provider-specific setup + model selection
|
||||
if selected_provider == "openrouter":
|
||||
_model_flow_openrouter(config, current_model)
|
||||
elif selected_provider == "nous":
|
||||
_model_flow_nous(config, current_model)
|
||||
elif selected_provider == "custom":
|
||||
_model_flow_custom(config)
|
||||
|
||||
|
||||
def _prompt_provider_choice(choices):
|
||||
"""Show provider selection menu. Returns index or None."""
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
menu_items = [f" {c}" for c in choices]
|
||||
menu = TerminalMenu(
|
||||
menu_items, cursor_index=0,
|
||||
menu_cursor="-> ", menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True, clear_screen=False,
|
||||
title="Select provider:",
|
||||
)
|
||||
idx = menu.show()
|
||||
print()
|
||||
return idx
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: numbered list
|
||||
print("Select provider:")
|
||||
for i, c in enumerate(choices, 1):
|
||||
print(f" {i}. {c}")
|
||||
print()
|
||||
while True:
|
||||
try:
|
||||
val = input(f"Choice [1-{len(choices)}]: ").strip()
|
||||
if not val:
|
||||
return None
|
||||
idx = int(val) - 1
|
||||
if 0 <= idx < len(choices):
|
||||
return idx
|
||||
print(f"Please enter 1-{len(choices)}")
|
||||
except ValueError:
|
||||
print("Please enter a number")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return None
|
||||
|
||||
|
||||
def _model_flow_openrouter(config, current_model=""):
|
||||
"""OpenRouter provider: ensure API key, then pick model."""
|
||||
from hermes_cli.auth import _prompt_model_selection, _save_model_choice
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
|
||||
api_key = get_env_value("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
print("No OpenRouter API key configured.")
|
||||
print("Get one at: https://openrouter.ai/keys")
|
||||
print()
|
||||
try:
|
||||
key = input("OpenRouter API key (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value("OPENROUTER_API_KEY", key)
|
||||
print("API key saved.")
|
||||
print()
|
||||
|
||||
OPENROUTER_MODELS = [
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"anthropic/claude-opus-4.5",
|
||||
"openai/gpt-5.2",
|
||||
"openai/gpt-5.2-codex",
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3-flash-preview",
|
||||
"z-ai/glm-4.7",
|
||||
"moonshotai/kimi-k2.5",
|
||||
"minimax/minimax-m2.1",
|
||||
]
|
||||
|
||||
selected = _prompt_model_selection(OPENROUTER_MODELS, current_model=current_model)
|
||||
if selected:
|
||||
# Clear any custom endpoint and set provider to openrouter
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_save_model_choice(selected)
|
||||
# Update config provider
|
||||
from hermes_cli.config import load_config, save_config
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if isinstance(model, dict):
|
||||
model["provider"] = "openrouter"
|
||||
model["base_url"] = "https://openrouter.ai/api/v1"
|
||||
save_config(cfg)
|
||||
print(f"Default model set to: {selected} (via OpenRouter)")
|
||||
else:
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_nous(config, current_model=""):
|
||||
"""Nous Portal provider: ensure logged in, then pick model."""
|
||||
from hermes_cli.auth import (
|
||||
get_provider_auth_state, _prompt_model_selection, _save_model_choice,
|
||||
resolve_nous_runtime_credentials, fetch_nous_models,
|
||||
AuthError, format_auth_error, _login_nous, PROVIDER_REGISTRY,
|
||||
)
|
||||
import argparse
|
||||
|
||||
state = get_provider_auth_state("nous")
|
||||
if not state or not state.get("access_token"):
|
||||
print("Not logged into Nous Portal. Starting login...")
|
||||
print()
|
||||
try:
|
||||
mock_args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None,
|
||||
scope=None, no_browser=False, timeout=15.0,
|
||||
ca_bundle=None, insecure=False,
|
||||
)
|
||||
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
||||
except SystemExit:
|
||||
print("Login cancelled or failed.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print(f"Login failed: {exc}")
|
||||
return
|
||||
# login_nous already handles model selection, so we're done
|
||||
return
|
||||
|
||||
# Already logged in — fetch models and select
|
||||
print("Fetching models from Nous Portal...")
|
||||
try:
|
||||
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60)
|
||||
model_ids = fetch_nous_models(
|
||||
inference_base_url=creds.get("base_url", ""),
|
||||
api_key=creds.get("api_key", ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
|
||||
print(f"Could not fetch models: {msg}")
|
||||
return
|
||||
|
||||
if not model_ids:
|
||||
print("No models returned by the inference API.")
|
||||
return
|
||||
|
||||
selected = _prompt_model_selection(model_ids, current_model=current_model)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
print(f"Default model set to: {selected} (via Nous Portal)")
|
||||
else:
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_custom(config):
|
||||
"""Custom endpoint: collect URL, API key, and model name."""
|
||||
from hermes_cli.auth import _save_model_choice
|
||||
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
||||
|
||||
current_url = get_env_value("OPENAI_BASE_URL") or ""
|
||||
current_key = get_env_value("OPENAI_API_KEY") or ""
|
||||
|
||||
print("Custom OpenAI-compatible endpoint configuration:")
|
||||
if current_url:
|
||||
print(f" Current URL: {current_url}")
|
||||
if current_key:
|
||||
print(f" Current key: {current_key[:8]}...")
|
||||
print()
|
||||
|
||||
try:
|
||||
base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip()
|
||||
api_key = input(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip()
|
||||
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
|
||||
if not base_url and not current_url:
|
||||
print("No URL provided. Cancelled.")
|
||||
return
|
||||
|
||||
# Validate URL format
|
||||
effective_url = base_url or current_url
|
||||
if not effective_url.startswith(("http://", "https://")):
|
||||
print(f"Invalid URL: {effective_url} (must start with http:// or https://)")
|
||||
return
|
||||
|
||||
if base_url:
|
||||
save_env_value("OPENAI_BASE_URL", base_url)
|
||||
if api_key:
|
||||
save_env_value("OPENAI_API_KEY", api_key)
|
||||
|
||||
if model_name:
|
||||
_save_model_choice(model_name)
|
||||
|
||||
# Update config to reflect custom provider
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if isinstance(model, dict):
|
||||
model["provider"] = "auto"
|
||||
model["base_url"] = effective_url
|
||||
save_config(cfg)
|
||||
|
||||
print(f"Default model set to: {model_name} (via {effective_url})")
|
||||
else:
|
||||
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
|
||||
|
||||
|
||||
def cmd_login(args):
|
||||
"""Authenticate Hermes CLI with a provider."""
|
||||
from hermes_cli.auth import login_command
|
||||
@@ -283,6 +548,7 @@ Examples:
|
||||
hermes setup Run setup wizard
|
||||
hermes login Authenticate with an inference provider
|
||||
hermes logout Clear stored authentication
|
||||
hermes model Select default model
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
@@ -335,7 +601,17 @@ For more help on a command:
|
||||
help="Verbose output"
|
||||
)
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# model command
|
||||
# =========================================================================
|
||||
model_parser = subparsers.add_parser(
|
||||
"model",
|
||||
help="Select default model and provider",
|
||||
description="Interactively select your inference provider and default model"
|
||||
)
|
||||
model_parser.set_defaults(func=cmd_model)
|
||||
|
||||
# =========================================================================
|
||||
# gateway command
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user