From de07aa7c40468361fc7684e62e8ee566520ff1fe Mon Sep 17 00:00:00 2001 From: Indelwin Date: Sun, 8 Mar 2026 18:40:50 +1000 Subject: [PATCH] feat: add Nous Portal API key provider (#644) Add support for using Nous Portal via a direct API key, mirroring how OpenRouter and other API-key providers work. This gives users a simpler alternative to the OAuth device-code flow when they already have a Nous API key. Changes: - Add 'nous-api' to PROVIDER_REGISTRY as an api_key provider pointing to https://inference-api.nousresearch.com/v1 - Add NOUS_API_KEY and NOUS_BASE_URL to OPTIONAL_ENV_VARS - Add NOUS_API_BASE_URL / NOUS_API_CHAT_URL to hermes_constants - Add 'Nous Portal API key' as first option in setup wizard - Add provider aliases (nous_api, nousapi, nous-portal-api) - Add test for nous-api runtime provider resolution Closes #644 --- cli-config.yaml.example | 1 + hermes_cli/auth.py | 9 ++++ hermes_cli/config.py | 16 ++++++ hermes_cli/setup.py | 63 ++++++++++++++++++----- hermes_constants.py | 3 ++ tests/test_runtime_provider_resolution.py | 23 +++++++++ 6 files changed, 103 insertions(+), 12 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 138345154..33f3702c5 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -11,6 +11,7 @@ model: # Inference provider selection: # "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default) + # "nous-api" - Use Nous Portal via API key (requires: NOUS_API_KEY) # "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY # "nous" - Always use Nous Portal (requires: hermes login) # "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index cde67b2b8..b7c18f92c 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -108,6 +108,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL, ), + "nous-api": ProviderConfig( + id="nous-api", + name="Nous Portal (API Key)", + auth_type="api_key", + inference_base_url="https://inference-api.nousresearch.com/v1", + api_key_env_vars=("NOUS_API_KEY",), + base_url_env_var="NOUS_BASE_URL", + ), "zai": ProviderConfig( id="zai", name="Z.AI / GLM", @@ -513,6 +521,7 @@ def resolve_provider( # Normalize provider aliases _PROVIDER_ALIASES = { + "nous_api": "nous-api", "nousapi": "nous-api", "nous-portal-api": "nous-api", "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", "kimi": "kimi-coding", "moonshot": "kimi-coding", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7e81962fa..a56b15e91 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -207,6 +207,22 @@ REQUIRED_ENV_VARS = {} # Optional environment variables that enhance functionality OPTIONAL_ENV_VARS = { # ── Provider (handled in provider selection, not shown in checklists) ── + "NOUS_API_KEY": { + "description": "Nous Portal API key (direct API key access to Nous inference)", + "prompt": "Nous Portal API key", + "url": "https://portal.nousresearch.com", + "password": True, + "category": "provider", + "advanced": True, + }, + "NOUS_BASE_URL": { + "description": "Nous Portal base URL override", + "prompt": "Nous Portal base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "OPENROUTER_API_KEY": { "description": "OpenRouter API key (for vision, web scraping helpers, and MoA)", "prompt": "OpenRouter API key", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 67958aa2a..06842fa4c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -516,7 +516,8 @@ def setup_model_provider(config: dict): keep_label = None # No provider configured — don't show "Keep current" provider_choices = [ - "Login with Nous Portal (Nous Research subscription)", + "Nous Portal API key (direct API key access)", + "Login with Nous Portal (Nous Research subscription — OAuth)", "Login with OpenAI Codex", "OpenRouter API key (100+ models, pay-per-use)", "Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", @@ -529,7 +530,7 @@ def setup_model_provider(config: dict): provider_choices.append(keep_label) # Default to "Keep current" if a provider exists, otherwise OpenRouter (most common) - default_provider = len(provider_choices) - 1 if has_any_provider else 2 + default_provider = len(provider_choices) - 1 if has_any_provider else 3 if not has_any_provider: print_warning("An inference provider is required for Hermes to work.") @@ -541,7 +542,37 @@ def setup_model_provider(config: dict): selected_provider = None # "nous", "openai-codex", "openrouter", "custom", or None (keep) nous_models = [] # populated if Nous login succeeds - if provider_idx == 0: # Nous Portal + if provider_idx == 0: # Nous Portal API Key (direct) + selected_provider = "nous-api" + print() + print_header("Nous Portal API Key") + print_info("Use a Nous Portal API key for direct access to Nous inference.") + print_info("Get your API key at: https://portal.nousresearch.com") + print() + + existing_key = get_env_value("NOUS_API_KEY") + if existing_key: + print_info(f"Current: {existing_key[:8]}... (configured)") + if prompt_yes_no("Update Nous API key?", False): + api_key = prompt(" Nous API key", password=True) + if api_key: + save_env_value("NOUS_API_KEY", api_key) + print_success("Nous API key updated") + else: + api_key = prompt(" Nous API key", password=True) + if api_key: + save_env_value("NOUS_API_KEY", api_key) + print_success("Nous API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear custom endpoint vars if switching + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _update_config_for_provider("nous-api", "https://inference-api.nousresearch.com/v1") + + elif provider_idx == 1: # Nous Portal selected_provider = "nous" print() print_header("Nous Portal Login") @@ -581,7 +612,7 @@ def setup_model_provider(config: dict): print_info("You can try again later with: hermes model") selected_provider = None - elif provider_idx == 1: # OpenAI Codex + elif provider_idx == 2: # OpenAI Codex selected_provider = "openai-codex" print() print_header("OpenAI Codex Login") @@ -605,7 +636,7 @@ def setup_model_provider(config: dict): print_info("You can try again later with: hermes model") selected_provider = None - elif provider_idx == 2: # OpenRouter + elif provider_idx == 3: # OpenRouter selected_provider = "openrouter" print() print_header("OpenRouter API Key") @@ -655,7 +686,7 @@ def setup_model_provider(config: dict): except Exception as e: logger.debug("Could not save provider to config.yaml: %s", e) - elif provider_idx == 3: # Custom endpoint + elif provider_idx == 4: # Custom endpoint selected_provider = "custom" print() print_header("Custom OpenAI-Compatible Endpoint") @@ -706,7 +737,7 @@ def setup_model_provider(config: dict): print_success("Custom endpoint configured") - elif provider_idx == 4: # Z.AI / GLM + elif provider_idx == 5: # Z.AI / GLM selected_provider = "zai" print() print_header("Z.AI / GLM API Key") @@ -760,7 +791,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("zai", zai_base_url) - elif provider_idx == 5: # Kimi / Moonshot + elif provider_idx == 6: # Kimi / Moonshot selected_provider = "kimi-coding" print() print_header("Kimi / Moonshot API Key") @@ -792,7 +823,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("kimi-coding", pconfig.inference_base_url) - elif provider_idx == 6: # MiniMax + elif provider_idx == 7: # MiniMax selected_provider = "minimax" print() print_header("MiniMax API Key") @@ -824,7 +855,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax", pconfig.inference_base_url) - elif provider_idx == 7: # MiniMax China + elif provider_idx == 8: # MiniMax China selected_provider = "minimax-cn" print() print_header("MiniMax China API Key") @@ -856,12 +887,12 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax-cn", pconfig.inference_base_url) - # else: provider_idx == 8 (Keep current) — only shown when a provider already exists + # else: provider_idx == 9 (Keep current) — only shown when a provider already exists # ── OpenRouter API Key for tools (if not already set) ── # 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", "openai-codex", "custom", "zai", "kimi-coding", "minimax", "minimax-cn") and not get_env_value("OPENROUTER_API_KEY"): + if selected_provider in ("nous", "nous-api", "openai-codex", "custom", "zai", "kimi-coding", "minimax", "minimax-cn") 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") @@ -914,6 +945,14 @@ def setup_model_provider(config: dict): if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) + elif selected_provider == "nous-api": + # Nous API key provider — prompt for model manually + print_info("Enter a model name available on Nous inference API.") + print_info("Examples: anthropic/claude-opus-4.6, deepseek/deepseek-r1") + custom = prompt(f" Model name (Enter to keep '{current_model}')") + if custom: + config['model'] = custom + save_env_value("LLM_MODEL", custom) elif selected_provider == "openai-codex": from hermes_cli.codex_models import get_codex_model_ids codex_models = get_codex_model_ids() diff --git a/hermes_constants.py b/hermes_constants.py index 066194c87..a81af04d3 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -7,3 +7,6 @@ without risk of circular imports. OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions" + +NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1" +NOUS_API_CHAT_URL = f"{NOUS_API_BASE_URL}/chat/completions" diff --git a/tests/test_runtime_provider_resolution.py b/tests/test_runtime_provider_resolution.py index f55af44c5..031457a53 100644 --- a/tests/test_runtime_provider_resolution.py +++ b/tests/test_runtime_provider_resolution.py @@ -158,6 +158,29 @@ def test_custom_endpoint_auto_provider_prefers_openai_key(monkeypatch): assert resolved["api_key"] == "sk-vllm-key" +def test_resolve_runtime_provider_nous_api(monkeypatch): + """Nous Portal API key provider resolves via the api_key path.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous-api") + monkeypatch.setattr( + rp, + "resolve_api_key_provider_credentials", + lambda pid: { + "provider": "nous-api", + "api_key": "nous-test-key", + "base_url": "https://inference-api.nousresearch.com/v1", + "source": "NOUS_API_KEY", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="nous-api") + + assert resolved["provider"] == "nous-api" + assert resolved["api_mode"] == "chat_completions" + assert resolved["base_url"] == "https://inference-api.nousresearch.com/v1" + assert resolved["api_key"] == "nous-test-key" + assert resolved["requested_provider"] == "nous-api" + + def test_resolve_requested_provider_precedence(monkeypatch): monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous") monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "openai-codex"})