diff --git a/README.md b/README.md index cf260a01a4..cbd02dd696 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ All your settings are stored in `~/.hermes/` for easy access: ~/.hermes/ ├── config.yaml # Settings (model, terminal, TTS, compression, etc.) ├── .env # API keys and secrets +├── auth.json # OAuth provider credentials (Nous Portal, etc.) ├── SOUL.md # Optional: global persona (agent embodies this personality) ├── memories/ # Persistent memory (MEMORY.md, USER.md) ├── skills/ # Agent-created skills (managed via skill_manage tool) @@ -114,14 +115,25 @@ hermes config set terminal.backend docker hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env ``` -### Required API Keys +### Inference Providers -You need at least one LLM provider: +You need at least one way to connect to an LLM: -| Provider | Get Key | Env Variable | -|----------|---------|--------------| -| **OpenRouter** (recommended) | [openrouter.ai/keys](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | +| 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` | +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. ### Optional API Keys @@ -271,11 +283,14 @@ See [docs/messaging.md](docs/messaging.md) for WhatsApp and advanced setup. ```bash hermes # Interactive chat (default) hermes chat -q "Hello" # Single query mode -hermes setup # Configure API keys and settings +hermes chat --provider nous # Chat using Nous Portal +hermes setup # Configure provider, API keys, and settings +hermes login # Authenticate with Nous Portal (OAuth) +hermes logout # Clear stored OAuth credentials 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 +hermes status # Show configuration status (incl. auth) hermes doctor # Diagnose issues hermes update # Update to latest version (prompts for new config) hermes uninstall # Uninstall (can keep configs for later reinstall) @@ -1248,10 +1263,19 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t **LLM Providers:** | Variable | Description | |----------|-------------| -| `OPENROUTER_API_KEY` | OpenRouter API key (recommended) | +| `OPENROUTER_API_KEY` | OpenRouter API key (recommended for flexibility) | | `ANTHROPIC_API_KEY` | Direct Anthropic access | | `OPENAI_API_KEY` | Direct OpenAI access | +**Provider Auth (OAuth):** +| Variable | Description | +|----------|-------------| +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous` (default: `auto`) | +| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | +| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | +| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | +| `HERMES_DUMP_REQUESTS` | Dump API request payloads to log files for debugging (`true`/`false`) | + **Tool APIs:** | Variable | Description | |----------|-------------| @@ -1311,11 +1335,13 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t |------|-------------| | `~/.hermes/config.yaml` | Your settings | | `~/.hermes/.env` | API keys and secrets | +| `~/.hermes/auth.json` | OAuth provider credentials (managed by `hermes login`) | | `~/.hermes/cron/` | Scheduled jobs data | | `~/.hermes/sessions/` | Gateway session data | | `~/.hermes-agent/` | Installation directory | | `~/.hermes-agent/logs/` | Session logs | | `hermes_cli/` | CLI implementation | +| `hermes_cli/auth.py` | Multi-provider auth system | | `tools/` | Tool implementations | | `skills/` | Bundled skill sources (copied to `~/.hermes/skills/` on install) | | `~/.hermes/skills/` | All active skills (bundled + hub-installed + agent-created) | @@ -1335,8 +1361,11 @@ hermes config # View current settings Common issues: - **"API key not set"**: Run `hermes setup` or `hermes config set OPENROUTER_API_KEY your_key` - **"hermes: command not found"**: Reload your shell (`source ~/.bashrc`) or check PATH +- **"Run `hermes login` to re-authenticate"**: Your Nous Portal session expired. Run `hermes login` to refresh. +- **"No active paid subscription"**: Your Nous Portal account needs an active subscription for inference. - **Gateway won't start**: Check `hermes gateway status` and logs - **Missing config after update**: Run `hermes config check` to see what's new, then `hermes config migrate` to add missing options +- **Provider auto-detection wrong**: Force a provider with `hermes chat --provider openrouter` or set `HERMES_INFERENCE_PROVIDER` in `.env` --- diff --git a/cli-config.yaml.example b/cli-config.yaml.example index ed14933468..f1dd046bde 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -9,6 +9,13 @@ model: # Default model to use (can be overridden with --model flag) default: "anthropic/claude-opus-4.6" + # Inference provider selection: + # "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default) + # "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY + # "nous" - Always use Nous Portal (requires: hermes login) + # Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var. + provider: "auto" + # API configuration (falls back to OPENROUTER_API_KEY env var) # api_key: "your-key-here" # Uncomment to set here instead of .env base_url: "https://openrouter.ai/api/v1" diff --git a/cli.py b/cli.py index ca6bcc5395..d3d7e9eb57 100755 --- a/cli.py +++ b/cli.py @@ -89,6 +89,7 @@ def load_cli_config() -> Dict[str, Any]: "model": { "default": "anthropic/claude-opus-4.6", "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", }, "terminal": { "env_type": "local", @@ -670,6 +671,7 @@ class HermesCLI: self, model: str = None, toolsets: List[str] = None, + provider: str = None, api_key: str = None, base_url: str = None, max_turns: int = 60, @@ -682,6 +684,7 @@ class HermesCLI: Args: model: Model to use (default: from env or claude-sonnet) toolsets: List of toolsets to enable (default: all) + provider: Inference provider ("auto", "openrouter", "nous") api_key: API key (default: from environment) base_url: API base URL (default: OpenRouter) max_turns: Maximum tool-calling iterations (default: 60) @@ -702,6 +705,22 @@ class HermesCLI: # API key: custom endpoint (OPENAI_API_KEY) takes precedence over OpenRouter self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") + + # Provider resolution: determines whether to use OAuth credentials or env var keys + from hermes_cli.auth import resolve_provider + self.requested_provider = ( + provider + or os.getenv("HERMES_INFERENCE_PROVIDER") + or CLI_CONFIG["model"].get("provider") + or "auto" + ) + self.provider = resolve_provider( + self.requested_provider, + explicit_api_key=api_key, + explicit_base_url=base_url, + ) + self._nous_key_expires_at: Optional[str] = None + self._nous_key_source: Optional[str] = None # Max turns priority: CLI arg > env var > config file (agent.max_turns or root max_turns) > default if max_turns != 60: # CLI arg was explicitly set self.max_turns = max_turns @@ -742,7 +761,53 @@ class HermesCLI: # History file for persistent input recall across sessions self._history_file = Path.home() / ".hermes_history" - + + def _ensure_runtime_credentials(self) -> bool: + """ + Ensure OAuth provider credentials are fresh before agent use. + For Nous Portal: checks agent key TTL, refreshes/re-mints as needed. + If the key changed, tears down the agent so it rebuilds with new creds. + Returns True if credentials are ready, False on auth failure. + """ + if self.provider != "nous": + return True + + from hermes_cli.auth import format_auth_error, resolve_nous_runtime_credentials + + try: + credentials = resolve_nous_runtime_credentials( + min_key_ttl_seconds=max( + 60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")) + ), + timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), + ) + except Exception as exc: + from hermes_cli.auth import AuthError + message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) + self.console.print(f"[bold red]{message}[/]") + return False + + api_key = credentials.get("api_key") + base_url = credentials.get("base_url") + if not isinstance(api_key, str) or not api_key: + self.console.print("[bold red]Nous credential resolver returned an empty API key.[/]") + return False + if not isinstance(base_url, str) or not base_url: + self.console.print("[bold red]Nous credential resolver returned an empty base URL.[/]") + return False + + credentials_changed = api_key != self.api_key or base_url != self.base_url + self.api_key = api_key + self.base_url = base_url + self._nous_key_expires_at = credentials.get("expires_at") + self._nous_key_source = credentials.get("source") + + # AIAgent/OpenAI client holds auth at init time, so rebuild if key rotated + if credentials_changed and self.agent is not None: + self.agent = None + + return True + def _init_agent(self) -> bool: """ Initialize the agent on first use. @@ -752,7 +817,10 @@ class HermesCLI: """ if self.agent is not None: return True - + + if self.provider == "nous" and not self._ensure_runtime_credentials(): + return False + # Initialize SQLite session store for CLI sessions self._session_db = None try: @@ -853,11 +921,15 @@ class HermesCLI: toolsets_info = "" if self.enabled_toolsets and "all" not in self.enabled_toolsets: toolsets_info = f" [dim #B8860B]·[/] [#CD7F32]toolsets: {', '.join(self.enabled_toolsets)}[/]" - + + provider_info = f" [dim #B8860B]·[/] [dim]provider: {self.provider}[/]" + if self.provider == "nous" and self._nous_key_source: + provider_info += f" [dim #B8860B]·[/] [dim]key: {self._nous_key_source}[/]" + self.console.print( f" {api_indicator} [#FFBF00]{model_short}[/] " f"[dim #B8860B]·[/] [bold cyan]{tool_count} tools[/]" - f"{toolsets_info}" + f"{toolsets_info}{provider_info}" ) def show_help(self): @@ -1528,6 +1600,10 @@ class HermesCLI: Returns: The agent's response, or None on error """ + # Refresh OAuth credentials if needed (handles key rotation transparently) + if self.provider == "nous" and not self._ensure_runtime_credentials(): + return None + # Initialize agent if needed if not self._init_agent(): return None @@ -2072,6 +2148,7 @@ def main( q: str = None, toolsets: str = None, model: str = None, + provider: str = None, api_key: str = None, base_url: str = None, max_turns: int = 60, @@ -2091,6 +2168,7 @@ def main( q: Shorthand for --query toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal") model: Model to use (default: anthropic/claude-opus-4-20250514) + provider: Inference provider ("auto", "openrouter", "nous") api_key: API key for authentication base_url: Base URL for the API max_turns: Maximum tool-calling iterations (default: 60) @@ -2165,6 +2243,7 @@ def main( cli = HermesCLI( model=model, toolsets=toolsets_list, + provider=provider, api_key=api_key, base_url=base_url, max_turns=max_turns, diff --git a/docs/cli.md b/docs/cli.md index af98de1694..65a675518c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -11,6 +11,10 @@ The Hermes Agent CLI provides an interactive terminal interface for working with # With specific model ./hermes --model "anthropic/claude-sonnet-4" +# With specific provider +./hermes --provider nous # Use Nous Portal (requires: hermes login) +./hermes --provider openrouter # Force OpenRouter + # With specific toolsets ./hermes --toolsets "web,terminal,skills" @@ -75,14 +79,22 @@ The CLI is configured via `cli-config.yaml`. Copy from `cli-config.yaml.example` cp cli-config.yaml.example cli-config.yaml ``` -### Model Configuration +### Model & Provider Configuration ```yaml model: - default: "anthropic/claude-opus-4.5" + default: "anthropic/claude-opus-4.6" base_url: "https://openrouter.ai/api/v1" + provider: "auto" # "auto" | "openrouter" | "nous" ``` +**Provider selection** (`provider` field): +- `auto` (default): Uses Nous Portal if logged in (`hermes login`), otherwise falls back to OpenRouter/env vars. +- `openrouter`: Always uses `OPENROUTER_API_KEY` from `.env`. +- `nous`: Always uses Nous Portal OAuth credentials from `auth.json`. + +Can also be overridden per-session with `--provider` or via `HERMES_INFERENCE_PROVIDER` env var. + ### Terminal Configuration The CLI supports multiple terminal backends: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py new file mode 100644 index 0000000000..122cfe2255 --- /dev/null +++ b/hermes_cli/auth.py @@ -0,0 +1,1054 @@ +""" +Multi-provider authentication system for Hermes Agent. + +Supports OAuth device code flows (Nous Portal, future: OpenAI Codex) and +traditional API key providers (OpenRouter, custom endpoints). Auth state +is persisted in ~/.hermes/auth.json with cross-process file locking. + +Architecture: +- ProviderConfig registry defines known OAuth providers +- Auth store (auth.json) holds per-provider credential state +- resolve_provider() picks the active provider via priority chain +- resolve_*_runtime_credentials() handles token refresh and key minting +- login_command() / logout_command() are the CLI entry points +""" + +from __future__ import annotations + +import json +import os +import stat +import time +import webbrowser +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import httpx +import yaml + +from hermes_cli.config import get_hermes_home, get_config_path + +try: + import fcntl +except Exception: + fcntl = None + +# ============================================================================= +# Constants +# ============================================================================= + +AUTH_STORE_VERSION = 1 +AUTH_LOCK_TIMEOUT_SECONDS = 15.0 + +# Nous Portal defaults +DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com" +DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1" +DEFAULT_NOUS_CLIENT_ID = "hermes-cli" +DEFAULT_NOUS_SCOPE = "inference:mint_agent_key" +DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes +ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry +DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s + + +# ============================================================================= +# Provider Registry +# ============================================================================= + +@dataclass +class ProviderConfig: + """Describes a known OAuth provider.""" + id: str + name: str + auth_type: str # "oauth_device_code" or "api_key" + portal_base_url: str = "" + inference_base_url: str = "" + client_id: str = "" + scope: str = "" + extra: Dict[str, Any] = field(default_factory=dict) + + +PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { + "nous": ProviderConfig( + id="nous", + name="Nous Portal", + auth_type="oauth_device_code", + portal_base_url=DEFAULT_NOUS_PORTAL_URL, + inference_base_url=DEFAULT_NOUS_INFERENCE_URL, + client_id=DEFAULT_NOUS_CLIENT_ID, + scope=DEFAULT_NOUS_SCOPE, + ), + # Future: "openai_codex", "anthropic", etc. +} + + +# ============================================================================= +# Error Types +# ============================================================================= + +class AuthError(RuntimeError): + """Structured auth error with UX mapping hints.""" + + def __init__( + self, + message: str, + *, + provider: str = "", + code: Optional[str] = None, + relogin_required: bool = False, + ) -> None: + super().__init__(message) + self.provider = provider + self.code = code + self.relogin_required = relogin_required + + +def format_auth_error(error: Exception) -> str: + """Map auth failures to concise user-facing guidance.""" + if not isinstance(error, AuthError): + return str(error) + + if error.relogin_required: + return f"{error} Run `hermes login` to re-authenticate." + + if error.code == "subscription_required": + return ( + "No active paid subscription found on Nous Portal. " + "Please purchase/activate a subscription, then retry." + ) + + if error.code == "insufficient_credits": + return ( + "Subscription credits are exhausted. " + "Top up/renew credits in Nous Portal, then retry." + ) + + if error.code == "temporarily_unavailable": + return f"{error} Please retry in a few seconds." + + return str(error) + + +# ============================================================================= +# Auth Store — persistence layer for ~/.hermes/auth.json +# ============================================================================= + +def _auth_file_path() -> Path: + return get_hermes_home() / "auth.json" + + +def _auth_lock_path() -> Path: + return _auth_file_path().with_suffix(".lock") + + +@contextmanager +def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): + """Cross-process advisory lock for auth.json reads+writes.""" + lock_path = _auth_lock_path() + lock_path.parent.mkdir(parents=True, exist_ok=True) + + with lock_path.open("a+") as lock_file: + if fcntl is None: + yield + return + + deadline = time.time() + max(1.0, timeout_seconds) + while True: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.time() >= deadline: + raise TimeoutError("Timed out waiting for auth store lock") + time.sleep(0.05) + + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: + auth_file = auth_file or _auth_file_path() + if not auth_file.exists(): + return {"version": AUTH_STORE_VERSION, "providers": {}} + + try: + raw = json.loads(auth_file.read_text()) + except Exception: + return {"version": AUTH_STORE_VERSION, "providers": {}} + + if isinstance(raw, dict) and isinstance(raw.get("providers"), dict): + return raw + + # Migrate from PR's "systems" format if present + if isinstance(raw, dict) and isinstance(raw.get("systems"), dict): + systems = raw["systems"] + providers = {} + if "nous_portal" in systems: + providers["nous"] = systems["nous_portal"] + return {"version": AUTH_STORE_VERSION, "providers": providers, + "active_provider": "nous" if providers else None} + + return {"version": AUTH_STORE_VERSION, "providers": {}} + + +def _save_auth_store(auth_store: Dict[str, Any]) -> Path: + auth_file = _auth_file_path() + auth_file.parent.mkdir(parents=True, exist_ok=True) + auth_store["version"] = AUTH_STORE_VERSION + auth_store["updated_at"] = datetime.now(timezone.utc).isoformat() + auth_file.write_text(json.dumps(auth_store, indent=2) + "\n") + # Restrict file permissions to owner only + try: + auth_file.chmod(stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + return auth_file + + +def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]: + providers = auth_store.get("providers") + if not isinstance(providers, dict): + return None + state = providers.get(provider_id) + return dict(state) if isinstance(state, dict) else None + + +def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None: + providers = auth_store.setdefault("providers", {}) + if not isinstance(providers, dict): + auth_store["providers"] = {} + providers = auth_store["providers"] + providers[provider_id] = state + auth_store["active_provider"] = provider_id + + +def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: + """Return persisted auth state for a provider, or None.""" + auth_store = _load_auth_store() + return _load_provider_state(auth_store, provider_id) + + +def get_active_provider() -> Optional[str]: + """Return the currently active provider ID from auth store.""" + auth_store = _load_auth_store() + return auth_store.get("active_provider") + + +def clear_provider_auth(provider_id: Optional[str] = None) -> bool: + """ + Clear auth state for a provider. Used by `hermes logout`. + If provider_id is None, clears the active provider. + Returns True if something was cleared. + """ + with _auth_store_lock(): + auth_store = _load_auth_store() + target = provider_id or auth_store.get("active_provider") + if not target: + return False + + providers = auth_store.get("providers", {}) + if target not in providers: + return False + + del providers[target] + if auth_store.get("active_provider") == target: + auth_store["active_provider"] = None + _save_auth_store(auth_store) + return True + + +# ============================================================================= +# Provider Resolution — picks which provider to use +# ============================================================================= + +def resolve_provider( + requested: Optional[str] = None, + *, + explicit_api_key: Optional[str] = None, + explicit_base_url: Optional[str] = None, +) -> str: + """ + Determine which inference provider to use. + + Priority (when requested="auto" or None): + 1. active_provider in auth.json with valid credentials + 2. Explicit CLI api_key/base_url -> "openrouter" + 3. OPENAI_API_KEY or OPENROUTER_API_KEY env vars -> "openrouter" + 4. Fallback: "openrouter" + """ + normalized = (requested or "auto").strip().lower() + + if normalized in PROVIDER_REGISTRY: + return normalized + if normalized == "openrouter": + return "openrouter" + if normalized != "auto": + return "openrouter" + + # Explicit one-off CLI creds always mean openrouter/custom + if explicit_api_key or explicit_base_url: + return "openrouter" + + # Check auth store for an active OAuth provider + try: + auth_store = _load_auth_store() + active = auth_store.get("active_provider") + if active and active in PROVIDER_REGISTRY: + state = _load_provider_state(auth_store, active) + if state and (state.get("access_token") or state.get("refresh_token")): + return active + except Exception: + pass + + if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY"): + return "openrouter" + + return "openrouter" + + +# ============================================================================= +# Timestamp / TTL helpers +# ============================================================================= + +def _parse_iso_timestamp(value: Any) -> Optional[float]: + if not isinstance(value, str) or not value: + return None + text = value.strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except Exception: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() + + +def _is_expiring(expires_at_iso: Any, skew_seconds: int) -> bool: + expires_epoch = _parse_iso_timestamp(expires_at_iso) + if expires_epoch is None: + return True + return expires_epoch <= (time.time() + skew_seconds) + + +def _coerce_ttl_seconds(expires_in: Any) -> int: + try: + ttl = int(expires_in) + except Exception: + ttl = 0 + return max(0, ttl) + + +def _optional_base_url(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + cleaned = value.strip().rstrip("/") + return cleaned if cleaned else None + + +# ============================================================================= +# SSH / remote session detection +# ============================================================================= + +def _is_remote_session() -> bool: + """Detect if running in an SSH session where webbrowser.open() won't work.""" + return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")) + + +# ============================================================================= +# TLS verification helper +# ============================================================================= + +def _resolve_verify( + *, + insecure: Optional[bool] = None, + ca_bundle: Optional[str] = None, + auth_state: Optional[Dict[str, Any]] = None, +) -> bool | str: + tls_state = auth_state.get("tls") if isinstance(auth_state, dict) else {} + tls_state = tls_state if isinstance(tls_state, dict) else {} + + effective_insecure = ( + bool(insecure) if insecure is not None + else bool(tls_state.get("insecure", False)) + ) + effective_ca = ( + ca_bundle + or tls_state.get("ca_bundle") + or os.getenv("HERMES_CA_BUNDLE") + or os.getenv("SSL_CERT_FILE") + ) + + if effective_insecure: + return False + if effective_ca: + return str(effective_ca) + return True + + +# ============================================================================= +# OAuth Device Code Flow — generic, parameterized by provider +# ============================================================================= + +def _request_device_code( + client: httpx.Client, + portal_base_url: str, + client_id: str, + scope: Optional[str], +) -> Dict[str, Any]: + """POST to the device code endpoint. Returns device_code, user_code, etc.""" + response = client.post( + f"{portal_base_url}/api/oauth/device/code", + data={ + "client_id": client_id, + **({"scope": scope} if scope else {}), + }, + ) + response.raise_for_status() + data = response.json() + + required_fields = [ + "device_code", "user_code", "verification_uri", + "verification_uri_complete", "expires_in", "interval", + ] + missing = [f for f in required_fields if f not in data] + if missing: + raise ValueError(f"Device code response missing fields: {', '.join(missing)}") + return data + + +def _poll_for_token( + client: httpx.Client, + portal_base_url: str, + client_id: str, + device_code: str, + expires_in: int, + poll_interval: int, +) -> Dict[str, Any]: + """Poll the token endpoint until the user approves or the code expires.""" + deadline = time.time() + max(1, expires_in) + current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)) + + while time.time() < deadline: + response = client.post( + f"{portal_base_url}/api/oauth/token", + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": client_id, + "device_code": device_code, + }, + ) + + if response.status_code == 200: + payload = response.json() + if "access_token" not in payload: + raise ValueError("Token response did not include access_token") + return payload + + try: + error_payload = response.json() + except Exception: + response.raise_for_status() + raise RuntimeError("Token endpoint returned a non-JSON error response") + + error_code = error_payload.get("error", "") + if error_code == "authorization_pending": + time.sleep(current_interval) + continue + if error_code == "slow_down": + current_interval = min(current_interval + 1, 30) + time.sleep(current_interval) + continue + + description = error_payload.get("error_description") or "Unknown authentication error" + raise RuntimeError(f"{error_code}: {description}") + + raise TimeoutError("Timed out waiting for device authorization") + + +# ============================================================================= +# Nous Portal — token refresh, agent key minting, model discovery +# ============================================================================= + +def _refresh_access_token( + *, + client: httpx.Client, + portal_base_url: str, + client_id: str, + refresh_token: str, +) -> Dict[str, Any]: + response = client.post( + f"{portal_base_url}/api/oauth/token", + data={ + "grant_type": "refresh_token", + "client_id": client_id, + "refresh_token": refresh_token, + }, + ) + + if response.status_code == 200: + payload = response.json() + if "access_token" not in payload: + raise AuthError("Refresh response missing access_token", + provider="nous", code="invalid_token", relogin_required=True) + return payload + + try: + error_payload = response.json() + except Exception as exc: + raise AuthError("Refresh token exchange failed", + provider="nous", relogin_required=True) from exc + + code = str(error_payload.get("error", "invalid_grant")) + description = str(error_payload.get("error_description") or "Refresh token exchange failed") + relogin = code in {"invalid_grant", "invalid_token"} + raise AuthError(description, provider="nous", code=code, relogin_required=relogin) + + +def _mint_agent_key( + *, + client: httpx.Client, + portal_base_url: str, + access_token: str, + min_ttl_seconds: int, +) -> Dict[str, Any]: + """Mint (or reuse) a short-lived inference API key.""" + response = client.post( + f"{portal_base_url}/api/oauth/agent-key", + headers={"Authorization": f"Bearer {access_token}"}, + json={"min_ttl_seconds": max(60, int(min_ttl_seconds))}, + ) + + if response.status_code == 200: + payload = response.json() + if "api_key" not in payload: + raise AuthError("Mint response missing api_key", + provider="nous", code="server_error") + return payload + + try: + error_payload = response.json() + except Exception as exc: + raise AuthError("Agent key mint request failed", + provider="nous", code="server_error") from exc + + code = str(error_payload.get("error", "server_error")) + description = str(error_payload.get("error_description") or "Agent key mint request failed") + relogin = code in {"invalid_token", "invalid_grant"} + raise AuthError(description, provider="nous", code=code, relogin_required=relogin) + + +def fetch_nous_models( + *, + inference_base_url: str, + api_key: str, + timeout_seconds: float = 15.0, + verify: bool | str = True, +) -> List[str]: + """Fetch available model IDs from the Nous inference API.""" + timeout = httpx.Timeout(timeout_seconds) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: + response = client.get( + f"{inference_base_url.rstrip('/')}/models", + headers={"Authorization": f"Bearer {api_key}"}, + ) + + if response.status_code != 200: + description = f"/models request failed with status {response.status_code}" + try: + err = response.json() + description = str(err.get("error_description") or err.get("error") or description) + except Exception: + pass + raise AuthError(description, provider="nous", code="models_fetch_failed") + + payload = response.json() + data = payload.get("data") + if not isinstance(data, list): + return [] + + model_ids: List[str] = [] + for item in data: + if not isinstance(item, dict): + continue + model_id = item.get("id") + if isinstance(model_id, str) and model_id.strip(): + model_ids.append(model_id.strip()) + + return list(dict.fromkeys(model_ids)) + + +def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool: + key = state.get("agent_key") + if not isinstance(key, str) or not key.strip(): + return False + return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds) + + +def resolve_nous_runtime_credentials( + *, + min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, + timeout_seconds: float = 15.0, + insecure: Optional[bool] = None, + ca_bundle: Optional[str] = None, + force_mint: bool = False, +) -> Dict[str, Any]: + """ + Resolve Nous inference credentials for runtime use. + + Ensures access_token is valid (refreshes if needed) and a short-lived + inference key is present with minimum TTL (mints/reuses as needed). + Concurrent processes coordinate through the auth store file lock. + + Returns dict with: provider, base_url, api_key, key_id, expires_at, + expires_in, source ("cache" or "portal"). + """ + min_key_ttl_seconds = max(60, int(min_key_ttl_seconds)) + + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + + if not state: + raise AuthError("Hermes is not logged into Nous Portal.", + provider="nous", relogin_required=True) + + portal_base_url = ( + _optional_base_url(state.get("portal_base_url")) + or os.getenv("HERMES_PORTAL_BASE_URL") + or os.getenv("NOUS_PORTAL_BASE_URL") + or DEFAULT_NOUS_PORTAL_URL + ).rstrip("/") + inference_base_url = ( + _optional_base_url(state.get("inference_base_url")) + or os.getenv("NOUS_INFERENCE_BASE_URL") + or DEFAULT_NOUS_INFERENCE_URL + ).rstrip("/") + client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) + + verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state) + timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) + + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + + if not isinstance(access_token, str) or not access_token: + raise AuthError("No access token found for Nous Portal login.", + provider="nous", relogin_required=True) + + # Step 1: refresh access token if expiring + if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS): + if not isinstance(refresh_token, str) or not refresh_token: + raise AuthError("Session expired and no refresh token is available.", + provider="nous", relogin_required=True) + + refreshed = _refresh_access_token( + client=client, portal_base_url=portal_base_url, + client_id=client_id, refresh_token=refresh_token, + ) + now = datetime.now(timezone.utc) + access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) + state["access_token"] = refreshed["access_token"] + state["refresh_token"] = refreshed.get("refresh_token") or refresh_token + state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" + state["scope"] = refreshed.get("scope") or state.get("scope") + refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) + if refreshed_url: + inference_base_url = refreshed_url + state["obtained_at"] = now.isoformat() + state["expires_in"] = access_ttl + state["expires_at"] = datetime.fromtimestamp( + now.timestamp() + access_ttl, tz=timezone.utc + ).isoformat() + access_token = state["access_token"] + + # Step 2: mint agent key if missing/expiring + used_cached_key = False + mint_payload: Optional[Dict[str, Any]] = None + + if not force_mint and _agent_key_is_usable(state, min_key_ttl_seconds): + used_cached_key = True + else: + try: + mint_payload = _mint_agent_key( + client=client, portal_base_url=portal_base_url, + access_token=access_token, min_ttl_seconds=min_key_ttl_seconds, + ) + except AuthError as exc: + # Retry path: access token may be stale server-side despite local checks + if exc.code in {"invalid_token", "invalid_grant"} and isinstance(refresh_token, str) and refresh_token: + refreshed = _refresh_access_token( + client=client, portal_base_url=portal_base_url, + client_id=client_id, refresh_token=refresh_token, + ) + now = datetime.now(timezone.utc) + access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) + state["access_token"] = refreshed["access_token"] + state["refresh_token"] = refreshed.get("refresh_token") or refresh_token + state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" + state["scope"] = refreshed.get("scope") or state.get("scope") + refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) + if refreshed_url: + inference_base_url = refreshed_url + state["obtained_at"] = now.isoformat() + state["expires_in"] = access_ttl + state["expires_at"] = datetime.fromtimestamp( + now.timestamp() + access_ttl, tz=timezone.utc + ).isoformat() + access_token = state["access_token"] + + mint_payload = _mint_agent_key( + client=client, portal_base_url=portal_base_url, + access_token=access_token, min_ttl_seconds=min_key_ttl_seconds, + ) + else: + raise + + if mint_payload is not None: + now = datetime.now(timezone.utc) + state["agent_key"] = mint_payload.get("api_key") + state["agent_key_id"] = mint_payload.get("key_id") + state["agent_key_expires_at"] = mint_payload.get("expires_at") + state["agent_key_expires_in"] = mint_payload.get("expires_in") + state["agent_key_reused"] = bool(mint_payload.get("reused", False)) + state["agent_key_obtained_at"] = now.isoformat() + minted_url = _optional_base_url(mint_payload.get("inference_base_url")) + if minted_url: + inference_base_url = minted_url + + # Persist routing and TLS metadata for non-interactive refresh/mint + state["portal_base_url"] = portal_base_url + state["inference_base_url"] = inference_base_url + state["client_id"] = client_id + state["tls"] = { + "insecure": verify is False, + "ca_bundle": verify if isinstance(verify, str) else None, + } + + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + + api_key = state.get("agent_key") + if not isinstance(api_key, str) or not api_key: + raise AuthError("Failed to resolve a Nous inference API key", + provider="nous", code="server_error") + + expires_at = state.get("agent_key_expires_at") + expires_epoch = _parse_iso_timestamp(expires_at) + expires_in = ( + max(0, int(expires_epoch - time.time())) + if expires_epoch is not None + else _coerce_ttl_seconds(state.get("agent_key_expires_in")) + ) + + return { + "provider": "nous", + "base_url": inference_base_url, + "api_key": api_key, + "key_id": state.get("agent_key_id"), + "expires_at": expires_at, + "expires_in": expires_in, + "source": "cache" if used_cached_key else "portal", + } + + +# ============================================================================= +# Status helpers +# ============================================================================= + +def get_nous_auth_status() -> Dict[str, Any]: + """Status snapshot for `hermes status` output.""" + state = get_provider_auth_state("nous") + if not state: + return { + "logged_in": False, + "portal_base_url": None, + "inference_base_url": None, + "access_expires_at": None, + "agent_key_expires_at": None, + "has_refresh_token": False, + } + return { + "logged_in": bool(state.get("access_token")), + "portal_base_url": state.get("portal_base_url"), + "inference_base_url": state.get("inference_base_url"), + "access_expires_at": state.get("expires_at"), + "agent_key_expires_at": state.get("agent_key_expires_at"), + "has_refresh_token": bool(state.get("refresh_token")), + } + + +def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: + """Generic auth status dispatcher.""" + target = provider_id or get_active_provider() + if target == "nous": + return get_nous_auth_status() + return {"logged_in": False} + + +# ============================================================================= +# CLI Commands — login / logout +# ============================================================================= + +def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Path: + """Update config.yaml to reflect the active provider after login.""" + config_path = get_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + + config: Dict[str, Any] = {} + if config_path.exists(): + try: + loaded = yaml.safe_load(config_path.read_text()) or {} + if isinstance(loaded, dict): + config = loaded + except Exception: + config = {} + + current_model = config.get("model") + if isinstance(current_model, dict): + model_cfg = dict(current_model) + elif isinstance(current_model, str) and current_model.strip(): + model_cfg = {"default": current_model.strip()} + else: + model_cfg = {} + + model_cfg["provider"] = provider_id + model_cfg["base_url"] = inference_base_url.rstrip("/") + config["model"] = model_cfg + + config_path.write_text(yaml.safe_dump(config, sort_keys=False)) + return config_path + + +def _reset_config_provider() -> Path: + """Reset config.yaml provider back to auto after logout.""" + config_path = get_config_path() + if not config_path.exists(): + return config_path + + try: + config = yaml.safe_load(config_path.read_text()) or {} + except Exception: + return config_path + + if not isinstance(config, dict): + return config_path + + model = config.get("model") + if isinstance(model, dict): + model["provider"] = "auto" + if "base_url" in model: + model["base_url"] = "https://openrouter.ai/api/v1" + config_path.write_text(yaml.safe_dump(config, sort_keys=False)) + return config_path + + +def login_command(args) -> None: + """Run OAuth device code login for the selected provider.""" + provider_id = getattr(args, "provider", None) or "nous" + + if provider_id not in PROVIDER_REGISTRY: + print(f"Unknown provider: {provider_id}") + print(f"Available: {', '.join(PROVIDER_REGISTRY.keys())}") + raise SystemExit(1) + + pconfig = PROVIDER_REGISTRY[provider_id] + + if provider_id == "nous": + _login_nous(args, pconfig) + else: + print(f"Login for provider '{provider_id}' is not yet implemented.") + raise SystemExit(1) + + +def _login_nous(args, pconfig: ProviderConfig) -> None: + """Nous Portal device authorization flow.""" + portal_base_url = ( + getattr(args, "portal_url", None) + or os.getenv("HERMES_PORTAL_BASE_URL") + or os.getenv("NOUS_PORTAL_BASE_URL") + or pconfig.portal_base_url + ).rstrip("/") + requested_inference_url = ( + getattr(args, "inference_url", None) + or os.getenv("NOUS_INFERENCE_BASE_URL") + or pconfig.inference_base_url + ).rstrip("/") + client_id = getattr(args, "client_id", None) or pconfig.client_id + scope = getattr(args, "scope", None) or pconfig.scope + open_browser = not getattr(args, "no_browser", False) + timeout_seconds = getattr(args, "timeout", None) or 15.0 + timeout = httpx.Timeout(timeout_seconds) + + insecure = bool(getattr(args, "insecure", False)) + ca_bundle = ( + getattr(args, "ca_bundle", None) + or os.getenv("HERMES_CA_BUNDLE") + or os.getenv("SSL_CERT_FILE") + ) + verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True) + + # Skip browser open in SSH sessions + if _is_remote_session(): + open_browser = False + + print(f"Starting Hermes login via {pconfig.name}...") + print(f"Portal: {portal_base_url}") + if insecure: + print("TLS verification: disabled (--insecure)") + elif ca_bundle: + print(f"TLS verification: custom CA bundle ({ca_bundle})") + + try: + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: + device_data = _request_device_code( + client=client, portal_base_url=portal_base_url, + client_id=client_id, scope=scope, + ) + + verification_url = str(device_data["verification_uri_complete"]) + user_code = str(device_data["user_code"]) + expires_in = int(device_data["expires_in"]) + interval = int(device_data["interval"]) + + print() + print("To continue:") + print(f" 1. Open: {verification_url}") + print(f" 2. If prompted, enter code: {user_code}") + + if open_browser: + opened = webbrowser.open(verification_url) + if opened: + print(" (Opened browser for verification)") + else: + print(" Could not open browser automatically — use the URL above.") + + effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)) + print(f"Waiting for approval (polling every {effective_interval}s)...") + + token_data = _poll_for_token( + client=client, portal_base_url=portal_base_url, + client_id=client_id, device_code=str(device_data["device_code"]), + expires_in=expires_in, poll_interval=interval, + ) + + # Process token response + now = datetime.now(timezone.utc) + token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0)) + expires_at = now.timestamp() + token_expires_in + inference_base_url = ( + _optional_base_url(token_data.get("inference_base_url")) + or requested_inference_url + ) + if inference_base_url != requested_inference_url: + print(f"Using portal-provided inference URL: {inference_base_url}") + + auth_state = { + "portal_base_url": portal_base_url, + "inference_base_url": inference_base_url, + "client_id": client_id, + "scope": token_data.get("scope") or scope, + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token"), + "obtained_at": now.isoformat(), + "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(), + "expires_in": token_expires_in, + "tls": { + "insecure": verify is False, + "ca_bundle": verify if isinstance(verify, str) else None, + }, + "agent_key": None, + "agent_key_id": None, + "agent_key_expires_at": None, + "agent_key_expires_in": None, + "agent_key_reused": None, + "agent_key_obtained_at": None, + } + + # Save auth state + with _auth_store_lock(): + auth_store = _load_auth_store() + _save_provider_state(auth_store, "nous", auth_state) + saved_to = _save_auth_store(auth_store) + + config_path = _update_config_for_provider("nous", inference_base_url) + print() + print("Login successful!") + print(f" Auth state: {saved_to}") + print(f" Config updated: {config_path} (model.provider=nous)") + + # Mint an initial agent key and list available models + try: + runtime_creds = resolve_nous_runtime_credentials( + min_key_ttl_seconds=5 * 60, + timeout_seconds=timeout_seconds, + insecure=insecure, ca_bundle=ca_bundle, + ) + runtime_key = runtime_creds.get("api_key") + runtime_base_url = runtime_creds.get("base_url") or inference_base_url + if not isinstance(runtime_key, str) or not runtime_key: + raise AuthError("No runtime API key available to fetch models", + provider="nous", code="invalid_token") + + model_ids = fetch_nous_models( + inference_base_url=runtime_base_url, + api_key=runtime_key, + timeout_seconds=timeout_seconds, + verify=verify, + ) + + print() + if model_ids: + print(f"Available models ({len(model_ids)}):") + for mid in model_ids: + print(f" - {mid}") + else: + print("No models were returned by the inference API.") + except Exception as exc: + message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) + print() + print(f"Login succeeded, but could not fetch available models. Reason: {message}") + + except KeyboardInterrupt: + print("\nLogin cancelled.") + raise SystemExit(130) + except Exception as exc: + print(f"Login failed: {exc}") + raise SystemExit(1) + + +def logout_command(args) -> None: + """Clear auth state for a provider.""" + provider_id = getattr(args, "provider", None) + + if provider_id and provider_id not in PROVIDER_REGISTRY: + print(f"Unknown provider: {provider_id}") + raise SystemExit(1) + + active = get_active_provider() + target = provider_id or active + + if not target: + print("No provider is currently logged in.") + return + + provider_name = PROVIDER_REGISTRY[target].name if target in PROVIDER_REGISTRY else target + + if clear_provider_auth(target): + _reset_config_provider() + print(f"Logged out of {provider_name}.") + if os.getenv("OPENROUTER_API_KEY"): + print("Hermes will use OpenRouter for inference.") + else: + print("Run `hermes login` or configure an API key to use Hermes.") + else: + print(f"No auth state found for {provider_name}.") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6a455abf60..0ace89effd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12,6 +12,8 @@ Usage: hermes gateway install # Install gateway service hermes gateway uninstall # Uninstall gateway service hermes setup # Interactive setup wizard + hermes login # Authenticate with Nous Portal (or other providers) + hermes logout # Clear stored authentication hermes status # Show status of all components hermes cron # Manage cron jobs hermes cron list # List cron jobs @@ -48,6 +50,7 @@ def cmd_chat(args): # Build kwargs from args kwargs = { "model": args.model, + "provider": getattr(args, "provider", None), "toolsets": args.toolsets, "verbose": args.verbose, "query": args.query, @@ -70,6 +73,18 @@ def cmd_setup(args): run_setup_wizard(args) +def cmd_login(args): + """Authenticate Hermes CLI with a provider.""" + from hermes_cli.auth import login_command + login_command(args) + + +def cmd_logout(args): + """Clear provider authentication.""" + from hermes_cli.auth import logout_command + logout_command(args) + + def cmd_status(args): """Show status of all components.""" from hermes_cli.status import show_status @@ -244,6 +259,9 @@ def cmd_update(args): print() print("✓ Update complete!") print() + print("Tip: You can now log in with Nous Portal for inference:") + print(" hermes login # Authenticate with Nous Portal") + print() print("Note: If you have the gateway service running, restart it:") print(" hermes gateway restart") @@ -263,6 +281,8 @@ Examples: hermes Start interactive chat hermes chat -q "Hello" Single query mode hermes setup Run setup wizard + hermes login Authenticate with an inference provider + hermes logout Clear stored authentication hermes config View configuration hermes config edit Edit config in $EDITOR hermes config set model gpt-4 Set a config value @@ -303,6 +323,12 @@ For more help on a command: "-t", "--toolsets", help="Comma-separated toolsets to enable" ) + chat_parser.add_argument( + "--provider", + choices=["auto", "openrouter", "nous"], + default=None, + help="Inference provider (default: auto)" + ) chat_parser.add_argument( "-v", "--verbose", action="store_true", @@ -365,7 +391,77 @@ For more help on a command: help="Reset configuration to defaults" ) setup_parser.set_defaults(func=cmd_setup) - + + # ========================================================================= + # login command + # ========================================================================= + login_parser = subparsers.add_parser( + "login", + help="Authenticate with an inference provider", + description="Run OAuth device authorization flow for Hermes CLI" + ) + login_parser.add_argument( + "--provider", + choices=["nous"], + default=None, + help="Provider to authenticate with (default: interactive selection)" + ) + login_parser.add_argument( + "--portal-url", + help="Portal base URL (default: production portal)" + ) + login_parser.add_argument( + "--inference-url", + help="Inference API base URL (default: production inference API)" + ) + login_parser.add_argument( + "--client-id", + default=None, + help="OAuth client id to use (default: hermes-cli)" + ) + login_parser.add_argument( + "--scope", + default=None, + help="OAuth scope to request" + ) + login_parser.add_argument( + "--no-browser", + action="store_true", + help="Do not attempt to open the browser automatically" + ) + login_parser.add_argument( + "--timeout", + type=float, + default=15.0, + help="HTTP request timeout in seconds (default: 15)" + ) + login_parser.add_argument( + "--ca-bundle", + help="Path to CA bundle PEM file for TLS verification" + ) + login_parser.add_argument( + "--insecure", + action="store_true", + help="Disable TLS verification (testing only)" + ) + login_parser.set_defaults(func=cmd_login) + + # ========================================================================= + # logout command + # ========================================================================= + logout_parser = subparsers.add_parser( + "logout", + help="Clear authentication for an inference provider", + description="Remove stored credentials and reset provider config" + ) + logout_parser.add_argument( + "--provider", + choices=["nous"], + default=None, + help="Provider to log out from (default: active provider)" + ) + logout_parser.set_defaults(func=cmd_logout) + # ========================================================================= # status command # ========================================================================= @@ -712,9 +808,9 @@ For more help on a command: # Default to chat if no command specified if args.command is None: - # No command = run chat args.query = None args.model = None + args.provider = None args.toolsets = None args.verbose = False cmd_chat(args) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index fea8b9eede..41b9cff450 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -437,127 +437,233 @@ def run_setup_wizard(args): print_info("You can edit these files directly or use 'hermes config edit'") # ========================================================================= - # Step 1: OpenRouter API Key (Required for tools) + # Step 1: Inference Provider Selection # ========================================================================= - print_header("OpenRouter API Key (Required)") - print_info("OpenRouter is used for vision, web scraping, and tool operations") - print_info("even if you use a custom endpoint for your main agent.") - print_info("Get your API key at: https://openrouter.ai/keys") - + print_header("Inference Provider") + print_info("Choose how to connect to your main chat model.") + print() + + # Detect current provider state + from hermes_cli.auth import ( + get_active_provider, get_provider_auth_state, PROVIDER_REGISTRY, + format_auth_error, AuthError, fetch_nous_models, + resolve_nous_runtime_credentials, _update_config_for_provider, + ) + existing_custom = get_env_value("OPENAI_BASE_URL") existing_or = get_env_value("OPENROUTER_API_KEY") - if existing_or: - print_info(f"Current: {existing_or[:8]}... (configured)") - if prompt_yes_no("Update OpenRouter API key?", False): + active_oauth = get_active_provider() + + # Build "keep current" label + if active_oauth and active_oauth in PROVIDER_REGISTRY: + keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})" + elif existing_custom: + keep_label = f"Keep current (Custom: {existing_custom})" + elif existing_or: + keep_label = "Keep current (OpenRouter)" + else: + keep_label = "Keep current" + + provider_choices = [ + "Login with Nous Portal (Nous Research subscription)", + "OpenRouter API key (100+ models, pay-per-use)", + "Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", + keep_label, + ] + + provider_idx = prompt_choice("Select your inference provider:", provider_choices, 3) + + # Track which provider was selected for model step + selected_provider = None # "nous", "openrouter", "custom", or None (keep) + nous_models = [] # populated if Nous login succeeds + + if provider_idx == 0: # Nous Portal + selected_provider = "nous" + print() + print_header("Nous Portal Login") + print_info("This will open your browser to authenticate with Nous Portal.") + print_info("You'll need a Nous Research account with an active subscription.") + print() + + try: + from hermes_cli.auth import _login_nous, ProviderConfig + import argparse + 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, + ) + pconfig = PROVIDER_REGISTRY["nous"] + _login_nous(mock_args, pconfig) + + # Fetch models for the selection step + try: + creds = resolve_nous_runtime_credentials( + min_key_ttl_seconds=5 * 60, timeout_seconds=15.0, + ) + nous_models = fetch_nous_models( + inference_base_url=creds.get("base_url", ""), + api_key=creds.get("api_key", ""), + ) + except Exception: + pass + + except SystemExit: + print_warning("Nous Portal login was cancelled or failed.") + print_info("You can try again later with: hermes login") + selected_provider = None + except Exception as e: + print_error(f"Login failed: {e}") + print_info("You can try again later with: hermes login") + selected_provider = None + + elif provider_idx == 1: # OpenRouter + selected_provider = "openrouter" + print() + print_header("OpenRouter API Key") + print_info("OpenRouter provides access to 100+ models from multiple providers.") + print_info("Get your API key at: https://openrouter.ai/keys") + + if existing_or: + print_info(f"Current: {existing_or[:8]}... (configured)") + if prompt_yes_no("Update OpenRouter API key?", False): + api_key = prompt(" OpenRouter API key", password=True) + if api_key: + save_env_value("OPENROUTER_API_KEY", api_key) + print_success("OpenRouter API key updated") + else: api_key = prompt(" OpenRouter API key", password=True) if api_key: save_env_value("OPENROUTER_API_KEY", api_key) - print_success("OpenRouter API key updated") - else: - api_key = prompt(" OpenRouter API key", password=True) - if api_key: - save_env_value("OPENROUTER_API_KEY", api_key) - print_success("OpenRouter API key saved") - else: - print_warning("Skipped - some tools (vision, web scraping) won't work without this") - - # ========================================================================= - # Step 2: Main Agent Provider - # ========================================================================= - print_header("Main Agent Provider") - print_info("Choose how to connect to your main chat model.") - - existing_custom = get_env_value("OPENAI_BASE_URL") - - provider_choices = [ - "OpenRouter (use same key for agent - recommended)", - "Custom OpenAI-compatible endpoint (separate from OpenRouter)", - f"Keep current" + (f" ({existing_custom})" if existing_custom else " (OpenRouter)") - ] - - provider_idx = prompt_choice("Select your main agent provider:", provider_choices, 2) - - if provider_idx == 0: # OpenRouter for agent too - # Clear any custom endpoint - will use OpenRouter + print_success("OpenRouter API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear any custom endpoint if switching to OpenRouter if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") - print_success("Agent will use OpenRouter") - - elif provider_idx == 1: # Custom endpoint - print_info("Custom OpenAI-Compatible Endpoint Configuration:") + + elif provider_idx == 2: # Custom endpoint + selected_provider = "custom" + print() + print_header("Custom OpenAI-Compatible Endpoint") print_info("Works with any API that follows OpenAI's chat completions spec") - - # Show current values if set + current_url = get_env_value("OPENAI_BASE_URL") or "" current_key = get_env_value("OPENAI_API_KEY") current_model = config.get('model', '') - + if current_url: print_info(f" Current URL: {current_url}") if current_key: print_info(f" Current key: {current_key[:8]}... (configured)") - + base_url = prompt(" API base URL (e.g., https://api.example.com/v1)", current_url) api_key = prompt(" API key", password=True) model_name = prompt(" Model name (e.g., gpt-4, claude-3-opus)", current_model) - + 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: config['model'] = model_name + save_env_value("LLM_MODEL", model_name) print_success("Custom endpoint configured") - # else: Keep current (provider_idx == 2) - + # else: provider_idx == 3, keep current + # ========================================================================= - # Step 3: Model Selection + # Step 1b: OpenRouter API Key for tools (if not already set) # ========================================================================= - print_header("Default Model") - - current_model = config.get('model', 'anthropic/claude-opus-4.6') - print_info(f"Current: {current_model}") - - model_choices = [ - "anthropic/claude-opus-4.6 (recommended)", - "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", - "Custom model", - f"Keep current ({current_model})" - ] - - model_idx = prompt_choice("Select default model:", model_choices, 11) # Default: keep current - - model_map = { - 0: "anthropic/claude-opus-4.6", - 1: "anthropic/claude-sonnet-4.5", - 2: "anthropic/claude-opus-4.5", - 3: "openai/gpt-5.2", - 4: "openai/gpt-5.2-codex", - 5: "google/gemini-3-pro-preview", - 6: "google/gemini-3-flash-preview", - 7: "z-ai/glm-4.7", - 8: "moonshotai/kimi-k2.5", - 9: "minimax/minimax-m2.1", - } - - if model_idx in model_map: - config['model'] = model_map[model_idx] - # Also update LLM_MODEL in .env so it stays in sync (cli.py reads .env first) - save_env_value("LLM_MODEL", model_map[model_idx]) - elif model_idx == 10: # Custom - custom = prompt("Enter model name (e.g., anthropic/claude-opus-4.6)") - if custom: - config['model'] = custom - save_env_value("LLM_MODEL", custom) - # else: Keep current (model_idx == 11) + # Tools (vision, web, MoA) use OpenRouter independently of the main provider. + # Prompt for OpenRouter key if not set and a non-OpenRouter provider was chosen. + if selected_provider in ("nous", "custom") and not get_env_value("OPENROUTER_API_KEY"): + print() + print_header("OpenRouter API Key (for tools)") + print_info("Tools like vision analysis, web search, and MoA use OpenRouter") + print_info("independently of your main inference provider.") + print_info("Get your API key at: https://openrouter.ai/keys") + + api_key = prompt(" OpenRouter API key (optional, press Enter to skip)", password=True) + if api_key: + save_env_value("OPENROUTER_API_KEY", api_key) + print_success("OpenRouter API key saved (for tools)") + else: + print_info("Skipped - some tools (vision, web scraping) won't work without this") + + # ========================================================================= + # Step 2: Model Selection (adapts based on provider) + # ========================================================================= + if selected_provider != "custom": # Custom already prompted for model name + print_header("Default Model") + + current_model = config.get('model', 'anthropic/claude-opus-4.6') + print_info(f"Current: {current_model}") + + if selected_provider == "nous" and nous_models: + # Dynamic model list from Nous Portal + model_choices = [f"{m}" for m in nous_models] + model_choices.append("Custom model") + model_choices.append(f"Keep current ({current_model})") + + # Post-login validation: warn if current model might not be available + if current_model and current_model not in nous_models: + print_warning(f"Your current model ({current_model}) may not be available via Nous Portal.") + print_info("Select a model from the list, or keep current to use it anyway.") + print() + + model_idx = prompt_choice("Select default model:", model_choices, len(model_choices) - 1) + + if model_idx < len(nous_models): + config['model'] = nous_models[model_idx] + save_env_value("LLM_MODEL", nous_models[model_idx]) + elif model_idx == len(nous_models): # Custom + custom = prompt("Enter model name") + if custom: + config['model'] = custom + save_env_value("LLM_MODEL", custom) + # else: keep current + else: + # Static list for OpenRouter / fallback + model_choices = [ + "anthropic/claude-opus-4.6 (recommended)", + "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", + "Custom model", + f"Keep current ({current_model})" + ] + + model_idx = prompt_choice("Select default model:", model_choices, 11) + + model_map = { + 0: "anthropic/claude-opus-4.6", + 1: "anthropic/claude-sonnet-4.5", + 2: "anthropic/claude-opus-4.5", + 3: "openai/gpt-5.2", + 4: "openai/gpt-5.2-codex", + 5: "google/gemini-3-pro-preview", + 6: "google/gemini-3-flash-preview", + 7: "z-ai/glm-4.7", + 8: "moonshotai/kimi-k2.5", + 9: "minimax/minimax-m2.1", + } + + if model_idx in model_map: + config['model'] = model_map[model_idx] + save_env_value("LLM_MODEL", model_map[model_idx]) + elif model_idx == 10: # Custom + custom = prompt("Enter model name (e.g., anthropic/claude-opus-4.6)") + if custom: + config['model'] = custom + save_env_value("LLM_MODEL", custom) + # else: Keep current (model_idx == 11) # ========================================================================= # Step 4: Terminal Backend diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 0e8f739d7e..f4d8fb6731 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -40,6 +40,25 @@ def redact_key(key: str) -> str: return key[:4] + "..." + key[-4:] +def _format_iso_timestamp(value) -> str: + """Format ISO timestamps for status output, converting to local timezone.""" + if not value or not isinstance(value, str): + return "(unknown)" + from datetime import datetime, timezone + text = value.strip() + if not text: + return "(unknown)" + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + except Exception: + return value + return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + + def show_status(args): """Show status of all Hermes Agent components.""" show_all = getattr(args, 'all', False) @@ -85,7 +104,34 @@ def show_status(args): has_key = bool(value) display = redact_key(value) if not show_all else value print(f" {name:<12} {check_mark(has_key)} {display}") - + + # ========================================================================= + # Auth Providers (OAuth) + # ========================================================================= + print() + print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) + + try: + from hermes_cli.auth import get_nous_auth_status + nous_status = get_nous_auth_status() + except Exception: + nous_status = {} + + nous_logged_in = bool(nous_status.get("logged_in")) + print( + f" {'Nous Portal':<12} {check_mark(nous_logged_in)} " + f"{'logged in' if nous_logged_in else 'not logged in (run: hermes login)'}" + ) + if nous_logged_in: + portal_url = nous_status.get("portal_base_url") or "(unknown)" + access_exp = _format_iso_timestamp(nous_status.get("access_expires_at")) + key_exp = _format_iso_timestamp(nous_status.get("agent_key_expires_at")) + refresh_label = "yes" if nous_status.get("has_refresh_token") else "no" + print(f" Portal URL: {portal_url}") + print(f" Access exp: {access_exp}") + print(f" Key exp: {key_exp}") + print(f" Refresh: {refresh_label}") + # ========================================================================= # Terminal Configuration # ========================================================================= diff --git a/run_agent.py b/run_agent.py index 4d37a365be..3ef98bdb94 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2031,7 +2031,96 @@ class AIAgent: # Silent fail - don't interrupt the agent for debug logging if self.verbose_logging: logging.warning(f"Failed to log API payload: {e}") - + + def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]: + if not key: + return None + if len(key) <= 12: + return "***" + return f"{key[:8]}...{key[-4:]}" + + def _dump_api_request_debug( + self, + api_kwargs: Dict[str, Any], + *, + reason: str, + error: Optional[Exception] = None, + ) -> Optional[Path]: + """ + Dump a debug-friendly HTTP request record for chat.completions.create(). + + Captures the request body from api_kwargs (excluding transport-only keys + like timeout). Intended for debugging provider-side 4xx failures where + retries are not useful. + """ + try: + body = copy.deepcopy(api_kwargs) + body.pop("timeout", None) + body = {k: v for k, v in body.items() if v is not None} + + api_key = None + try: + api_key = getattr(self.client, "api_key", None) + except Exception: + pass + + dump_payload: Dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "session_id": self.session_id, + "reason": reason, + "request": { + "method": "POST", + "url": f"{self.base_url.rstrip('/')}/chat/completions", + "headers": { + "Authorization": f"Bearer {self._mask_api_key_for_logs(api_key)}", + "Content-Type": "application/json", + }, + "body": body, + }, + } + + if error is not None: + error_info: Dict[str, Any] = { + "type": type(error).__name__, + "message": str(error), + } + for attr_name in ("status_code", "request_id", "code", "param", "type"): + attr_value = getattr(error, attr_name, None) + if attr_value is not None: + error_info[attr_name] = attr_value + + body_attr = getattr(error, "body", None) + if body_attr is not None: + error_info["body"] = body_attr + + response_obj = getattr(error, "response", None) + if response_obj is not None: + try: + error_info["response_status"] = getattr(response_obj, "status_code", None) + error_info["response_text"] = response_obj.text + except Exception: + pass + + dump_payload["error"] = error_info + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + dump_file = self.logs_dir / f"request_dump_{self.session_id}_{timestamp}.json" + dump_file.write_text( + json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + + print(f"{self.log_prefix}🧾 Request debug dump written to: {dump_file}") + + if os.getenv("HERMES_DUMP_REQUEST_STDOUT", "").strip().lower() in {"1", "true", "yes", "on"}: + print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)) + + return dump_file + except Exception as dump_error: + if self.verbose_logging: + logging.warning(f"Failed to dump API request debug payload: {dump_error}") + return None + def _save_session_log(self, messages: List[Dict[str, Any]] = None): """ Save the current session trajectory to the logs directory. @@ -2425,7 +2514,10 @@ class AIAgent: if extra_body: api_kwargs["extra_body"] = extra_body - + + if os.getenv("HERMES_DUMP_REQUESTS", "").strip().lower() in {"1", "true", "yes", "on"}: + self._dump_api_request_debug(api_kwargs, reason="preflight") + response = self.client.chat.completions.create(**api_kwargs) api_duration = time.time() - api_start_time @@ -2624,7 +2716,9 @@ class AIAgent: # Check for non-retryable client errors (4xx HTTP status codes). # These indicate a problem with the request itself (bad model ID, # invalid API key, forbidden, etc.) and will never succeed on retry. - is_client_error = any(phrase in error_msg for phrase in [ + status_code = getattr(api_error, "status_code", None) + is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 + is_client_error = is_client_status_error or any(phrase in error_msg for phrase in [ 'error code: 400', 'error code: 401', 'error code: 403', 'error code: 404', 'error code: 422', 'is not a valid model', 'invalid model', 'model not found', @@ -2633,6 +2727,9 @@ class AIAgent: ]) if is_client_error: + self._dump_api_request_debug( + api_kwargs, reason="non_retryable_client_error", error=api_error, + ) print(f"{self.log_prefix}❌ Non-retryable client error detected. Aborting immediately.") print(f"{self.log_prefix} 💡 This type of error won't be fixed by retrying.") logging.error(f"{self.log_prefix}Non-retryable client error: {api_error}")