Files
hermes-agent/hermes_cli/main_provider_setup.py
T

1330 lines
48 KiB
Python

"""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.<name>`` 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.<name>`` 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/<name>/ 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:<gid>"
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