"""Provider setup wizard helpers: provider picker, custom-provider save/remove, auxiliary-model routing menu, API-key/reasoning prompts, Anthropic OAuth. Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so ``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. Names that stay in main are imported lazily inside the functions that use them (call-time resolution keeps ``hermes_cli.main.`` patches effective and avoids an import cycle). """ import subprocess from typing import Optional from hermes_cli.cli_output import line_input def _is_profile_api_key_provider(provider_id: str) -> bool: """Return True when provider_id maps to a profile with auth_type='api_key'. Used as a catch-all in select_provider_and_model() so that new providers declared in plugins/model-providers// automatically dispatch to _model_flow_api_key_provider without requiring an explicit elif branch here. """ try: from providers import get_provider_profile _p = get_provider_profile(provider_id) return _p is not None and _p.auth_type == "api_key" except Exception: return False _GENERIC_API_KEY_PROVIDERS = frozenset({ "openai-api", "gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "opencode-free", "alibaba", "huggingface", "xiaomi", "arcee", "gmi", "nvidia", "ollama-cloud", "tencent-tokenhub", "tencent-tokenplan", "lmstudio", }) def _clear_stale_openai_base_url(): """Remove OPENAI_BASE_URL from ~/.hermes/.env if the active provider is not 'custom'. After a provider switch, a leftover OPENAI_BASE_URL causes auxiliary clients (compression, vision, delegation) with provider:auto to route requests to the old custom endpoint instead of the newly selected provider. See issue #5161. """ from hermes_cli.config import get_env_value, save_env_value, load_config cfg = load_config() model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): provider = (model_cfg.get("provider") or "").strip().lower() else: provider = "" if provider == "custom" or not provider: return # custom provider legitimately uses OPENAI_BASE_URL stale_url = get_env_value("OPENAI_BASE_URL") if stale_url: save_env_value("OPENAI_BASE_URL", "") print( f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)" if len(stale_url) > 40 else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})" ) # (task_key, display_name, short_description) _AUX_TASKS: list[tuple[str, str, str]] = [ ("vision", "Vision", "image/screenshot analysis"), ("compression", "Compression", "context summarization"), ("approval", "Approval", "smart command approval"), ("mcp", "MCP", "MCP tool reasoning"), ("title_generation", "Title generation", "session titles"), ("review", "Review", "/review reviewer subagent"), ("memory_query_rewrite", "Memory query rewrite", "memory retrieval queries"), ("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"), ("skills_hub", "Skills hub", "skills search/install"), ("triage_specifier", "Triage specifier", "kanban spec fleshing"), ("kanban_decomposer", "Kanban decomposer", "task decomposition"), ("profile_describer", "Profile describer", "auto profile descriptions"), ("curator", "Curator", "skill-usage review pass"), ] # Special non-auxiliary task surfaced in the same picker: subagent delegation. # Routing lives under top-level `delegation.*` in config.yaml (NOT # `auxiliary.delegation`) because delegate_task spawns full child agents via # tools/delegate_tool.py::_resolve_delegation_credentials(), which reads the # delegation section directly. "auto" here means "inherit the parent agent's # provider/model/credentials" and is stored as empty strings — never persist # the literal "auto", or it would be resolved as a provider name. _DELEGATION_TASK_KEY = "delegation" _DELEGATION_TASK_NAME = "Delegation" _DELEGATION_TASK_DESC = "subagent model (delegate_task)" def _all_aux_tasks() -> list[tuple[str, str, str]]: """Return built-in + plugin-registered auxiliary tasks for picker/menu use. Built-in tasks come first (preserving order), followed by plugin tasks sorted by key. Used by ``_aux_config_menu``, ``_reset_aux_to_auto``, and display-name lookups so plugin-registered tasks (registered via :meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`) appear in the same surfaces as built-in ones without core knowing about them. """ from hermes_cli.main import _AUX_TASKS tasks = list(_AUX_TASKS) try: from hermes_cli.plugins import get_plugin_auxiliary_tasks for entry in get_plugin_auxiliary_tasks(): tasks.append((entry["key"], entry["display_name"], entry["description"])) except Exception: # Plugin discovery failure must not break the aux config UI. # Built-in tasks remain available. pass return tasks def _format_aux_current(task_cfg: dict) -> str: """Render the current aux config for display in the task menu.""" if not isinstance(task_cfg, dict): return "auto" base_url = str(task_cfg.get("base_url") or "").strip() provider = str(task_cfg.get("provider") or "auto").strip() or "auto" model = str(task_cfg.get("model") or "").strip() if base_url: short = base_url.replace("https://", "").replace("http://", "").rstrip("/") return f"custom ({short})" + (f" · {model}" if model else "") if provider == "auto": return "auto" + (f" · {model}" if model else "") if model: return f"{provider} · {model}" return provider def _delegation_cfg_as_task(cfg: dict) -> dict: """Project the top-level ``delegation`` section into aux-task shape. Returns a dict with provider/model/base_url/api_key keys so the shared rendering (``_format_aux_current``) and picker code can treat delegation like any other task. Empty provider means "inherit parent" which renders as "auto". """ d = cfg.get("delegation") if not isinstance(d, dict): d = {} return { "provider": str(d.get("provider") or "").strip(), "model": str(d.get("model") or "").strip(), "base_url": str(d.get("base_url") or "").strip(), "api_key": str(d.get("api_key") or "").strip(), } def _aux_task_display_name(task: str) -> str: """Display name for a task key, covering the special delegation entry.""" if task == _DELEGATION_TASK_KEY: return _DELEGATION_TASK_NAME return next((name for key, name, _ in _all_aux_tasks() if key == task), task) def _save_aux_choice( task: str, *, provider: str, model: str = "", base_url: str = "", api_key: str = "", ) -> None: """Persist an auxiliary task's provider/model to config.yaml. Only writes the four routing fields — timeout, download_timeout, and any other task-specific settings are preserved untouched. The main model config (``model.default``/``model.provider``) is never modified. The special ``delegation`` task writes to the top-level ``delegation`` section (consumed by ``tools/delegate_tool.py``), not ``auxiliary.*``. There, "auto" (inherit the parent agent) is stored as an empty provider — the literal string "auto" would be resolved as a provider name. """ from hermes_cli.config import load_config, save_config cfg = load_config() if task == _DELEGATION_TASK_KEY: entry = cfg.setdefault("delegation", {}) if not isinstance(entry, dict): entry = {} cfg["delegation"] = entry entry["provider"] = "" if provider == "auto" else provider entry["model"] = model or "" entry["base_url"] = base_url or "" entry["api_key"] = api_key or "" save_config(cfg) return aux = cfg.setdefault("auxiliary", {}) if not isinstance(aux, dict): aux = {} cfg["auxiliary"] = aux entry = aux.setdefault(task, {}) if not isinstance(entry, dict): entry = {} aux[task] = entry entry["provider"] = provider entry["model"] = model or "" entry["base_url"] = base_url or "" entry["api_key"] = api_key or "" save_config(cfg) def _reset_aux_to_auto() -> int: """Reset every known aux task back to auto/empty. Returns number reset. Includes plugin-registered tasks (via ``_all_aux_tasks``) so a plugin that contributed an auxiliary task gets reset alongside built-ins. """ from hermes_cli.config import load_config, save_config cfg = load_config() aux = cfg.setdefault("auxiliary", {}) if not isinstance(aux, dict): aux = {} cfg["auxiliary"] = aux count = 0 for task, _name, _desc in _all_aux_tasks(): entry = aux.setdefault(task, {}) if not isinstance(entry, dict): entry = {} aux[task] = entry changed = False if entry.get("provider") not in {None, "", "auto"}: entry["provider"] = "auto" changed = True for field in ("model", "base_url", "api_key"): if entry.get(field): entry[field] = "" changed = True # Preserve timeout/download_timeout — those are user-tuned, not routing if changed: count += 1 # Delegation (top-level section) — clear only the routing fields; other # delegation settings (max_concurrent_children, max_spawn_depth, etc.) # are not routing and must be preserved. dele = cfg.get("delegation") if isinstance(dele, dict): changed = False for field in ("provider", "model", "base_url", "api_key"): if dele.get(field): dele[field] = "" changed = True if changed: count += 1 save_config(cfg) return count def _aux_config_menu() -> None: """Top-level auxiliary-model picker — choose a task to configure. Loops until the user picks "Back" so multiple tasks can be configured without returning to the main provider menu. """ from hermes_cli.main import _aux_select_for_task, _prompt_provider_choice from hermes_cli.config import load_config while True: cfg = load_config() aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} print() print(" Auxiliary models — side-task routing") print() print(" Side tasks (vision, compression, web extraction, etc.) default") print(' to your main chat model. "auto" means "use my main model" —') print(" Hermes only falls back to a lightweight backend (OpenRouter,") print(" Nous Portal) if the main model is unavailable. Override a") print(" task below if you want it pinned to a specific provider/model.") print() # Build the task menu with current settings inline all_tasks = _all_aux_tasks() menu_tasks = all_tasks + [ (_DELEGATION_TASK_KEY, _DELEGATION_TASK_NAME, _DELEGATION_TASK_DESC) ] name_col = max(len(name) for _, name, _ in menu_tasks) + 2 desc_col = max(len(desc) for _, _, desc in menu_tasks) + 4 entries: list[tuple[str, str]] = [] for task_key, name, desc in menu_tasks: if task_key == _DELEGATION_TASK_KEY: task_cfg = _delegation_cfg_as_task(cfg) else: task_cfg = ( aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {} ) current = _format_aux_current(task_cfg) label = ( f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}" ) entries.append((task_key, label)) entries.append(("__reset__", "Reset all to auto")) entries.append(("__back__", "Back")) idx = _prompt_provider_choice( [label for _, label in entries], default=0, ) if idx is None: return key = entries[idx][0] if key == "__back__": return if key == "__reset__": n = _reset_aux_to_auto() if n: print(f"Reset {n} auxiliary task(s) to auto.") else: print("All auxiliary tasks were already set to auto.") print() continue # Otherwise configure the specific task _aux_select_for_task(key) def _aux_select_for_task(task: str) -> None: """Pick a provider + model for a single auxiliary task and persist it. Provider rows come from ``build_aux_picker_rows()`` — the shared aux-picker substrate — so this surface shows exactly what every other aux picker shows: authenticated built-ins, the user's own ``providers:`` / ``custom_providers:`` endpoints, and providers whose credential pool is temporarily exhausted. Only already-configured providers appear; users set up new ones through the normal ``hermes model`` flow, then route aux tasks to them here. """ from hermes_cli.main import _prompt_provider_choice from hermes_cli.config import load_config from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries cfg = load_config() if task == _DELEGATION_TASK_KEY: task_cfg = _delegation_cfg_as_task(cfg) else: aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} task_cfg = aux.get(task, {}) if isinstance(aux.get(task), dict) else {} current_provider = str(task_cfg.get("provider") or "auto").strip() or "auto" current_model = str(task_cfg.get("model") or "").strip() current_base_url = str(task_cfg.get("base_url") or "").strip() display_name = _aux_task_display_name(task) # Gather authenticated providers (has credentials + curated model list) try: providers = build_aux_picker_rows( current_provider=current_provider, current_model=current_model, current_base_url=current_base_url, ) except Exception as exc: print(f"Could not detect authenticated providers: {exc}") providers = [] entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models) # "auto" always first auto_marker = ( " ← current" if current_provider == "auto" and not current_base_url else "" ) auto_label = ( "auto (inherit main agent)" if task == _DELEGATION_TASK_KEY else "auto (recommended)" ) entries.append(("__auto__", f"{auto_label}{auto_marker}", [])) entries.extend( format_aux_picker_entries( providers, current_provider=current_provider, current_base_url=current_base_url, ) ) # Custom endpoint (raw base_url) custom_marker = " ← current" if current_base_url else "" entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", [])) entries.append(("__back__", "Back", [])) print() print(f" Configure {display_name} — current: {_format_aux_current(task_cfg)}") print() idx = _prompt_provider_choice([label for _, label, _ in entries], default=0) if idx is None: return slug, _label, models = entries[idx] if slug == "__back__": return if slug == "__auto__": _save_aux_choice(task, provider="auto", model="", base_url="", api_key="") print(f"{display_name}: reset to auto.") return if slug == "__custom__": _aux_flow_custom_endpoint(task, task_cfg) return # Regular provider — pick a model from its curated list _aux_flow_provider_model(task, slug, models, current_model) def _aux_flow_provider_model( task: str, provider_slug: str, curated_models: list, current_model: str = "", ) -> None: """Prompt for a model under an already-authenticated provider, save to aux.""" from hermes_cli.auth import _prompt_model_selection from hermes_cli.models import get_pricing_for_provider display_name = _aux_task_display_name(task) # Fetch live pricing for this provider (non-blocking) pricing: dict = {} try: pricing = get_pricing_for_provider(provider_slug) or {} except Exception: pricing = {} model_list = list(curated_models) # Let the user pick a model. _prompt_model_selection supports "Enter custom # model name" and cancel. When there's no curated list (rare), fall back # to a raw input prompt. if not model_list: print(f"No curated model list for {provider_slug}.") print("Enter a model slug manually (blank = use provider default):") try: val = line_input("Model: ").strip() except (KeyboardInterrupt, EOFError): print() return selected = val or "" else: selected = _prompt_model_selection( model_list, current_model=current_model, pricing=pricing, confirm_provider=provider_slug, ) if selected is None: print("No change.") return _save_aux_choice( task, provider=provider_slug, model=selected or "", base_url="", api_key="" ) if selected: print(f"{display_name}: {provider_slug} · {selected}") else: print(f"{display_name}: {provider_slug} (provider default model)") def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: """Prompt for a direct OpenAI-compatible base_url + optional api_key/model.""" from hermes_cli.secret_prompt import masked_secret_prompt display_name = _aux_task_display_name(task) current_base_url = str(task_cfg.get("base_url") or "").strip() current_model = str(task_cfg.get("model") or "").strip() print() print(f" Custom endpoint for {display_name}") print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)") print() try: url_prompt = ( f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: " ) url = line_input(url_prompt).strip() except (KeyboardInterrupt, EOFError): print() return url = url or current_base_url if not url: print("No URL provided. No change.") return try: model_prompt = ( f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): " ) model = line_input(model_prompt).strip() except (KeyboardInterrupt, EOFError): print() return model = model or current_model try: api_key = masked_secret_prompt( "API key (optional, blank = use OPENAI_API_KEY): " ).strip() except (KeyboardInterrupt, EOFError): print() return _save_aux_choice( task, provider="custom", model=model, base_url=url, api_key=api_key, ) short_url = url.replace("https://", "").replace("http://", "").rstrip("/") print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else "")) def _prompt_provider_choice(choices, *, default=0, title="Select provider:"): """Show provider selection menu with curses arrow-key navigation. Falls back to a numbered list when curses is unavailable (e.g. piped stdin, non-TTY environments). Returns the selected index, or None if the user cancels. """ try: from hermes_cli.setup import _curses_prompt_choice idx = _curses_prompt_choice(title, choices, default) if idx >= 0: print() return idx except Exception: pass # Fallback: numbered list print(title) for i, c in enumerate(choices, 1): marker = "→" if i - 1 == default else " " print(f" {marker} {i}. {c}") print() while True: try: val = input(f"Choice [1-{len(choices)}] ({default + 1}): ").strip() if not val: return default idx = int(val) - 1 if 0 <= idx < len(choices): return idx print(f"Please enter 1-{len(choices)}") except ValueError: print("Please enter a number") except (KeyboardInterrupt, EOFError): print() return None _DEFAULT_QWEN_PORTAL_MODELS = [ "qwen3-coder-plus", "qwen3-coder", ] def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]: """Prompt for a custom provider API mode. Returns an explicit mode string, or None to keep auto-detect behavior. """ from hermes_cli.runtime_provider import _detect_api_mode_for_url detected_mode = _detect_api_mode_for_url(base_url) normalized_current = str(current_api_mode or "").strip().lower() default_mode = normalized_current or detected_mode or "" mode_options = [ ( "", "Auto-detect", "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.", ), ( "chat_completions", "Chat Completions", "Use /chat/completions for standard OpenAI-compatible servers.", ), ( "codex_responses", "Responses / Codex", "Use /responses for Codex-compatible tool-calling backends.", ), ( "anthropic_messages", "Anthropic Messages", "Use /v1/messages for Anthropic-compatible endpoints.", ), ] print() print("Select API compatibility mode:") for idx, (value, label, description) in enumerate(mode_options, 1): markers = [] if value == detected_mode: markers.append("detected") if value == default_mode: markers.append("current") suffix = f" [{' / '.join(markers)}]" if markers else "" print(f" {idx}. {label}{suffix}") print(f" {description}") try: raw = input( "Choice [1-4, Enter to keep current/detected]: " ).strip().lower() except (KeyboardInterrupt, EOFError): print("\nCancelled.") raise if not raw: return default_mode or None if raw in {"1", "auto", "detect", "auto-detect"}: return None if raw in {"2", "chat", "chat_completions", "completions"}: return "chat_completions" if raw in {"3", "responses", "codex", "codex_responses"}: return "codex_responses" if raw in {"4", "anthropic", "anthropic_messages", "messages"}: return "anthropic_messages" print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.") return None def _auto_provider_name(base_url: str) -> str: """Generate a display name from a custom endpoint URL. Returns a human-friendly label like "Local (localhost:11434)" or "RunPod (xyz.runpod.io)". Used as the default when prompting the user for a display name during custom endpoint setup. """ import re clean = base_url.replace("https://", "").replace("http://", "").rstrip("/") clean = re.sub(r"/v1/?$", "", clean) name = clean.split("/")[0] if "localhost" in name or "127.0.0.1" in name: name = f"Local ({name})" elif "runpod" in name.lower(): name = f"RunPod ({name})" else: name = name.capitalize() return name def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""): """Return the value that should be persisted for a custom provider key.""" api_key_ref = str(provider_info.get("api_key_ref", "") or "").strip() if api_key_ref: return api_key_ref key_env = str(provider_info.get("key_env", "") or "").strip() if key_env and not str(provider_info.get("api_key", "") or "").strip(): return f"${{{key_env}}}" return str(resolved_api_key or "").strip() def _custom_provider_base_url_config_value(provider_info, resolved_base_url=""): """Return the value that should be persisted for a custom provider URL.""" base_url_ref = str(provider_info.get("base_url_ref", "") or "").strip() if base_url_ref: return base_url_ref return str(resolved_base_url or "").strip() def _save_custom_provider( base_url, api_key="", model="", context_length=None, name=None, api_mode=None, key_env="" ): """Save a custom endpoint to custom_providers in config.yaml. Deduplicates by base_url — if the URL already exists, updates the model name, context_length, and api_mode but doesn't add a duplicate entry. Uses *name* when provided, otherwise auto-generates from the URL. When *key_env* is set the caller has already written the key to ``.env``, so the entry references it instead of inlining the secret (#69449). """ from hermes_cli.config import load_config, save_config cfg = load_config() providers = cfg.get("custom_providers") or [] if not isinstance(providers, list): providers = [] # Check if this URL is already saved — update model/context_length if so for entry in providers: if isinstance(entry, dict) and entry.get("base_url", "").rstrip( "/" ) == base_url.rstrip("/"): changed = False if model and entry.get("model") != model: entry["model"] = model changed = True if model and context_length: models_cfg = entry.get("models", {}) if not isinstance(models_cfg, dict): models_cfg = {} models_cfg[model] = {"context_length": context_length} entry["models"] = models_cfg changed = True if api_mode: if entry.get("api_mode") != api_mode: entry["api_mode"] = api_mode changed = True elif "api_mode" in entry: entry.pop("api_mode", None) changed = True if key_env and (entry.get("key_env") != key_env or entry.get("api_key")): entry["key_env"] = key_env entry.pop("api_key", None) changed = True if changed: cfg["custom_providers"] = providers save_config(cfg) return # already saved, updated if needed # Use provided name or auto-generate from URL if not name: name = _auto_provider_name(base_url) entry = {"name": name, "base_url": base_url} if key_env: entry["key_env"] = key_env elif api_key: entry["api_key"] = api_key if model: entry["model"] = model if api_mode: entry["api_mode"] = api_mode if model and context_length: entry["models"] = {model: {"context_length": context_length}} providers.append(entry) cfg["custom_providers"] = providers save_config(cfg) print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)') def _remove_custom_provider(config): """Let the user remove a saved custom provider from config.yaml.""" from hermes_cli.config import load_config, save_config cfg = load_config() providers = cfg.get("custom_providers") or [] if not isinstance(providers, list) or not providers: print("No custom providers configured.") return print("Remove a custom provider:\n") choices = [] for entry in providers: if isinstance(entry, dict): name = entry.get("name", "unnamed") url = entry.get("base_url", "") short_url = url.replace("https://", "").replace("http://", "").rstrip("/") choices.append(f"{name} ({short_url})") else: choices.append(str(entry)) choices.append("Cancel") try: from hermes_cli.curses_ui import curses_radiolist idx = curses_radiolist( "Select provider to remove:", list(choices), selected=0, cancel_returns=-1, ) print() if idx < 0: idx = None except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): for i, c in enumerate(choices, 1): print(f" {i}. {c}") print() try: val = input(f"Choice [1-{len(choices)}]: ").strip() idx = int(val) - 1 if val else None except (ValueError, KeyboardInterrupt, EOFError): idx = None if idx is None or idx >= len(providers): print("No change.") return removed = providers.pop(idx) cfg["custom_providers"] = providers save_config(cfg) removed_name = ( removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed) ) print(f'✅ Removed "{removed_name}" from custom providers.') def _prompt_reasoning_effort_selection(efforts, current_effort=""): """Prompt for a reasoning effort. Returns effort, 'none', or None to keep current.""" deduped = list( dict.fromkeys( str(effort).strip().lower() for effort in efforts if str(effort).strip() ) ) canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") ordered = [effort for effort in canonical_order if effort in deduped] ordered.extend(effort for effort in deduped if effort not in canonical_order) if not ordered: return None def _label(effort): if effort == current_effort: return f"{effort} ← currently in use" return effort disable_label = "Disable reasoning" skip_label = "Skip (keep current)" if current_effort == "none": default_idx = len(ordered) elif current_effort in ordered: default_idx = ordered.index(current_effort) elif "medium" in ordered: default_idx = ordered.index("medium") else: default_idx = 0 try: from hermes_cli.curses_ui import curses_radiolist choices = [_label(effort) for effort in ordered] choices.append(disable_label) choices.append(skip_label) idx = curses_radiolist( "Select reasoning effort:", choices, selected=default_idx, cancel_returns=-1, ) if idx < 0: return None print() if idx < len(ordered): return ordered[idx] if idx == len(ordered): return "none" return None except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): pass print("Select reasoning effort:") for i, effort in enumerate(ordered, 1): print(f" {i}. {_label(effort)}") n = len(ordered) print(f" {n + 1}. {disable_label}") print(f" {n + 2}. {skip_label}") print() while True: try: choice = input(f"Choice [1-{n + 2}] (default: keep current): ").strip() if not choice: return None idx = int(choice) if 1 <= idx <= n: return ordered[idx - 1] if idx == n + 1: return "none" if idx == n + 2: return None print(f"Please enter 1-{n + 2}") except ValueError: print("Please enter a number") except (KeyboardInterrupt, EOFError): return None def _prompt_api_key( pconfig, existing_key: str, provider_id: str = "", existing_source: str = "", ) -> tuple: """Shared API-key entry point for ``hermes setup`` / ``hermes model``. Handles both first-time entry and the already-configured case. When a key is already present, offers [K]eep / [R]eplace / [C]lear so the user can recover from a malformed paste without editing ``~/.hermes/.env`` by hand. Returns ``(resolved_key, abort)``. ``abort=True`` means the caller should ``return`` immediately — the user cancelled entry, declined to replace, or cleared the key and is now unconfigured. """ from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER from hermes_cli.config import save_env_value from hermes_cli.secret_prompt import masked_secret_prompt key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" def _prompt_new_key(*, allow_lmstudio_default: bool) -> str: if provider_id == "lmstudio" and allow_lmstudio_default: prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): " else: prompt = f"{key_env} (or Enter to cancel): " try: entered = masked_secret_prompt(prompt).strip() except (KeyboardInterrupt, EOFError): print() return "" if not entered and provider_id == "lmstudio" and allow_lmstudio_default: return LMSTUDIO_NOAUTH_PLACEHOLDER return entered # First-time entry ──────────────────────────────────────────────────── if not existing_key: print(f"No {pconfig.name} API key configured.") if not key_env: return "", True new_key = _prompt_new_key(allow_lmstudio_default=True) if not new_key: print("Cancelled.") return "", True save_env_value(key_env, new_key) print("API key saved.") print() return new_key, False # Already configured — offer K / R / C ──────────────────────────────── from hermes_cli.env_loader import format_secret_source_suffix source_suffix = format_secret_source_suffix(key_env) if key_env else "" print(f" {pconfig.name} API key: {existing_key[:8]}... ✓{source_suffix}") if not key_env: # Nothing we can rewrite; just acknowledge and move on. print() return existing_key, False pool_backed = existing_source.startswith("credential_pool:") menu = ( " [K]eep / [R]eplace (default K): " if pool_backed else " [K]eep / [R]eplace / [C]lear (default K): " ) try: choice = input(menu).strip().lower() except (KeyboardInterrupt, EOFError): print() choice = "k" if choice.startswith("r"): new_key = _prompt_new_key(allow_lmstudio_default=False) if not new_key: print(" No change.") print() return existing_key, False save_env_value(key_env, new_key) print(" API key updated.") print() return new_key, False if choice.startswith("c") and not pool_backed: save_env_value(key_env, "") print( f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again." ) return "", True # Keep (default, or any other input) print() return existing_key, False def _infer_stepfun_region(base_url: str) -> str: """Infer the current StepFun region from the configured endpoint.""" normalized = (base_url or "").strip().lower() if "api.stepfun.com" in normalized: return "china" return "international" def _stepfun_base_url_for_region(region: str) -> str: from hermes_cli.auth import ( STEPFUN_STEP_PLAN_CN_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL, ) return ( STEPFUN_STEP_PLAN_CN_BASE_URL if region == "china" else STEPFUN_STEP_PLAN_INTL_BASE_URL ) def _run_anthropic_oauth_flow(save_env_value): """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" from agent.anthropic_adapter import ( run_oauth_setup_token, read_claude_code_credentials, is_claude_code_token_valid, ) from hermes_cli.config import ( save_anthropic_oauth_token, use_anthropic_claude_code_credentials, ) def _activate_claude_code_credentials_if_available() -> bool: try: creds = read_claude_code_credentials() except Exception: creds = None if creds and ( is_claude_code_token_valid(creds) or bool(creds.get("refreshToken")) ): use_anthropic_claude_code_credentials(save_fn=save_env_value) print(" ✓ Claude Code credentials linked.") from hermes_constants import display_hermes_home as _dhh_fn print( f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env." ) return True return False try: print() print(" Running 'claude setup-token' — follow the prompts below.") print(" A browser window will open for you to authorize access.") print() token = run_oauth_setup_token() if token: if _activate_claude_code_credentials_if_available(): return True save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ OAuth credentials saved.") return True # Subprocess completed but no token auto-detected — ask user to paste print() print(" If the setup-token was displayed above, paste it here:") print() from hermes_cli.secret_prompt import masked_secret_prompt try: manual_token = masked_secret_prompt( " Paste setup-token (or Enter to cancel): " ).strip() except (KeyboardInterrupt, EOFError): print() return False if manual_token: save_anthropic_oauth_token(manual_token, save_fn=save_env_value) print(" ✓ Setup-token saved.") return True print(" ⚠ Could not detect saved credentials.") return False except FileNotFoundError: # Claude CLI not installed — guide user through manual setup print() print(" The 'claude' CLI is required for OAuth login.") print() print(" To install and authenticate:") print() print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code") print(" 2. Run: claude setup-token") print(" 3. Follow the browser prompts to authorize") print(" 4. Re-run: hermes model") print() print(" Or paste an existing setup-token now (sk-ant-oat-...):") print() from hermes_cli.secret_prompt import masked_secret_prompt try: token = masked_secret_prompt(" Setup-token (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() return False if token: save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ Setup-token saved.") return True print(" Cancelled — install Claude Code and try again.") return False def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: """Saved custom providers keyed by slug, with raw ``${ENV}`` refs preserved.""" from hermes_cli.config import get_compatible_custom_providers, read_raw_config from hermes_cli.providers import custom_provider_slug # Build lookups of raw (un-expanded) templates keyed by a # stable identity. We intentionally bypass # ``get_compatible_custom_providers(read_raw_config())`` here because # its ``_normalize_custom_provider_entry`` step calls ``urlparse()`` # on ``base_url`` and drops any entry whose ``base_url`` is itself an # env-ref template (e.g. ``${NEURALWATT_API_BASE}``). Dropping those # entries is exactly how env-ref preservation fails for the user # config that motivated this fix. raw_api_key_refs: dict[tuple, str] = {} raw_base_url_refs: dict[tuple, str] = {} raw_cfg = read_raw_config() def _record_raw( name: str, provider_key: str, model: str, api_key: str, base_url: str, ) -> None: template = str(api_key or "").strip() base_template = str(base_url or "").strip() name = str(name or "").strip() provider_key = str(provider_key or "").strip() model = str(model or "").strip() # Index by every plausible identity the loaded (expanded) config # might present: (name), (name, model), (provider_key), and # (provider_key, model). Case-insensitive on name/provider_key so # the loaded entry matches regardless of display casing. identities = [] if name: identities.extend(((name.lower(),), (name.lower(), model))) if provider_key: identities.extend( ((provider_key.lower(),), (provider_key.lower(), model)) ) if "${" in template: for identity in identities: raw_api_key_refs.setdefault(identity, template) if "${" in base_template: for identity in identities: raw_base_url_refs.setdefault(identity, base_template) raw_list = raw_cfg.get("custom_providers") if isinstance(raw_list, list): for raw_entry in raw_list: if not isinstance(raw_entry, dict): continue _record_raw( raw_entry.get("name", ""), "", raw_entry.get("model", "") or raw_entry.get("default_model", ""), raw_entry.get("api_key", ""), raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", ""), ) raw_providers = raw_cfg.get("providers") if isinstance(raw_providers, dict): for raw_key, raw_entry in raw_providers.items(): if not isinstance(raw_entry, dict): continue _record_raw( raw_entry.get("name", "") or raw_key, raw_key, raw_entry.get("model", "") or raw_entry.get("default_model", ""), raw_entry.get("api_key", ""), raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", ""), ) def _lookup_ref( refs: dict[tuple, str], name: str, provider_key: str, model: str, ) -> str: name_lc = str(name or "").strip().lower() pkey_lc = str(provider_key or "").strip().lower() model = str(model or "").strip() for identity in ( (pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,), ): if identity[0] and identity in refs: return refs[identity] return "" custom_provider_map = {} for entry in get_compatible_custom_providers(cfg): if not isinstance(entry, dict): continue name = (entry.get("name") or "").strip() base_url = (entry.get("base_url") or "").strip() if not name or not base_url: continue provider_key = (entry.get("provider_key") or "").strip() key = custom_provider_slug(name, provider_key) custom_provider_map[key] = { "name": name, "base_url": base_url, "api_key": entry.get("api_key", ""), "key_env": entry.get("key_env") or entry.get("api_key_env", ""), "model": entry.get("model", ""), "models": entry.get("models", {}), "models_discovered": entry.get("models_discovered", False), "extra_headers": entry.get("extra_headers", {}), "discover_models": entry.get("discover_models", True), "api_mode": entry.get("api_mode", ""), "provider_key": provider_key, "api_key_ref": _lookup_ref( raw_api_key_refs, name, provider_key, entry.get("model", "") ), "base_url_ref": _lookup_ref( raw_base_url_refs, name, provider_key, entry.get("model", "") ), } return custom_provider_map def _build_provider_picker_rows( config: dict, active: str, provider_labels: dict[str, str], custom_provider_map: dict[str, dict[str, str]], ) -> tuple[list[tuple[str, str, list[str]]], int]: """Rows for the ``hermes model`` provider picker plus the pre-selected index. Canonical providers are folded into display groups (see PROVIDER_GROUPS in hermes_cli/models.py): a multi-member group is one row whose ``members`` list drives a sub-picker; leaf rows have ``members == []``. Saved custom providers and the trailing actions stay flat. Honors ``model_catalog.excluded_providers`` (slug or alias, case-insensitive) so the CLI hides the same providers the gateway/TUI pickers do. """ from hermes_cli.models import ( CANONICAL_PROVIDERS, _PROVIDER_ALIASES, group_providers, provider_group_for_slug, ) _custom_provider_map = custom_provider_map canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS} # Honor ``model_catalog.excluded_providers`` so the CLI ``hermes model`` # picker hides the same providers the gateway/TUI pickers do. A canonical # provider is hidden if its slug OR any of its aliases appears in the # exclusion list (case-insensitive), matching list_authenticated_providers' # matching against hermes_id / alias / canonical slug. _cli_excluded = { str(p).strip().lower() for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or [] if p } if _cli_excluded: _alias_to_canon = _PROVIDER_ALIASES _names_for: dict[str, set[str]] = {} for _p in CANONICAL_PROVIDERS: _names_for[_p.slug] = {_p.slug.lower()} for _alias, _canon in _alias_to_canon.items(): _names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower()) _visible_slugs = [ p.slug for p in CANONICAL_PROVIDERS if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded ] else: _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS] grouped_rows = group_providers(_visible_slugs) # The group/slug that should be pre-selected: the active provider's group # if it's grouped, otherwise the active slug itself. active_group = provider_group_for_slug(active) if active else "" # ordered entries: (key, label, members) # members == [] → leaf row, key is a provider slug / action # members != [] → group row, key is "group:" ordered: list[tuple[str, str, list[str]]] = [] default_idx = 0 for row in grouped_rows: if row["kind"] == "group": gid = row["group_id"] group_desc = row.get("description", "") label = f"{row['label']} ▸ ({group_desc})" if group_desc else f"{row['label']} ▸" key = f"group:{gid}" is_active = bool(active_group) and gid == active_group members = row["members"] else: slug = row["slug"] label = canonical_descs.get(slug, provider_labels.get(slug, slug)) key = slug is_active = bool(active) and slug == active members = [] if is_active: ordered.append((key, f"{label} ← currently active", members)) default_idx = len(ordered) - 1 else: ordered.append((key, label, members)) for key, provider_info in _custom_provider_map.items(): name = provider_info["name"] base_url = provider_info["base_url"] short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/") saved_model = provider_info.get("model", "") model_hint = f" — {saved_model}" if saved_model else "" label = f"{name} ({short_url}){model_hint}" if active and key == active: ordered.append((key, f"{label} ← currently active", [])) default_idx = len(ordered) - 1 else: ordered.append((key, label, [])) ordered.append(("custom", "Custom endpoint (enter URL manually)", [])) _has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool( config.get("custom_providers") ) if _has_saved_custom_list: ordered.append(("remove-custom", "Remove a saved custom provider", [])) ordered.append(("aux-config", "Configure auxiliary models...", [])) ordered.append(("cancel", "Leave unchanged", [])) return ordered, default_idx