diff --git a/README.md b/README.md index cbd02dd696..d71e5b0530 100644 --- a/README.md +++ b/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) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 3e06c46dce..31880a00c2 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0ace89effd..0ac07f1182 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 # =========================================================================