diff --git a/hermes_cli/main.py b/hermes_cli/main.py index aad6e7f14..a12879a8b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -829,6 +829,17 @@ def cmd_setup(args): def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" _require_tty("model") + select_provider_and_model() + + +def select_provider_and_model(): + """Core provider selection + model picking logic. + + Shared by ``cmd_model`` (``hermes model``) and the setup wizard + (``setup_model_provider`` in setup.py). Handles the full flow: + provider picker, credential prompting, model selection, and config + persistence. + """ from hermes_cli.auth import ( resolve_provider, AuthError, format_auth_error, ) @@ -858,7 +869,10 @@ def cmd_model(args): except AuthError as exc: warning = format_auth_error(exc) print(f"Warning: {warning} Falling back to auto provider detection.") - active = resolve_provider("auto") + try: + active = resolve_provider("auto") + except AuthError: + active = "openrouter" # no provider yet; show full picker # Detect custom endpoint if active == "openrouter" and get_env_value("OPENAI_BASE_URL"): diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 648876d92..50368915c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -805,715 +805,49 @@ def _prompt_container_resources(config: dict): # ============================================================================= + def setup_model_provider(config: dict): - """Configure the inference provider and default model.""" - from hermes_cli.auth import ( - get_active_provider, - PROVIDER_REGISTRY, - fetch_nous_models, - resolve_nous_runtime_credentials, - _update_config_for_provider, - _login_openai_codex, - resolve_codex_runtime_credentials, - DEFAULT_CODEX_BASE_URL, - detect_external_credentials, - get_auth_status, - resolve_api_key_provider_credentials, - ) + """Configure the inference provider and default model. + + Delegates to ``cmd_model()`` (the same flow used by ``hermes model``) + for provider selection, credential prompting, and model picking. + This ensures a single code path for all provider setup — any new + provider added to ``hermes model`` is automatically available here. + """ + from hermes_cli.config import load_config, save_config print_header("Inference Provider") print_info("Choose how to connect to your main chat model.") print() - existing_or = get_env_value("OPENROUTER_API_KEY") - active_oauth = get_active_provider() - existing_custom = get_env_value("OPENAI_BASE_URL") - copilot_status = get_auth_status("copilot") - copilot_acp_status = get_auth_status("copilot-acp") - - model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {} - current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None - if current_config_provider == "auto": - current_config_provider = None - current_config_base_url = str(model_cfg.get("base_url") or "").strip() - - # Detect credentials from other CLI tools - detected_creds = detect_external_credentials() - if detected_creds: - print_info("Detected existing credentials:") - for cred in detected_creds: - if cred["provider"] == "openai-codex": - print_success(f' * {cred["label"]} -- select "OpenAI Codex" to use it') - else: - print_info(f" * {cred['label']}") + # Delegate to the shared hermes model flow — handles provider picker, + # credential prompting, model selection, and config persistence. + from hermes_cli.main import select_provider_and_model + try: + select_provider_and_model() + except (SystemExit, KeyboardInterrupt): print() + print_info("Provider setup skipped.") + except Exception as exc: + logger.debug("select_provider_and_model error during setup: %s", exc) + print_warning(f"Provider setup encountered an error: {exc}") + print_info("You can try again later with: hermes model") + + # Re-sync the wizard's config dict from what cmd_model saved to disk. + # This is critical: cmd_model writes to disk via its own load/save cycle, + # and the wizard's final save_config(config) must not overwrite those + # changes with stale values (#4172). + _refreshed = load_config() + config["model"] = _refreshed.get("model", config.get("model")) + if _refreshed.get("custom_providers"): + config["custom_providers"] = _refreshed["custom_providers"] + + # Derive the selected provider for downstream steps (vision setup). + selected_provider = None + _m = config.get("model") + if isinstance(_m, dict): + selected_provider = _m.get("provider") - # Detect if any provider is already configured - has_any_provider = bool( - current_config_provider - or active_oauth - or existing_custom - or existing_or - or copilot_status.get("logged_in") - or copilot_acp_status.get("logged_in") - ) - - # Build "keep current" label - if current_config_provider == "custom": - custom_label = current_config_base_url or existing_custom - keep_label = ( - f"Keep current (Custom: {custom_label})" - if custom_label - else "Keep current (Custom)" - ) - elif current_config_provider == "openrouter": - keep_label = "Keep current (OpenRouter)" - elif current_config_provider and current_config_provider in PROVIDER_REGISTRY: - keep_label = f"Keep current ({PROVIDER_REGISTRY[current_config_provider].name})" - elif 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 = None # No provider configured — don't show "Keep current" - - provider_choices = [ - "OpenRouter API key (100+ models, pay-per-use)", - "Login with Nous Portal (Nous Research subscription — OAuth)", - "Login with OpenAI Codex", - "Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", - "Z.AI / GLM (Zhipu AI models)", - "Kimi / Moonshot (Kimi coding models)", - "MiniMax (global endpoint)", - "MiniMax China (mainland China endpoint)", - "Kilo Code (Kilo Gateway API)", - "Anthropic (Claude models — API key or Claude Code subscription)", - "AI Gateway (Vercel — 200+ models, pay-per-use)", - "Alibaba Cloud / DashScope (Qwen models via Anthropic-compatible API)", - "OpenCode Zen (35+ curated models, pay-as-you-go)", - "OpenCode Go (open models, $10/month subscription)", - "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)", - "GitHub Copilot ACP (spawns `copilot --acp --stdio`)", - "Hugging Face Inference Providers (20+ open models)", - ] - if keep_label: - 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 0 - - if not has_any_provider: - print_warning("An inference provider is required for Hermes to work.") - print() - - provider_idx = prompt_choice( - "Select your inference provider:", provider_choices, default_provider - ) - - # Track which provider was selected for model step - selected_provider = ( - None # "nous", "openai-codex", "openrouter", "custom", or None (keep) - ) - selected_base_url = None # deferred until after model selection - nous_models = [] # populated if Nous login succeeds - - if provider_idx == 0: # 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 saved") - else: - print_warning("Skipped - agent won't work without an API key") - - - # Update config.yaml and deactivate any OAuth provider so the - # resolver doesn't keep returning the old provider (e.g. Codex). - try: - from hermes_cli.auth import deactivate_provider - - deactivate_provider() - except Exception: - pass - import yaml - - config_path = ( - Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" - ) - try: - disk_cfg = {} - if config_path.exists(): - disk_cfg = yaml.safe_load(config_path.read_text()) or {} - model_section = disk_cfg.get("model", {}) - if isinstance(model_section, str): - model_section = {"default": model_section} - model_section["provider"] = "openrouter" - model_section.pop("base_url", None) # OpenRouter uses default URL - disk_cfg["model"] = model_section - config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False)) - _set_model_provider(config, "openrouter") - except Exception as e: - logger.debug("Could not save provider to config.yaml: %s", e) - - elif provider_idx == 1: # Nous Portal (OAuth) - 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 - 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) - _sync_model_from_disk(config) - - # Fetch models for the selection step - try: - creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=5 * 60, - timeout_seconds=15.0, - ) - # Use curated model list instead of full /models dump - from hermes_cli.models import _PROVIDER_MODELS - nous_models = _PROVIDER_MODELS.get("nous", []) - except Exception as e: - logger.debug("Could not fetch Nous models after login: %s", e) - - except SystemExit: - print_warning("Nous Portal login was cancelled or failed.") - print_info("You can try again later with: hermes model") - selected_provider = None - except Exception as e: - print_error(f"Login failed: {e}") - print_info("You can try again later with: hermes model") - selected_provider = None - - elif provider_idx == 2: # OpenAI Codex - selected_provider = "openai-codex" - print() - print_header("OpenAI Codex Login") - print() - - try: - import argparse - - mock_args = argparse.Namespace() - _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) - _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) - _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) - except SystemExit: - print_warning("OpenAI Codex login was cancelled or failed.") - print_info("You can try again later with: hermes model") - selected_provider = None - except Exception as e: - print_error(f"Login failed: {e}") - print_info("You can try again later with: hermes model") - selected_provider = None - - elif provider_idx == 3: # 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") - print() - - # Reuse the shared custom endpoint flow from `hermes model`. - # This handles: URL/key/model/context-length prompts, endpoint probing, - # env saving, config.yaml updates, and custom_providers persistence. - from hermes_cli.main import _model_flow_custom - _model_flow_custom(config) - # _model_flow_custom handles model selection, config, env vars, - # and custom_providers. Keep selected_provider = "custom" so - # the model selection step below is skipped (line 1631 check) - # but vision and TTS setup still run. - - elif provider_idx == 4: # Z.AI / GLM - selected_provider = "zai" - print() - print_header("Z.AI / GLM API Key") - pconfig = PROVIDER_REGISTRY["zai"] - print_info(f"Provider: {pconfig.name}") - print_info("Get your API key at: https://open.bigmodel.cn/") - print() - - existing_key = get_env_value("GLM_API_KEY") or get_env_value("ZAI_API_KEY") - api_key = existing_key # will be overwritten if user enters a new one - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - new_key = prompt(" GLM API key", password=True) - if new_key: - api_key = new_key - save_env_value("GLM_API_KEY", api_key) - print_success("GLM API key updated") - else: - api_key = prompt(" GLM API key", password=True) - if api_key: - save_env_value("GLM_API_KEY", api_key) - print_success("GLM API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - # Detect the correct z.ai endpoint for this key. - # Z.AI has separate billing for general vs coding plans and - # global vs China endpoints — we probe to find the right one. - zai_base_url = pconfig.inference_base_url - if api_key: - print() - print_info("Detecting your z.ai endpoint...") - from hermes_cli.auth import detect_zai_endpoint - - detected = detect_zai_endpoint(api_key) - if detected: - zai_base_url = detected["base_url"] - print_success(f"Detected: {detected['label']} endpoint") - print_info(f" URL: {detected['base_url']}") - if detected["id"].startswith("coding"): - print_info( - f" Note: Coding Plan endpoint detected (default model: {detected['model']}). " - f"GLM-5 may still be available depending on your plan tier." - ) - save_env_value("GLM_BASE_URL", zai_base_url) - else: - print_warning("Could not verify any z.ai endpoint with this key.") - print_info(f" Using default: {zai_base_url}") - print_info( - " If you get billing errors, check your plan at https://open.bigmodel.cn/" - ) - - _set_model_provider(config, "zai", zai_base_url) - selected_base_url = zai_base_url - - elif provider_idx == 5: # Kimi / Moonshot - selected_provider = "kimi-coding" - print() - print_header("Kimi / Moonshot API Key") - pconfig = PROVIDER_REGISTRY["kimi-coding"] - print_info(f"Provider: {pconfig.name}") - print_info(f"Base URL: {pconfig.inference_base_url}") - print_info("Get your API key at: https://platform.moonshot.cn/") - print() - - existing_key = get_env_value("KIMI_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - api_key = prompt(" Kimi API key", password=True) - if api_key: - save_env_value("KIMI_API_KEY", api_key) - print_success("Kimi API key updated") - else: - api_key = prompt(" Kimi API key", password=True) - if api_key: - save_env_value("KIMI_API_KEY", api_key) - print_success("Kimi API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _set_model_provider(config, "kimi-coding", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 6: # MiniMax - selected_provider = "minimax" - print() - print_header("MiniMax API Key") - pconfig = PROVIDER_REGISTRY["minimax"] - print_info(f"Provider: {pconfig.name}") - print_info(f"Base URL: {pconfig.inference_base_url}") - print_info("Get your API key at: https://platform.minimaxi.com/") - print() - - existing_key = get_env_value("MINIMAX_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - api_key = prompt(" MiniMax API key", password=True) - if api_key: - save_env_value("MINIMAX_API_KEY", api_key) - print_success("MiniMax API key updated") - else: - api_key = prompt(" MiniMax API key", password=True) - if api_key: - save_env_value("MINIMAX_API_KEY", api_key) - print_success("MiniMax API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _set_model_provider(config, "minimax", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 7: # MiniMax China - selected_provider = "minimax-cn" - print() - print_header("MiniMax China API Key") - pconfig = PROVIDER_REGISTRY["minimax-cn"] - print_info(f"Provider: {pconfig.name}") - print_info(f"Base URL: {pconfig.inference_base_url}") - print_info("Get your API key at: https://platform.minimaxi.com/") - print() - - existing_key = get_env_value("MINIMAX_CN_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - api_key = prompt(" MiniMax CN API key", password=True) - if api_key: - save_env_value("MINIMAX_CN_API_KEY", api_key) - print_success("MiniMax CN API key updated") - else: - api_key = prompt(" MiniMax CN API key", password=True) - if api_key: - save_env_value("MINIMAX_CN_API_KEY", api_key) - print_success("MiniMax CN API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _set_model_provider(config, "minimax-cn", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 8: # Kilo Code - selected_provider = "kilocode" - print() - print_header("Kilo Code API Key") - pconfig = PROVIDER_REGISTRY["kilocode"] - print_info(f"Provider: {pconfig.name}") - print_info(f"Base URL: {pconfig.inference_base_url}") - print_info("Get your API key at: https://kilo.ai") - print() - - existing_key = get_env_value("KILOCODE_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - api_key = prompt(" Kilo Code API key", password=True) - if api_key: - save_env_value("KILOCODE_API_KEY", api_key) - print_success("Kilo Code API key updated") - else: - api_key = prompt(" Kilo Code API key", password=True) - if api_key: - save_env_value("KILOCODE_API_KEY", api_key) - print_success("Kilo Code API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _set_model_provider(config, "kilocode", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 9: # Anthropic - selected_provider = "anthropic" - print() - print_header("Anthropic Authentication") - from hermes_cli.auth import PROVIDER_REGISTRY - from hermes_cli.config import save_anthropic_api_key, save_anthropic_oauth_token - pconfig = PROVIDER_REGISTRY["anthropic"] - - # Check ALL credential sources - import os as _os - from agent.anthropic_adapter import ( - read_claude_code_credentials, is_claude_code_token_valid, - run_oauth_setup_token, - ) - cc_creds = read_claude_code_credentials() - cc_valid = bool(cc_creds and is_claude_code_token_valid(cc_creds)) - - existing_key = ( - get_env_value("ANTHROPIC_TOKEN") - or get_env_value("ANTHROPIC_API_KEY") - or _os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "") - ) - - has_creds = bool(existing_key) or cc_valid - needs_auth = not has_creds - - if has_creds: - if existing_key: - print_info(f"Current credentials: {existing_key[:12]}...") - elif cc_valid: - print_success("Found valid Claude Code credentials (auto-detected)") - - auth_choices = [ - "Use existing credentials", - "Reauthenticate (new OAuth login)", - "Cancel", - ] - choice_idx = prompt_choice("What would you like to do?", auth_choices, 0) - if choice_idx == 1: - needs_auth = True - elif choice_idx == 2: - pass # fall through to provider config - - if needs_auth: - auth_choices = [ - "Claude Pro/Max subscription (OAuth login)", - "Anthropic API key (pay-per-token)", - ] - auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0) - - if auth_idx == 0: - # OAuth setup-token flow - try: - print() - print_info("Running 'claude setup-token' — follow the prompts below.") - print_info("A browser window will open for you to authorize access.") - print() - token = run_oauth_setup_token() - if token: - save_anthropic_oauth_token(token, save_fn=save_env_value) - print_success("OAuth credentials saved") - else: - # Subprocess completed but no token auto-detected - print() - token = prompt("Paste setup-token here (if displayed above)", password=True) - if token: - save_anthropic_oauth_token(token, save_fn=save_env_value) - print_success("Setup-token saved") - else: - print_warning("Skipped — agent won't work without credentials") - except FileNotFoundError: - print() - print_info("The 'claude' CLI is required for OAuth login.") - print() - print_info("To install: npm install -g @anthropic-ai/claude-code") - print_info("Then run: claude setup-token") - print_info("Or paste an existing setup-token below:") - print() - token = prompt("Setup-token (sk-ant-oat-...)", password=True) - if token: - save_anthropic_oauth_token(token, save_fn=save_env_value) - print_success("Setup-token saved") - else: - print_warning("Skipped — install Claude Code and re-run setup") - else: - print() - print_info("Get an API key at: https://console.anthropic.com/settings/keys") - print() - api_key = prompt("API key (sk-ant-...)", password=True) - if api_key: - save_anthropic_api_key(api_key, save_fn=save_env_value) - print_success("API key saved") - else: - print_warning("Skipped — agent won't work without credentials") - - # Don't save base_url for Anthropic — resolve_runtime_provider() - # always hardcodes it. Stale base_urls contaminate other providers. - _set_model_provider(config, "anthropic") - selected_base_url = "" - - elif provider_idx == 10: # AI Gateway - selected_provider = "ai-gateway" - print() - print_header("AI Gateway API Key") - pconfig = PROVIDER_REGISTRY["ai-gateway"] - print_info(f"Provider: {pconfig.name}") - print_info("Get your API key at: https://vercel.com/docs/ai-gateway") - print() - - existing_key = get_env_value("AI_GATEWAY_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - api_key = prompt(" AI Gateway API key", password=True) - if api_key: - save_env_value("AI_GATEWAY_API_KEY", api_key) - print_success("AI Gateway API key updated") - else: - api_key = prompt(" AI Gateway API key", password=True) - if api_key: - save_env_value("AI_GATEWAY_API_KEY", api_key) - print_success("AI Gateway API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6") - _set_model_provider(config, "ai-gateway", pconfig.inference_base_url) - - elif provider_idx == 11: # Alibaba Cloud / DashScope - selected_provider = "alibaba" - print() - print_header("Alibaba Cloud / DashScope API Key") - pconfig = PROVIDER_REGISTRY["alibaba"] - print_info(f"Provider: {pconfig.name}") - print_info("Get your API key at: https://modelstudio.console.alibabacloud.com/") - print() - - existing_key = get_env_value("DASHSCOPE_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - new_key = prompt(" DashScope API key", password=True) - if new_key: - save_env_value("DASHSCOPE_API_KEY", new_key) - print_success("DashScope API key updated") - else: - new_key = prompt(" DashScope API key", password=True) - if new_key: - save_env_value("DASHSCOPE_API_KEY", new_key) - print_success("DashScope API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _update_config_for_provider("alibaba", pconfig.inference_base_url, default_model="qwen3.5-plus") - _set_model_provider(config, "alibaba", pconfig.inference_base_url) - - elif provider_idx == 12: # OpenCode Zen - selected_provider = "opencode-zen" - print() - print_header("OpenCode Zen API Key") - pconfig = PROVIDER_REGISTRY["opencode-zen"] - print_info(f"Provider: {pconfig.name}") - print_info(f"Base URL: {pconfig.inference_base_url}") - print_info("Get your API key at: https://opencode.ai/auth") - print() - - existing_key = get_env_value("OPENCODE_ZEN_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - api_key = prompt(" OpenCode Zen API key", password=True) - if api_key: - save_env_value("OPENCODE_ZEN_API_KEY", api_key) - print_success("OpenCode Zen API key updated") - else: - api_key = prompt(" OpenCode Zen API key", password=True) - if api_key: - save_env_value("OPENCODE_ZEN_API_KEY", api_key) - print_success("OpenCode Zen API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _set_model_provider(config, "opencode-zen", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 13: # OpenCode Go - selected_provider = "opencode-go" - print() - print_header("OpenCode Go API Key") - pconfig = PROVIDER_REGISTRY["opencode-go"] - print_info(f"Provider: {pconfig.name}") - print_info(f"Base URL: {pconfig.inference_base_url}") - print_info("Get your API key at: https://opencode.ai/auth") - print() - - existing_key = get_env_value("OPENCODE_GO_API_KEY") - if existing_key: - print_info(f"Current: {existing_key[:8]}... (configured)") - if prompt_yes_no("Update API key?", False): - api_key = prompt(" OpenCode Go API key", password=True) - if api_key: - save_env_value("OPENCODE_GO_API_KEY", api_key) - print_success("OpenCode Go API key updated") - else: - api_key = prompt(" OpenCode Go API key", password=True) - if api_key: - save_env_value("OPENCODE_GO_API_KEY", api_key) - print_success("OpenCode Go API key saved") - else: - print_warning("Skipped - agent won't work without an API key") - - _set_model_provider(config, "opencode-go", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 14: # GitHub Copilot - selected_provider = "copilot" - print() - print_header("GitHub Copilot") - pconfig = PROVIDER_REGISTRY["copilot"] - print_info("Hermes can use GITHUB_TOKEN, GH_TOKEN, or your gh CLI login.") - print_info(f"Base URL: {pconfig.inference_base_url}") - print() - - copilot_creds = resolve_api_key_provider_credentials("copilot") - source = copilot_creds.get("source", "") - token = copilot_creds.get("api_key", "") - if token: - if source in ("GITHUB_TOKEN", "GH_TOKEN"): - print_info(f"Current: {token[:8]}... ({source})") - elif source == "gh auth token": - print_info("Current: authenticated via `gh auth token`") - else: - print_info("Current: GitHub token configured") - else: - api_key = prompt(" GitHub token", password=True) - if api_key: - save_env_value("GITHUB_TOKEN", api_key) - print_success("GitHub token saved") - else: - print_warning("Skipped - agent won't work without a GitHub token or gh auth login") - - _set_model_provider(config, "copilot", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 15: # GitHub Copilot ACP - selected_provider = "copilot-acp" - print() - print_header("GitHub Copilot ACP") - pconfig = PROVIDER_REGISTRY["copilot-acp"] - print_info("Hermes will start `copilot --acp --stdio` for each request.") - print_info("Use HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH to override the command.") - print_info(f"Base marker: {pconfig.inference_base_url}") - print() - - _set_model_provider(config, "copilot-acp", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - elif provider_idx == 16: # Hugging Face Inference Providers - selected_provider = "huggingface" - print() - print_header("Hugging Face API Token") - pconfig = PROVIDER_REGISTRY["huggingface"] - print_info(f"Provider: {pconfig.name}") - print_info("Get your token at: https://huggingface.co/settings/tokens") - print_info("Required permission: 'Make calls to Inference Providers'") - print() - - api_key = prompt(" HF Token", password=True) - if api_key: - save_env_value("HF_TOKEN", api_key) - _set_model_provider(config, "huggingface", pconfig.inference_base_url) - selected_base_url = pconfig.inference_base_url - - # else: provider_idx == 17 (Keep current) — only shown when a provider already exists - # Normalize "keep current" to an explicit provider so downstream logic - # doesn't fall back to the generic OpenRouter/static-model path. - if selected_provider is None: - if current_config_provider: - selected_provider = current_config_provider - elif active_oauth and active_oauth in PROVIDER_REGISTRY: - selected_provider = active_oauth - elif existing_custom: - selected_provider = "custom" - elif existing_or: - selected_provider = "openrouter" # ── Vision & Image Analysis Setup ── # Keep setup aligned with the actual runtime resolver the vision tools use. @@ -1599,155 +933,6 @@ def setup_model_provider(config: dict): else: print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings") - # ── Model Selection (adapts based on provider) ── - if selected_provider != "custom": # Custom already prompted for model name - print_header("Default Model") - - _raw_model = config.get("model", "anthropic/claude-opus-4.6") - current_model = ( - _raw_model.get("default", "anthropic/claude-opus-4.6") - if isinstance(_raw_model, dict) - else (_raw_model or "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): - _set_default_model(config, nous_models[model_idx]) - elif model_idx == len(model_choices) - 2: # Custom - model_name = prompt(" Model name") - if model_name: - _set_default_model(config, model_name) - # else: keep current - - elif selected_provider == "nous": - # Nous login succeeded but model fetch failed — prompt manually - # instead of falling through to the OpenRouter static list. - print_warning("Could not fetch available models from Nous Portal.") - print_info("Enter a Nous model name manually (e.g., claude-opus-4-6).") - custom = prompt(f" Model name (Enter to keep '{current_model}')") - if custom: - _set_default_model(config, custom) - elif selected_provider == "openai-codex": - from hermes_cli.codex_models import get_codex_model_ids - - codex_token = None - try: - codex_creds = resolve_codex_runtime_credentials() - codex_token = codex_creds.get("api_key") - except Exception as exc: - logger.debug("Could not resolve Codex runtime credentials for model list: %s", exc) - - codex_models = get_codex_model_ids(access_token=codex_token) - - model_choices = codex_models + [f"Keep current ({current_model})"] - default_codex = 0 - if current_model in codex_models: - default_codex = codex_models.index(current_model) - elif current_model: - default_codex = len(model_choices) - 1 - - model_idx = prompt_choice( - "Select default model:", model_choices, default_codex - ) - if model_idx < len(codex_models): - _set_default_model(config, codex_models[model_idx]) - elif model_idx == len(codex_models): - custom = prompt("Enter model name") - if custom: - _set_default_model(config, custom) - _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) - _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) - elif selected_provider == "copilot-acp": - _setup_provider_model_selection( - config, selected_provider, current_model, - prompt_choice, prompt, - ) - model_cfg = _model_config_dict(config) - model_cfg["api_mode"] = "chat_completions" - config["model"] = model_cfg - elif selected_provider in ("copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "ai-gateway", "opencode-zen", "opencode-go", "alibaba"): - _setup_provider_model_selection( - config, selected_provider, current_model, - prompt_choice, prompt, - ) - elif selected_provider == "anthropic": - # Try live model list first, fall back to static - from hermes_cli.models import provider_model_ids - live_models = provider_model_ids("anthropic") - anthropic_models = live_models if live_models else [ - "claude-opus-4-6", - "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", - ] - model_choices = list(anthropic_models) - model_choices.append("Custom model") - model_choices.append(f"Keep current ({current_model})") - - keep_idx = len(model_choices) - 1 - model_idx = prompt_choice("Select default model:", model_choices, keep_idx) - - if model_idx < len(anthropic_models): - _set_default_model(config, anthropic_models[model_idx]) - elif model_idx == len(anthropic_models): - custom = prompt("Enter model name (e.g., claude-sonnet-4-20250514)") - if custom: - _set_default_model(config, custom) - # else: keep current - else: - # Static list for OpenRouter / fallback (from canonical list) - from hermes_cli.models import model_ids, menu_labels - - ids = model_ids() - model_choices = menu_labels() + [ - "Custom model", - f"Keep current ({current_model})", - ] - - keep_idx = len(model_choices) - 1 - model_idx = prompt_choice("Select default model:", model_choices, keep_idx) - - if model_idx < len(ids): - _set_default_model(config, ids[model_idx]) - elif model_idx == len(ids): # Custom - custom = prompt("Enter model name (e.g., anthropic/claude-opus-4.6)") - if custom: - _set_default_model(config, custom) - # else: Keep current - - _final_model = config.get("model", "") - if _final_model: - _display = ( - _final_model.get("default", _final_model) - if isinstance(_final_model, dict) - else _final_model - ) - print_success(f"Model set to: {_display}") - - # Write provider+base_url to config.yaml only after model selection is complete. - # This prevents a race condition where the gateway picks up a new provider - # before the model name has been updated to match. - if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None: - _update_config_for_provider(selected_provider, selected_base_url) save_config(config) diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index c5a19f06f..f4f13696c 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -1,6 +1,8 @@ +"""Tests for setup_model_provider — verifies the delegation to +select_provider_and_model() and config dict sync.""" import json -from hermes_cli.auth import _update_config_for_provider, get_active_provider +from hermes_cli.auth import get_active_provider from hermes_cli.config import load_config, save_config from hermes_cli.setup import setup_model_provider @@ -23,270 +25,198 @@ def _clear_provider_env(monkeypatch): monkeypatch.delenv(key, raising=False) +def _stub_tts(monkeypatch): + """Stub out TTS prompts so setup_model_provider doesn't block.""" + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda q, c, d=0: ( + _maybe_keep_current_tts(q, c) if _maybe_keep_current_tts(q, c) is not None + else d + )) + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False) -def test_nous_oauth_setup_keeps_current_model_when_syncing_disk_provider( - tmp_path, monkeypatch -): + +def _write_model_config(tmp_path, provider, base_url="", model_name="test-model"): + """Simulate what a _model_flow_* function writes to disk.""" + cfg = load_config() + m = cfg.get("model") + if not isinstance(m, dict): + m = {"default": m} if m else {} + cfg["model"] = m + m["provider"] = provider + if base_url: + m["base_url"] = base_url + if model_name: + m["default"] = model_name + save_config(cfg) + + +def test_setup_delegates_to_select_provider_and_model(tmp_path, monkeypatch): + """setup_model_provider calls select_provider_and_model and syncs config.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) config = load_config() - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - return 1 # Nous Portal - if question == "Configure vision:": - return len(choices) - 1 - if question == "Select default model:": - assert choices[-1] == "Keep current (anthropic/claude-opus-4.6)" - return len(choices) - 1 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + _write_model_config(tmp_path, "custom", "http://localhost:11434/v1", "qwen3.5:32b") - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - - def _fake_login_nous(*args, **kwargs): - auth_path = tmp_path / "auth.json" - auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}})) - _update_config_for_provider("nous", "https://inference.example.com/v1") - - monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous) - monkeypatch.setattr( - "hermes_cli.auth.resolve_nous_runtime_credentials", - lambda *args, **kwargs: { - "base_url": "https://inference.example.com/v1", - "api_key": "nous-key", - }, - ) - monkeypatch.setattr( - "hermes_cli.auth.fetch_nous_models", - lambda *args, **kwargs: ["gemini-3-flash"], - ) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) save_config(config) reloaded = load_config() + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "custom" + assert reloaded["model"]["base_url"] == "http://localhost:11434/v1" + assert reloaded["model"]["default"] == "qwen3.5:32b" + +def test_setup_syncs_openrouter_from_disk(tmp_path, monkeypatch): + """When select_provider_and_model saves OpenRouter config to disk, + the wizard's config dict picks it up.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) + + config = load_config() + assert isinstance(config.get("model"), str) # fresh install + + def fake_select(): + _write_model_config(tmp_path, "openrouter", model_name="anthropic/claude-opus-4.6") + + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "openrouter" + + +def test_setup_syncs_nous_from_disk(tmp_path, monkeypatch): + """Nous OAuth writes config to disk; wizard config dict must pick it up.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) + + config = load_config() + + def fake_select(): + _write_model_config(tmp_path, "nous", "https://inference.example.com/v1", "gemini-3-flash") + + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "nous" assert reloaded["model"]["base_url"] == "https://inference.example.com/v1" - assert reloaded["model"]["default"] == "anthropic/claude-opus-4.6" -def test_custom_setup_clears_active_oauth_provider(tmp_path, monkeypatch): +def test_setup_custom_providers_synced(tmp_path, monkeypatch): + """custom_providers written by select_provider_and_model must survive.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) - - auth_path = tmp_path / "auth.json" - auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}})) + _stub_tts(monkeypatch) config = load_config() - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - return 3 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + _write_model_config(tmp_path, "custom", "http://localhost:8080/v1", "llama3") + cfg = load_config() + cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}] + save_config(cfg) - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - - # _model_flow_custom uses builtins.input (URL, key, model, context_length) - input_values = iter([ - "https://custom.example/v1", - "custom-api-key", - "custom/model", - "", # context_length (blank = auto-detect) - ]) - monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values)) - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None) - monkeypatch.setattr( - "hermes_cli.models.probe_api_models", - lambda api_key, base_url: {"models": ["m"], "probed_url": base_url + "/models"}, - ) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) - - # Core assertion: switching to custom endpoint clears OAuth provider - assert get_active_provider() is None - - # Simulate what the real setup wizard does: save_config(config) AFTER - # setup_model_provider returns. This is the step that previously - # overwrote model.provider/base_url (#4172). save_config(config) reloaded = load_config() - assert isinstance(reloaded.get("model"), dict), ( - "model should be a dict after custom setup, not " - + repr(type(reloaded.get("model"))) - ) - assert reloaded["model"].get("provider") == "custom" - assert reloaded["model"].get("default") == "custom/model" - assert "custom.example" in reloaded["model"].get("base_url", "") + assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}] -def test_custom_setup_preserves_provider_after_wizard_save_config( - tmp_path, monkeypatch -): - """Regression test for #4172: the setup wizard's final save_config(config) - must not overwrite model.provider/base_url that _model_flow_custom set. - - Simulates the full flow: - 1. load config (fresh install — model is a string) - 2. setup_model_provider picks custom - 3. wizard calls save_config(config) afterward - 4. verify resolve_requested_provider returns "custom" - """ +def test_setup_cancel_preserves_existing_config(tmp_path, monkeypatch): + """When the user cancels provider selection, existing config is preserved.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) + + # Pre-set a provider + _write_model_config(tmp_path, "openrouter", model_name="gpt-4o") config = load_config() - # Sanity: fresh install has model as a string - assert isinstance(config.get("model"), str) or config.get("model") is None + assert config["model"]["provider"] == "openrouter" - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - return 3 # Custom endpoint - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + pass # user cancelled — nothing written to disk - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - - input_values = iter([ - "http://localhost:11434/v1", # Ollama URL - "", # no API key (local) - "qwen3.5:32b", # model name - "", # context length (auto-detect) - ]) - monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values)) - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **kw: None) - monkeypatch.setattr( - "hermes_cli.models.probe_api_models", - lambda api_key, base_url: {"models": ["qwen3.5:32b"], "probed_url": base_url + "/models"}, - ) - - # Full wizard cycle - setup_model_provider(config) - save_config(config) # ← this is what the real wizard does - - # Verify config on disk - reloaded = load_config() - assert isinstance(reloaded["model"], dict) - assert reloaded["model"]["provider"] == "custom" - assert reloaded["model"]["base_url"] == "http://localhost:11434/v1" - assert reloaded["model"]["default"] == "qwen3.5:32b" - assert "api_mode" not in reloaded["model"] - - # Verify the runtime resolver sees "custom", not "auto" - from hermes_cli.runtime_provider import resolve_requested_provider - assert resolve_requested_provider() == "custom" - - -def test_custom_setup_no_model_name_still_preserves_endpoint( - tmp_path, monkeypatch -): - """When the user enters a URL and key but skips the model name, - model.provider and model.base_url must still survive the wizard's - final save_config(config).""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - _clear_provider_env(monkeypatch) - - config = load_config() - - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - return 3 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") - - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - - input_values = iter([ - "http://192.168.1.50:8080/v1", # URL - "my-key", # API key - "", # no model name - "", # context length - ]) - monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values)) - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **kw: None) - monkeypatch.setattr( - "hermes_cli.models.probe_api_models", - lambda api_key, base_url: {"models": None, "probed_url": base_url + "/models"}, - ) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) save_config(config) reloaded = load_config() assert isinstance(reloaded["model"], dict) - assert reloaded["model"]["provider"] == "custom" - assert reloaded["model"]["base_url"] == "http://192.168.1.50:8080/v1" + assert reloaded["model"]["provider"] == "openrouter" + assert reloaded["model"]["default"] == "gpt-4o" + + +def test_setup_exception_in_select_gracefully_handled(tmp_path, monkeypatch): + """If select_provider_and_model raises, setup continues with existing config.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) + + config = load_config() + + def fake_select(): + raise RuntimeError("something broke") + + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) + + # Should not raise + setup_model_provider(config) + + +def test_setup_keyboard_interrupt_gracefully_handled(tmp_path, monkeypatch): + """KeyboardInterrupt during provider selection is handled.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) + + config = load_config() + + def fake_select(): + raise KeyboardInterrupt() + + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) + + setup_model_provider(config) def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch): + """Codex model list fetching uses the runtime access token.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key") _clear_provider_env(monkeypatch) monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key") config = load_config() + _stub_tts(monkeypatch) - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - return 2 # OpenAI Codex - if question == "Select default model:": - return 0 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + _write_model_config(tmp_path, "openai-codex", "https://api.openai.com/v1", "gpt-4o") - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None) - monkeypatch.setattr( - "hermes_cli.auth.resolve_codex_runtime_credentials", - lambda *args, **kwargs: { - "base_url": "https://chatgpt.com/backend-api/codex", - "api_key": "codex-access-token", - }, - ) - - captured = {} - - def _fake_get_codex_model_ids(access_token=None): - captured["access_token"] = access_token - return ["gpt-5.2-codex", "gpt-5.2"] - - monkeypatch.setattr( - "hermes_cli.codex_models.get_codex_model_ids", - _fake_get_codex_model_ids, - ) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) save_config(config) reloaded = load_config() - - assert captured["access_token"] == "codex-access-token" assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "openai-codex" - assert reloaded["model"]["default"] == "gpt-5.2-codex" - assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex" diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index 76ba94374..09116bc95 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -1,9 +1,14 @@ -"""Regression tests for interactive setup provider/model persistence.""" +"""Regression tests for interactive setup provider/model persistence. + +Since setup_model_provider delegates to select_provider_and_model() +from hermes_cli.main, these tests mock the delegation point and verify +that the setup wizard correctly syncs config from disk after the call. +""" from __future__ import annotations from hermes_cli.config import load_config, save_config, save_env_value -from hermes_cli.setup import _print_setup_summary, setup_model_provider +from hermes_cli.setup import setup_model_provider def _maybe_keep_current_tts(question, choices): @@ -13,19 +18,6 @@ def _maybe_keep_current_tts(question, choices): return len(choices) - 1 -def _read_env(home): - env_path = home / ".env" - data = {} - if not env_path.exists(): - return data - for line in env_path.read_text().splitlines(): - if not line or line.startswith("#") or "=" not in line: - continue - k, v = line.split("=", 1) - data[k] = v - return data - - def _clear_provider_env(monkeypatch): for key in ( "HERMES_INFERENCE_PROVIDER", @@ -44,429 +36,173 @@ def _clear_provider_env(monkeypatch): monkeypatch.delenv(key, raising=False) +def _stub_tts(monkeypatch): + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda q, c, d=0: ( + _maybe_keep_current_tts(q, c) if _maybe_keep_current_tts(q, c) is not None + else d + )) + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False) + + +def _write_model_config(provider, base_url="", model_name="test-model"): + """Simulate what a _model_flow_* function writes to disk.""" + cfg = load_config() + m = cfg.get("model") + if not isinstance(m, dict): + m = {"default": m} if m else {} + cfg["model"] = m + m["provider"] = provider + if base_url: + m["base_url"] = base_url + else: + m.pop("base_url", None) + if model_name: + m["default"] = model_name + m.pop("api_mode", None) + save_config(cfg) + + def test_setup_keep_current_custom_from_config_does_not_fall_through(tmp_path, monkeypatch): """Keep-current custom should not fall through to the generic model menu.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) - save_env_value("OPENAI_BASE_URL", "https://example.invalid/v1") - save_env_value("OPENAI_API_KEY", "custom-key") + _stub_tts(monkeypatch) + + # Pre-set custom provider + _write_model_config("custom", "http://localhost:8080/v1", "local-model") config = load_config() - config["model"] = { - "default": "custom/model", - "provider": "custom", - "base_url": "https://example.invalid/v1", - } - save_config(config) + assert config["model"]["provider"] == "custom" - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - assert choices[-1] == "Keep current (Custom: https://example.invalid/v1)" - return len(choices) - 1 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError("Model menu should not appear for keep-current custom") + def fake_select(): + pass # user chose "cancel" or "keep current" - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) save_config(config) reloaded = load_config() + assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "custom" - assert reloaded["model"]["default"] == "custom/model" - assert reloaded["model"]["base_url"] == "https://example.invalid/v1" + assert reloaded["model"]["base_url"] == "http://localhost:8080/v1" -def test_setup_custom_endpoint_saves_working_v1_base_url(tmp_path, monkeypatch): +def test_setup_keep_current_config_provider_uses_provider_specific_model_menu( + tmp_path, monkeypatch +): + """Keeping current provider preserves the config on disk.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) + + _write_model_config("zai", "https://open.bigmodel.cn/api/paas/v4", "glm-5") config = load_config() - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - return 3 # Custom endpoint - if question == "Configure vision:": - return len(choices) - 1 # Skip - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + pass # keep current - # _model_flow_custom uses builtins.input (URL, key, model, context_length) - input_values = iter([ - "http://localhost:8000", - "local-key", - "llm", - "", # context_length (blank = auto-detect) - ]) - monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values)) - - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) - monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None) - monkeypatch.setattr( - "hermes_cli.models.probe_api_models", - lambda api_key, base_url: { - "models": ["llm"], - "probed_url": "http://localhost:8000/v1/models", - "resolved_base_url": "http://localhost:8000/v1", - "suggested_base_url": "http://localhost:8000/v1", - "used_fallback": True, - }, - ) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) + save_config(config) - env = _read_env(tmp_path) - - # _model_flow_custom saves config to disk (base_url in config, not .env) reloaded = load_config() - model_cfg = reloaded.get("model", {}) - if isinstance(model_cfg, dict): - assert model_cfg.get("provider") == "custom" - assert model_cfg.get("default") == "llm" - assert model_cfg.get("base_url") == "http://localhost:8000/v1" - - -def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch): - """Keep-current should respect config-backed providers, not fall back to OpenRouter.""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - _clear_provider_env(monkeypatch) - - config = load_config() - config["model"] = { - "default": "claude-opus-4-6", - "provider": "anthropic", - } - save_config(config) - - captured = {"provider_choices": None, "model_choices": None} - - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - captured["provider_choices"] = list(choices) - assert choices[-1] == "Keep current (Anthropic)" - return len(choices) - 1 - if question == "Configure vision:": - assert question == "Configure vision:" - assert choices[-1] == "Skip for now" - return len(choices) - 1 - if question == "Select default model:": - captured["model_choices"] = list(choices) - return len(choices) - 1 # keep current model - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") - - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) - monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) - - setup_model_provider(config) - save_config(config) - - assert captured["provider_choices"] is not None - assert captured["model_choices"] is not None - assert captured["model_choices"][0] == "claude-opus-4-6" - assert "anthropic/claude-opus-4.6 (recommended)" not in captured["model_choices"] - - -def test_setup_keep_current_anthropic_can_configure_openai_vision_default(tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - _clear_provider_env(monkeypatch) - - config = load_config() - config["model"] = { - "default": "claude-opus-4-6", - "provider": "anthropic", - } - save_config(config) - - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - assert choices[-1] == "Keep current (Anthropic)" - return len(choices) - 1 - if question == "Configure vision:": - return 1 - if question == "Select vision model:": - assert choices[-1] == "Use default (gpt-4o-mini)" - return len(choices) - 1 - if question == "Select default model:": - assert choices[-1] == "Keep current (claude-opus-4-6)" - return len(choices) - 1 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") - - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr( - "hermes_cli.setup.prompt", - lambda message, *args, **kwargs: "sk-openai" if "OpenAI API key" in message else "", - ) - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) - monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) - - setup_model_provider(config) - env = _read_env(tmp_path) - - assert env.get("OPENAI_API_KEY") == "sk-openai" - assert env.get("AUXILIARY_VISION_MODEL") == "gpt-4o-mini" - # Vision base URL saved to config.yaml, not .env - reloaded = load_config() - vision_cfg = reloaded.get("auxiliary", {}).get("vision", {}) - assert vision_cfg.get("base_url") == "https://api.openai.com/v1" + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "zai" def test_setup_copilot_uses_gh_auth_and_saves_provider(tmp_path, monkeypatch): + """Copilot provider saves correctly through delegation.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) config = load_config() - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - assert choices[14] == "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)" - return 14 - if question == "Select default model:": - assert "gpt-4.1" in choices - assert "gpt-5.4" in choices - return choices.index("gpt-5.4") - if question == "Select reasoning effort:": - assert "low" in choices - assert "high" in choices - return choices.index("high") - if question == "Configure vision:": - return len(choices) - 1 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + _write_model_config("copilot", "https://models.github.ai/inference/v1", "gpt-4o") - def fake_prompt(message, *args, **kwargs): - raise AssertionError(f"Unexpected prompt call: {message}") - - def fake_get_auth_status(provider_id): - if provider_id == "copilot": - return {"logged_in": True} - return {"logged_in": False} - - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt) - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.auth.get_auth_status", fake_get_auth_status) - monkeypatch.setattr( - "hermes_cli.auth.resolve_api_key_provider_credentials", - lambda provider_id: { - "provider": provider_id, - "api_key": "gh-cli-token", - "base_url": "https://api.githubcopilot.com", - "source": "gh auth token", - }, - ) - monkeypatch.setattr( - "hermes_cli.models.fetch_github_model_catalog", - lambda api_key: [ - { - "id": "gpt-4.1", - "capabilities": {"type": "chat", "supports": {}}, - "supported_endpoints": ["/chat/completions"], - }, - { - "id": "gpt-5.4", - "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}, - "supported_endpoints": ["/responses"], - }, - ], - ) - monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) save_config(config) - env = _read_env(tmp_path) reloaded = load_config() - - assert env.get("GITHUB_TOKEN") is None + assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "copilot" - assert reloaded["model"]["base_url"] == "https://api.githubcopilot.com" - assert reloaded["model"]["default"] == "gpt-5.4" - assert reloaded["model"]["api_mode"] == "codex_responses" - assert reloaded["agent"]["reasoning_effort"] == "high" def test_setup_copilot_acp_uses_model_picker_and_saves_provider(tmp_path, monkeypatch): + """Copilot ACP provider saves correctly through delegation.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) config = load_config() - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - assert choices[15] == "GitHub Copilot ACP (spawns `copilot --acp --stdio`)" - return 15 - if question == "Select default model:": - assert "gpt-4.1" in choices - assert "gpt-5.4" in choices - return choices.index("gpt-5.4") - if question == "Configure vision:": - return len(choices) - 1 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + _write_model_config("copilot-acp", "", "claude-sonnet-4") - def fake_prompt(message, *args, **kwargs): - raise AssertionError(f"Unexpected prompt call: {message}") - - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt) - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda provider_id: {"logged_in": provider_id == "copilot-acp"}) - monkeypatch.setattr( - "hermes_cli.auth.resolve_api_key_provider_credentials", - lambda provider_id: { - "provider": "copilot", - "api_key": "gh-cli-token", - "base_url": "https://api.githubcopilot.com", - "source": "gh auth token", - }, - ) - monkeypatch.setattr( - "hermes_cli.models.fetch_github_model_catalog", - lambda api_key: [ - { - "id": "gpt-4.1", - "capabilities": {"type": "chat", "supports": {}}, - "supported_endpoints": ["/chat/completions"], - }, - { - "id": "gpt-5.4", - "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}, - "supported_endpoints": ["/responses"], - }, - ], - ) - monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) save_config(config) reloaded = load_config() - + assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "copilot-acp" - assert reloaded["model"]["base_url"] == "acp://copilot" - assert reloaded["model"]["default"] == "gpt-5.4" - assert reloaded["model"]["api_mode"] == "chat_completions" -def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(tmp_path, monkeypatch): - """Switching from custom to Codex should clear custom endpoint overrides.""" +def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config( + tmp_path, monkeypatch +): + """Switching from custom to codex updates config correctly.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) - save_env_value("OPENAI_BASE_URL", "https://example.invalid/v1") - save_env_value("OPENAI_API_KEY", "sk-custom") - save_env_value("OPENROUTER_API_KEY", "sk-or") + # Start with custom + _write_model_config("custom", "http://localhost:11434/v1", "qwen3.5:32b") config = load_config() - config["model"] = { - "default": "custom/model", - "provider": "custom", - "base_url": "https://example.invalid/v1", - } - save_config(config) + assert config["model"]["provider"] == "custom" - def fake_prompt_choice(question, choices, default=0): - if question == "Select your inference provider:": - return 2 # OpenAI Codex - if question == "Select default model:": - return 0 - tts_idx = _maybe_keep_current_tts(question, choices) - if tts_idx is not None: - return tts_idx - raise AssertionError(f"Unexpected prompt_choice call: {question}") + def fake_select(): + _write_model_config("openai-codex", "https://api.openai.com/v1", "gpt-4o") - monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) - monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") - monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) - monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) - monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) - monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None) - monkeypatch.setattr( - "hermes_cli.auth.resolve_codex_runtime_credentials", - lambda *args, **kwargs: { - "base_url": "https://chatgpt.com/backend-api/codex", - "api_key": "codex-...oken", - }, - ) - monkeypatch.setattr( - "hermes_cli.codex_models.get_codex_model_ids", - lambda **kwargs: ["openai/gpt-5.3-codex", "openai/gpt-5-codex-mini"], - ) + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) setup_model_provider(config) save_config(config) - env = _read_env(tmp_path) reloaded = load_config() - - # OPENAI_BASE_URL is no longer written/cleared in .env — config is authoritative + assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "openai-codex" - assert reloaded["model"]["default"] == "openai/gpt-5.3-codex" - assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex" + assert reloaded["model"]["default"] == "gpt-4o" -def test_setup_summary_marks_codex_auth_as_vision_available(tmp_path, monkeypatch, capsys): +def test_setup_switch_preserves_non_model_config(tmp_path, monkeypatch): + """Provider switch preserves other config sections (terminal, display, etc.).""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) + _stub_tts(monkeypatch) - (tmp_path / "auth.json").write_text( - '{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token": "***", "refresh_token": "***"}}}}' - ) + config = load_config() + config["terminal"]["timeout"] = 999 + save_config(config) - monkeypatch.setattr("shutil.which", lambda _name: None) + config = load_config() - _print_setup_summary(load_config(), tmp_path) - output = capsys.readouterr().out + def fake_select(): + _write_model_config("openrouter", model_name="gpt-4o") - assert "Vision (image analysis)" in output - assert "missing run 'hermes setup' to configure" not in output - assert "Mixture of Agents" in output - assert "missing OPENROUTER_API_KEY" in output + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select) + setup_model_provider(config) + save_config(config) -def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - _clear_provider_env(monkeypatch) - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") - monkeypatch.setattr("shutil.which", lambda _name: None) - monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: ["anthropic"]) - - _print_setup_summary(load_config(), tmp_path) - output = capsys.readouterr().out - - assert "Vision (image analysis)" in output - assert "missing run 'hermes setup' to configure" not in output + reloaded = load_config() + assert reloaded["terminal"]["timeout"] == 999 + assert reloaded["model"]["provider"] == "openrouter"