Files
hermes-agent/hermes_cli/main_provider_setup.py
Siddharth Balyan a2db110ccc feat(auth): Nous free tier: free inference and connectors out of the box, one command to sign in (#105258)
* feat(auth): Nous free tier core: anonymous identity minted on first use, welcome inference, shared-store scoping

A fresh install with no provider sets up a free Nous identity (anonymous auth method of the nous
provider) instead of forcing the setup wizard. The identity is persisted through the same path a
real login uses, so the resolver ladder is unchanged. Two seams differ: token acquisition
re-exchanges the anon credential (no refresh token), and routing pins the welcome host's single
model nous/welcome. One identity per shared store; nous.guest: false turns the free tier off.

* test(auth): free tier core contracts: lifecycle, resolver precedence, exchange seam, model pin

* docs(user-guide): free tier and signing in

New page explaining what a fresh install gets before any key or sign-in
(free inference on nous/welcome plus connectors), how the free tier
coexists with a user's own API key, how to sign in with hermes auth
upgrade and keep connectors, how to turn the free tier off with
nous.guest, what hermes logout does in each state, a troubleshooting
table, and a plain privacy note. Wired into the Using Hermes sidebar.

* fix(auth): logout leaves the free tier alone and clears the shared store for a real Nous account

Logging out of the free tier is a no-op: it is not a login, so nothing is cleared and the user
is told they were never signed in. Logging out of a real Nous account now also clears the
cross-profile store, so a profile logout is not silently re-adopted on the next boot.

* fix(model): switching off the free tier points at signing in, never hops providers

* Name the free tier in the gateway startup notice and tell explicit-provider installs about it once

* Render the Nous free tier as free tier on auth status, auth list, hermes status and portal info, short-circuit billing copy for it, and skip the keepalive when there is no refresh token

* fix(auth): free tier is set up where nothing is configured: resolver last rung and first-run check

Both the provider resolver's terminal rung and the CLI first-run check now try to set up the free
tier before declaring nothing configured. On a fresh install the first command lands in chat on
nous/welcome; a failed setup still falls through to the existing guidance.

* Add hermes auth upgrade: sign the free tier into a Nous account while keeping its connectors

The device-code flow runs as usual, with a promotion intent registered on the portal between the
code request and the token poll so the account that approves the code inherits the free tier's
connectors. The promotion status decides the outcome: only a completed one is followed by the
token grant, which is persisted over the free-tier singleton and the shared store. Declined,
superseded, retired and busy outcomes each print their own plain copy, and a retired identity is
cleared so the next use sets up a fresh one. User-facing text never names the free tier's internals.

* Show the Nous free tier as one picker row with nous/welcome and hide it when nous.guest is off

* fix(auth): upgrade opens the consent page for this sign-in; one mint attempt per process; forced free tier wins the first-run check

The browser leg of hermes auth upgrade now prints and opens the promotion claim URL with the
claim code, not the generic device page. A failed mint is attempted once per process so several
bootstrap sites cannot hit a closed gate or a 429 twice; a retired credential resets that so
re-minting still happens. HERMES_FORCE_GUEST is honoured ahead of the first-run provider check.

* fix(auth): pin the welcome model on the selected route, not on profile state; background setup retries after a failure

A credential-pool entry can select a paid Nous key while the profile singleton is still the free
tier. The model pin now keys on the resolved endpoint (welcome host) in agent init and /model, and
the pin in model normalization is removed since it had no route to look at. A failed background
identity setup releases its latch so a later attempt in the same process can try again.

* fix(auth): decide the Nous model together with the route on every credential-pool swap

The credential pool can move a Nous agent between the welcome host and the portal host after
init. One helper, pin_model_for_route, now runs at init and inside every pool swap, so the
welcome host always carries nous/welcome and a paid endpoint always keeps the caller's model.

* fix(auth): apply the route model policy on every wire mode during a pool swap; release the setup latch if the thread cannot start

* fix(auth): free-tier lifecycle takes profile then shared lock, reconciles with the shared store, persists the mint before exchanging, and clears only the identity that died

The shared store is the identity of record for a Hermes root: a profile holding a stale free-tier
identity adopts a sibling's newer sign-in instead of keeping the guest, and never overwrites the
shared account. Locks are taken in the documented order (profile, then shared). A minted credential
is stored as soon as create succeeds, so a rate-limited or timed-out exchange does not lose it and
trigger a second mint. Retiring a dead credential removes only that credential from both stores.
Guest exchange uses the resolver's canonical portal URL.

* fix(auth): a credential rotation never rewrites the conversation model; connectors honour the off switch and replace a retired free-tier credential

The welcome host serves one model, so a rotation onto it is refused for any conversation on another
model instead of silently switching that conversation to nous/welcome (the model pin applies only
when a route is first chosen). The connector token path now treats the free tier as absent when
nous.guest is false, including cached tokens, and shares the one dead-credential rule with
inference: a retired identity is replaced once rather than returning its stale token.

* fix(auth): plain login never imports the free tier as OAuth credentials; the gateway startup line reads persisted state only

A free-tier identity in the shared store is not an OAuth credential to offer for import; a real
sign-in replaces it. The gateway's startup notice now answers provider precedence from persisted
state (no token refresh at boot), so an expired free-tier token cannot stall the online message.
2026-09-11 03:45:31 +05:30

842 lines
38 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``. Names that still live in main are imported lazily at call time.
"""
import contextlib
from typing import Optional
from hermes_cli.model_setup_flows_common import _ask, _ensure_dict_section, _print_numbered, _radiolist, _say
def _is_profile_api_key_provider(provider_id: str) -> bool:
"""True when *provider_id* maps to a profile with ``auth_type='api_key'`` — the catch-all
in select_provider_and_model() so plugin providers dispatch to the generic key flow."""
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 _short_url(url: str) -> str:
"""``https://host/path/`` -> ``host/path`` for menu labels."""
return url.replace("https://", "").replace("http://", "").rstrip("/")
def _clear_stale_openai_base_url():
"""Remove OPENAI_BASE_URL from ~/.hermes/.env unless the active provider is 'custom' — a
leftover value routes provider:auto auxiliary clients to the old custom endpoint."""
from hermes_cli.config import get_env_value, save_env_value, load_config
model_cfg = load_config().get("model", {})
provider = (model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else ""
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", "")
shown = f"{stale_url[:40]}..." if len(stale_url) > 40 else stale_url
print(f"Cleared stale OPENAI_BASE_URL from .env (was: {shown})")
# (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.*` (NOT `auxiliary.delegation`) because delegate_task spawns full
# child agents via tools/delegate_tool.py::_resolve_delegation_credentials(), which reads that
# section directly. "auto" means "inherit the parent agent" 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]]:
"""Built-in aux tasks (in order) followed by plugin-registered ones
(:meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`)."""
tasks = list(_AUX_TASKS)
# Plugin discovery failure must not break the aux config UI.
with contextlib.suppress(Exception):
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"]))
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:
return f"custom ({_short_url(base_url)})" + (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 (provider/model/
base_url/api_key); an empty provider means "inherit parent" and renders as "auto"."""
d = cfg.get("delegation")
if not isinstance(d, dict):
d = {}
return {k: str(d.get(k) or "").strip() for k in ("provider", "model", "base_url", "api_key")}
def _aux_task_cfg(cfg: dict, task: str) -> dict:
"""The stored routing dict for *task* (delegation reads its top-level section)."""
if task == _DELEGATION_TASK_KEY:
return _delegation_cfg_as_task(cfg)
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
return aux.get(task, {}) if isinstance(aux.get(task), dict) else {}
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 aux task's four routing fields (timeout etc. untouched; main model config never
modified). ``delegation`` writes the top-level section, with "auto" stored as an empty provider."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
if task == _DELEGATION_TASK_KEY:
entry = _ensure_dict_section(cfg, "delegation")
provider = "" if provider == "auto" else provider
else:
entry = _ensure_dict_section(_ensure_dict_section(cfg, "auxiliary"), task)
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 (built-in + plugin) back to auto/empty. Returns number reset."""
from hermes_cli.config import load_config, save_config
def _clear(entry: dict, auto: str) -> bool:
# Only the routing fields; timeout/download_timeout (aux) and max_concurrent_children
# etc. (delegation) are user-tuned and preserved. *auto* is the reset provider value
# ("auto" for aux tasks, "" for delegation); anything else counts as a change.
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
return changed
cfg = load_config()
aux = _ensure_dict_section(cfg, "auxiliary")
count = sum(_clear(_ensure_dict_section(aux, task), "auto") for task, _name, _desc in _all_aux_tasks())
dele = cfg.get("delegation")
if isinstance(dele, dict):
count += _clear(dele, "")
save_config(cfg)
return count
def _aux_config_menu() -> None:
"""Top-level auxiliary-model picker; loops until the user picks "Back"."""
from hermes_cli.config import load_config
while True:
cfg = load_config()
_say("", " Auxiliary models — side-task routing", "",
" Side tasks (vision, compression, web extraction, etc.) default",
' to your main chat model. "auto" means "use my main model" —',
" Hermes only falls back to a lightweight backend (OpenRouter,",
" Nous Portal) if the main model is unavailable. Override a",
" task below if you want it pinned to a specific provider/model.", "")
menu_tasks = _all_aux_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 = [
(task_key, f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}"
f"{_format_aux_current(_aux_task_cfg(cfg, task_key))}")
for task_key, name, desc in menu_tasks]
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()
_say(f"Reset {n} auxiliary task(s) to auto." if n else "All auxiliary tasks were already set to auto.",
"")
continue
_aux_select_for_task(key)
def _aux_select_for_task(task: str) -> None:
"""Pick a provider + model for one aux task and persist it. Rows come from
``build_aux_picker_rows()`` (shared substrate): only already-configured providers appear."""
from hermes_cli.config import load_config
from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries
task_cfg = _aux_task_cfg(load_config(), task)
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)
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 = []
# (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: list[tuple[str, str, list[str]]] = [("__auto__", f"{auto_label}{auto_marker}", [])]
entries.extend(format_aux_picker_entries(providers, current_provider=current_provider,
current_base_url=current_base_url))
custom_marker = " ← current" if current_base_url else ""
entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", []))
entries.append(("__back__", "Back", []))
_say("", f" Configure {display_name} — current: {_format_aux_current(task_cfg)}", "")
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.")
elif slug == "__custom__":
_aux_flow_custom_endpoint(task, task_cfg)
else:
_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_pricing import get_pricing_for_provider
display_name = _aux_task_display_name(task)
try:
pricing = get_pricing_for_provider(provider_slug) or {}
except Exception:
pricing = {}
model_list = list(curated_models)
# _prompt_model_selection supports "Enter custom model name" and cancel; with no curated
# list (rare) fall back to a raw input prompt.
if not model_list:
_say(f"No curated model list for {provider_slug}.", "Enter a model slug manually (blank = use provider default):")
selected = _ask("Model: ", cancel_msg="")
if selected is None:
return
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."""
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()
_say("", f" Custom endpoint for {display_name}",
" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)", "")
url = _ask(f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: ", cancel_msg="")
if url is None:
return
url = url or current_base_url
if not url:
print("No URL provided. No change.")
return
model = _ask(f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): ",
cancel_msg="")
if model is None:
return
model = model or current_model
api_key = _ask("API key (optional, blank = use OPENAI_API_KEY): ", secret=True, cancel_msg="")
if api_key is None:
return
_save_aux_choice(task, provider="custom", model=model, base_url=url, api_key=api_key)
print(f"{display_name}: custom ({_short_url(url)})" + (f" · {model}" if model else ""))
_CANCELLED = object()
def _ask_index(prompt: str, count: int, *, echo_cancel: bool):
"""Numbered-menu input loop: 0-based index in ``range(count)``, ``None`` on blank,
``_CANCELLED`` on Ctrl-C/EOF (*echo_cancel* prints a blank line first)."""
while True:
try:
val = input(prompt).strip()
if not val:
return None
idx = int(val) - 1
if 0 <= idx < count:
return idx
print(f"Please enter 1-{count}")
except ValueError:
print("Please enter a number")
except (KeyboardInterrupt, EOFError):
if echo_cancel:
print()
return _CANCELLED
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
"""Provider menu with curses arrow keys; numbered-list fallback when curses is unavailable
(piped stdin, non-TTY). Returns the selected index, or None if the user cancels."""
with contextlib.suppress(Exception):
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice(title, choices, default)
if idx >= 0:
print()
return idx
_print_numbered(title, choices, default)
print()
idx = _ask_index(f"Choice [1-{len(choices)}] ({default + 1}): ", len(choices), echo_cancel=True)
if idx is None:
return default
return None if idx is _CANCELLED else idx
_DEFAULT_QWEN_PORTAL_MODELS = [
"qwen3-coder-plus", "qwen3-coder"]
# (mode value, label, description, accepted answers); "" = auto-detect
_CUSTOM_API_MODES = (
("", "Auto-detect", "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.",
("1", "auto", "detect", "auto-detect")),
("chat_completions", "Chat Completions", "Use /chat/completions for standard OpenAI-compatible servers.",
("2", "chat", "chat_completions", "completions")),
("codex_responses", "Responses / Codex", "Use /responses for Codex-compatible tool-calling backends.",
("3", "responses", "codex", "codex_responses")),
("anthropic_messages", "Anthropic Messages", "Use /v1/messages for Anthropic-compatible endpoints.",
("4", "anthropic", "anthropic_messages", "messages")))
_CUSTOM_API_MODE_ANSWERS = {answer: value for value, _, _, answers in _CUSTOM_API_MODES for answer in answers}
def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
"""Prompt for a custom provider API mode: an explicit mode string, or None for auto-detect."""
from hermes_cli.runtime_provider import _detect_api_mode_for_url
detected_mode = _detect_api_mode_for_url(base_url)
default_mode = str(current_api_mode or "").strip().lower() or detected_mode or ""
_say("", "Select API compatibility mode:")
for idx, (value, label, description, _answers) in enumerate(_CUSTOM_API_MODES, 1):
markers = [m for m, hit in (("detected", value == detected_mode), ("current", value == default_mode)) if hit]
suffix = f" [{' / '.join(markers)}]" if markers else ""
_say(f" {idx}. {label}{suffix}", 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 _CUSTOM_API_MODE_ANSWERS:
return _CUSTOM_API_MODE_ANSWERS[raw] or None
print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.")
return None
def _auto_provider_name(base_url: str) -> str:
"""Display name from a custom endpoint URL, e.g. "Local (localhost:11434)" or
"RunPod (xyz.runpod.io)" — the default offered during custom endpoint setup."""
import re
name = re.sub(r"/v1/?$", "", _short_url(base_url)).split("/")[0]
if "localhost" in name or "127.0.0.1" in name:
return f"Local ({name})"
if "runpod" in name.lower():
return f"RunPod ({name})"
return name.capitalize()
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."""
return str(provider_info.get("base_url_ref", "") or "").strip() or 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, deduplicated by base_url (an
existing entry gets model / context_length / api_mode updated). *key_env* set means the caller
already wrote the key to ``.env``; the entry references it instead of inlining the secret.
See #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 = []
for entry in providers:
if not (isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/")):
continue
changed = False
if model and entry.get("model") != model:
entry["model"] = model
changed = True
if model and context_length:
_ensure_dict_section(entry, "models")[model] = {"context_length": context_length}
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
name = name or _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 = [
f"{entry.get('name', 'unnamed')} ({_short_url(entry.get('base_url', ''))})" if isinstance(entry, dict) else str(entry)
for entry in providers]
choices.append("Cancel")
idx = _radiolist("Select provider to remove:", list(choices))
if idx is not None:
print()
if idx < 0:
idx = None
else:
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):
return f"{effort} ← currently in use" if effort == current_effort else 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
n = len(ordered)
idx = _radiolist("Select reasoning effort:", [_label(effort) for effort in ordered] + [disable_label, skip_label],
default_idx)
if idx is not None:
if idx < 0:
return None
print()
else:
print("Select reasoning effort:")
for i, effort in enumerate(ordered, 1):
print(f" {i}. {_label(effort)}")
_say(f" {n + 1}. {disable_label}", f" {n + 2}. {skip_label}", "")
idx = _ask_index(f"Choice [1-{n + 2}] (default: keep current): ", n + 2, echo_cancel=False)
if idx is None or idx is _CANCELLED:
return None
if idx < n:
return ordered[idx]
return "none" if idx == n else None
def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "", existing_source: str = "") -> tuple:
"""API-key entry for ``hermes setup`` / ``hermes model``: first-time entry, or [K]eep / [R]eplace /
[C]lear when a key exists (a malformed paste is recoverable without editing ``.env``).
Returns ``(resolved_key, abort)``; ``abort=True`` means the caller must ``return`` at once."""
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
from hermes_cli.config import save_env_value
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:
lmstudio_default = provider_id == "lmstudio" and allow_lmstudio_default
if lmstudio_default:
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
else:
prompt = f"{key_env} (or Enter to cancel): "
entered = _ask(prompt, secret=True, cancel_msg="")
if entered is None:
return ""
if not entered and lmstudio_default:
return LMSTUDIO_NOAUTH_PLACEHOLDER
return entered
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)
_say("API key saved.", "")
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): "
choice = _ask(menu, raw=True, cancel_msg="", on_cancel="k").lower()
if choice.startswith("r"):
new_key = _prompt_new_key(allow_lmstudio_default=False)
if not new_key:
_say(" No change.", "")
return existing_key, False
save_env_value(key_env, new_key)
_say(" API key updated.", "")
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."""
return "china" if "api.stepfun.com" in (base_url or "").strip().lower() else "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_credentials 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
def _paste_token(prompt: str):
"""Manual setup-token entry: True saved, False empty, None cancelled."""
token = _ask(prompt, secret=True, cancel_msg="")
if not token:
return token
save_anthropic_oauth_token(token, save_fn=save_env_value)
print(" ✓ Setup-token saved.")
return True
try:
_say("", " Running 'claude setup-token' — follow the prompts below.",
" A browser window will open for you to authorize access.", "")
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
_say("", " If the setup-token was displayed above, paste it here:", "")
saved = _paste_token(" Paste setup-token (or Enter to cancel): ")
if saved is None:
return False
if saved:
return True
print(" ⚠ Could not detect saved credentials.")
return False
except FileNotFoundError:
# Claude CLI not installed — guide user through manual setup
_say("", " The 'claude' CLI is required for OAuth login.", "", " To install and authenticate:", "",
" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code",
" 2. Run: claude setup-token",
" 3. Follow the browser prompts to authorize",
" 4. Re-run: hermes model", "",
" Or paste an existing setup-token now (sk-ant-oat-...):", "")
saved = _paste_token(" Setup-token (or Enter to cancel): ")
if saved is None:
return False
if 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
# Raw (un-expanded) templates keyed by identity. ``get_compatible_custom_providers(
# read_raw_config())`` is deliberately bypassed: its normalize step ``urlparse()``s
# ``base_url`` and drops entries whose base_url is itself an env-ref template.
raw_api_key_refs: dict[tuple, str] = {}
raw_base_url_refs: dict[tuple, str] = {}
raw_cfg = read_raw_config()
raw_entries: list[tuple[str, str, dict]] = []
raw_list = raw_cfg.get("custom_providers")
if isinstance(raw_list, list):
raw_entries.extend((e.get("name", ""), "", e) for e in raw_list if isinstance(e, dict))
raw_providers = raw_cfg.get("providers")
if isinstance(raw_providers, dict):
raw_entries.extend((e.get("name", "") or k, k, e) for k, e in raw_providers.items() if isinstance(e, dict))
for name, provider_key, raw_entry in raw_entries:
template = str(raw_entry.get("api_key", "") or "").strip()
base_template = str(raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", "") or "").strip()
name = str(name or "").strip()
provider_key = str(provider_key or "").strip()
model = str(raw_entry.get("model", "") or raw_entry.get("default_model", "") or "").strip()
# Index by every identity the loaded (expanded) config might present: (name),
# (name, model), (provider_key), (provider_key, model); case-insensitive names.
keys = [k.lower() for k in (name, provider_key) if k]
identities = [(k,) for k in keys] + [(k, model) for k in keys]
for refs, tmpl in ((raw_api_key_refs, template), (raw_base_url_refs, base_template)):
if "${" in tmpl:
for identity in identities:
refs.setdefault(identity, tmpl)
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()
return next((refs[i] for i in ((pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,)) if i[0] and i in refs), "")
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()
model = entry.get("model", "")
custom_provider_map[custom_provider_slug(name, provider_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", ""),
"key_cmd": entry.get("key_cmd", ""),
"model": 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, model),
"base_url_ref": _lookup_ref(raw_base_url_refs, name, provider_key, 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
fold into display groups (PROVIDER_GROUPS): a group row's ``members`` drive a sub-picker, leaf
rows have ``members == []``; saved custom providers and trailing actions stay flat. Honors
``model_catalog.excluded_providers`` (slug or alias, case-insensitive) like the gateway/TUI."""
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_ALIASES
from hermes_cli.models_catalog_static import group_providers, provider_group_for_slug
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
_cli_excluded = {
str(p).strip().lower()
for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or []
if p}
if _cli_excluded:
# A canonical provider is hidden if its slug OR any alias is excluded.
_names_for: dict[str, set[str]] = {_p.slug: {_p.slug.lower()} for _p in CANONICAL_PROVIDERS}
for _alias, _canon in _PROVIDER_ALIASES.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]
# The active provider's group when grouped, otherwise the active slug itself.
active_group = provider_group_for_slug(active) if active else ""
# (key, label, members): members == [] → leaf row (provider slug / action);
# members != [] → group row, key is "group:<gid>"
ordered: list[tuple[str, str, list[str]]] = []
default_idx = 0
def _add(key, label, members, is_active):
nonlocal default_idx
if is_active:
label = f"{label} ← currently active"
default_idx = len(ordered)
ordered.append((key, label, members))
for row in group_providers(_visible_slugs):
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']} ▸"
_add(f"group:{gid}", label, row["members"], bool(active_group) and gid == active_group)
else:
slug = row["slug"]
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
if slug == "nous":
# Same free-tier rule as the gateway/TUI pickers: relabel for a guest, hide
# when nous.guest is off, untouched for a real account.
from hermes_cli.model_switch_providers import _free_tier_nous_row
tier_row = _free_tier_nous_row({"name": label, "models": []})
if tier_row is None:
continue
label = tier_row["name"]
_add(slug, label, [], bool(active) and slug == active)
for key, provider_info in custom_provider_map.items():
saved_model = provider_info.get("model", "")
model_hint = f" — {saved_model}" if saved_model else ""
_add(key, f"{provider_info['name']} ({_short_url(provider_info['base_url'])}){model_hint}", [],
bool(active) and key == active)
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
if isinstance(config.get("custom_providers"), list) and config.get("custom_providers"):
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