feat(setup): thin out setup — Quick Setup via Nous Portal + Full Setup defaults (#35723)

* feat(setup): Quick Setup routes through Nous Portal (OAuth + model + messaging)

First-time quick setup now goes straight to the Nous Portal provider
instead of showing the full provider picker. Runs the device-code OAuth
login, selects a Nous model, configures the terminal backend, and offers
messaging setup — applying recommended defaults for everything else.

- Rename menu entry to 'Quick Setup (Nous Portal)'.
- _run_first_time_quick_setup now calls _model_flow_nous (handles both the
  logged-out OAuth+model-select path and the logged-in curated picker),
  then re-syncs config from disk to avoid the #4172 stale-overwrite.
- Terminal / defaults / messaging steps unchanged.

* feat(setup): thin out Full Setup with happy defaults

Full Setup no longer asks for every config knob — anything with an
obvious default is applied silently and stays tunable via the per-section
commands (hermes setup agent|terminal|tts, hermes auth add).

- Model section: drop the same-provider rotation pool, vision-backend
  picker, and TTS provider sub-flows. Vision auto-detects from the main
  provider; TTS defaults to Edge; rotation lives in hermes auth add.
- Terminal section: keep the backend picker (Local default) and any
  required credentials (Modal token, SSH host/user/key, Daytona key),
  but stop prompting for container image, CPU/mem/disk resources, gateway
  cwd, and sudo password — all use defaults.
- Agent Settings: removed from the wizard. First installs get recommended
  defaults silently; existing installs keep their tuned values.
- New defaults: max_turns 90 -> 150, session_reset both -> none.
- Tests: reconfigure tests assert agent settings are no longer prompted
  on existing installs; drop 3 tests covering the deleted in-setup
  rotation flow.
This commit is contained in:
Teknium
2026-05-31 09:13:06 -07:00
committed by GitHub
parent a726e8a811
commit de4f40ed02
3 changed files with 73 additions and 427 deletions

View File

@ -737,175 +737,15 @@ def setup_model_provider(config: dict, *, quick: bool = False):
if isinstance(_m, dict):
selected_provider = _m.get("provider")
# ── Same-provider fallback & rotation setup (full setup only) ──
if not quick and _supports_same_provider_pool_setup(selected_provider):
try:
from types import SimpleNamespace
from agent.credential_pool import load_pool
from hermes_cli.auth_commands import auth_add_command
pool = load_pool(selected_provider)
entries = pool.entries()
entry_count = len(entries)
manual_count = sum(1 for entry in entries if str(getattr(entry, "source", "")).startswith("manual"))
auto_count = entry_count - manual_count
print()
print_header("Same-Provider Fallback & Rotation")
print_info(
"Hermes can keep multiple credentials for one provider and rotate between"
)
print_info(
"them when a credential is exhausted or rate-limited. This preserves"
)
print_info(
"your primary provider while reducing interruptions from quota issues."
)
print()
if auto_count > 0:
print_info(
f"Current pooled credentials for {selected_provider}: {entry_count} "
f"({manual_count} manual, {auto_count} auto-detected from env/shared auth)"
)
else:
print_info(f"Current pooled credentials for {selected_provider}: {entry_count}")
while prompt_yes_no("Add another credential for same-provider fallback?", False):
auth_add_command(
SimpleNamespace(
provider=selected_provider,
auth_type="",
label=None,
api_key=None,
portal_url=None,
inference_url=None,
client_id=None,
scope=None,
no_browser=False,
timeout=15.0,
insecure=False,
ca_bundle=None,
)
)
pool = load_pool(selected_provider)
entry_count = len(pool.entries())
print_info(f"Provider pool now has {entry_count} credential(s).")
if entry_count > 1:
strategy_labels = [
"Fill-first / sticky — keep using the first healthy credential until it is exhausted",
"Round robin — rotate to the next healthy credential after each selection",
"Random — pick a random healthy credential each time",
]
current_strategy = _get_credential_pool_strategies(config).get(selected_provider, "fill_first")
default_strategy_idx = {
"fill_first": 0,
"round_robin": 1,
"random": 2,
}.get(current_strategy, 0)
strategy_idx = prompt_choice(
"Select same-provider rotation strategy:",
strategy_labels,
default_strategy_idx,
)
strategy_value = ["fill_first", "round_robin", "random"][strategy_idx]
_set_credential_pool_strategy(config, selected_provider, strategy_value)
print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}")
except Exception as exc:
logger.debug("Could not configure same-provider fallback in setup: %s", exc)
# ── Vision & Image Analysis Setup (full setup only) ──
if quick:
_vision_needs_setup = False
else:
try:
from agent.auxiliary_client import get_available_vision_backends
_vision_backends = set(get_available_vision_backends())
except Exception:
_vision_backends = set()
_vision_needs_setup = not bool(_vision_backends)
if selected_provider in _vision_backends:
_vision_needs_setup = False
if _vision_needs_setup:
_prov_names = {
"nous-api": "Nous Portal API key",
"copilot": "GitHub Copilot",
"copilot-acp": "GitHub Copilot ACP",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"kimi-coding-cn": "Kimi / Moonshot (China)",
"stepfun": "StepFun Step Plan",
"minimax": "MiniMax",
"minimax-cn": "MiniMax CN",
"anthropic": "Anthropic",
"custom": "your custom endpoint",
}
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
print()
print_header("Vision & Image Analysis (optional)")
print_info(f"Vision uses a separate multimodal backend. {_prov_display}")
print_info("doesn't currently provide one Hermes can auto-use for vision,")
print_info("so choose a backend now or skip and configure later.")
print()
_vision_choices = [
"OpenRouter — uses Gemini (free tier at openrouter.ai/keys)",
"OpenAI-compatible endpoint — base URL, API key, and vision model",
"Skip for now",
]
_vision_idx = prompt_choice("Configure vision:", _vision_choices, 2)
if _vision_idx == 0: # OpenRouter
_or_key = prompt(" OpenRouter API key", password=True).strip()
if _or_key:
save_env_value("OPENROUTER_API_KEY", _or_key)
print_success("OpenRouter key saved — vision will use Gemini")
else:
print_info("Skipped — vision won't be available")
elif _vision_idx == 1: # OpenAI-compatible endpoint
_base_url = prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
_api_key_label = " API key"
_is_native_openai = base_url_hostname(_base_url) == "api.openai.com"
if _is_native_openai:
_api_key_label = " OpenAI API key"
_oai_key = prompt(_api_key_label, password=True).strip()
if _oai_key:
save_env_value("OPENAI_API_KEY", _oai_key)
# Save vision base URL to config (not .env — only secrets go there)
_vaux = config.setdefault("auxiliary", {}).setdefault("vision", {})
_vaux["base_url"] = _base_url
if _is_native_openai:
_oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
_vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
_vm_idx = prompt_choice("Select vision model:", _vm_choices, 0)
_selected_vision_model = (
_oai_vision_models[_vm_idx]
if _vm_idx < len(_oai_vision_models)
else "gpt-4o-mini"
)
else:
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
if _selected_vision_model:
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
print_success(
f"Vision configured with {_base_url}"
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
)
else:
print_info("Skipped — vision won't be available")
else:
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
# Credential rotation, vision-backend selection, and TTS provider are no
# longer prompted here. They have safe defaults (rotation off, vision
# auto-detected from the main provider, TTS = Edge) and are configurable
# on demand via `hermes auth add`, `hermes setup` vision, and
# `hermes setup tts`. This keeps both quick and full setup thin.
# Tool Gateway prompt is already shown by _model_flow_nous() above.
save_config(config)
if not quick and selected_provider != "nous":
_setup_tts_provider(config)
# =============================================================================
# Section 1b: TTS Provider Configuration
@ -1341,29 +1181,9 @@ def setup_terminal_backend(config: dict):
if selected_backend == "local":
print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.")
# Gateway/cron working directory
print()
print_info("Gateway working directory:")
print_info(" Used by Telegram/Discord/cron sessions.")
print_info(" CLI/TUI always uses your launch directory instead.")
current_cwd = cfg_get(config, "terminal", "cwd", default="")
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
if cwd:
config["terminal"]["cwd"] = cwd
# Sudo support
print()
existing_sudo = get_env_value("SUDO_PASSWORD")
if existing_sudo:
print_info("Sudo password: configured")
elif prompt_yes_no(
"Enable sudo support? (stores password for apt install, etc.)", False
):
sudo_pass = prompt(" Sudo password", password=True)
if sudo_pass:
save_env_value("SUDO_PASSWORD", sudo_pass)
print_success("Sudo password saved")
# Gateway working directory defaults to home; sudo stays off. Both are
# configurable later via `hermes setup terminal` / config.yaml.
config["terminal"].setdefault("cwd", str(Path.home()))
elif selected_backend == "docker":
print_success("Terminal backend: Docker")
@ -1376,13 +1196,10 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Docker found: {docker_bin}")
# Docker image
current_image = cfg_get(config, "terminal", "docker_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Docker image", current_image)
config["terminal"]["docker_image"] = image
save_env_value("TERMINAL_DOCKER_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
elif selected_backend == "singularity":
print_success("Terminal backend: Singularity/Apptainer")
@ -1397,12 +1214,11 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Found: {sing_bin}")
current_image = cfg_get(config, "terminal", "singularity_image", default="docker://nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Container image", current_image)
config["terminal"]["singularity_image"] = image
save_env_value("TERMINAL_SINGULARITY_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"singularity_image",
"docker://nikolaik/python-nodejs:python3.11-nodejs20",
)
elif selected_backend == "modal":
print_success("Terminal backend: Modal")
@ -1501,8 +1317,6 @@ def setup_terminal_backend(config: dict):
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
_prompt_container_resources(config)
elif selected_backend == "daytona":
print_success("Terminal backend: Daytona")
print_info("Persistent cloud development environments.")
@ -1552,13 +1366,10 @@ def setup_terminal_backend(config: dict):
save_env_value("DAYTONA_API_KEY", api_key)
print_success(" Configured")
# Daytona image
current_image = cfg_get(config, "terminal", "daytona_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Sandbox image", current_image)
config["terminal"]["daytona_image"] = image
save_env_value("TERMINAL_DAYTONA_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
@ -1625,7 +1436,7 @@ def setup_terminal_backend(config: dict):
def _apply_default_agent_settings(config: dict):
"""Apply recommended defaults for all agent settings without prompting."""
config.setdefault("agent", {})["max_turns"] = 90
config.setdefault("agent", {})["max_turns"] = 150
# config.yaml is the authoritative source for max_turns; the gateway
# bridges it into HERMES_MAX_ITERATIONS at startup. We no longer write
# to .env to avoid the dual-source inconsistency that caused the
@ -1637,18 +1448,17 @@ def _apply_default_agent_settings(config: dict):
config.setdefault("compression", {})["enabled"] = True
config["compression"]["threshold"] = 0.50
config.setdefault("session_reset", {}).update({
"mode": "both",
"idle_minutes": 1440,
"at_hour": 4,
})
# Default to never auto-resetting sessions. The gateway treats absent
# session_reset as "both", so we must write "none" explicitly to make
# the no-auto-reset default actually take effect.
config.setdefault("session_reset", {})["mode"] = "none"
save_config(config)
print_success("Applied recommended defaults:")
print_info(" Max iterations: 90")
print_info(" Max iterations: 150")
print_info(" Tool progress: all")
print_info(" Compression threshold: 0.50")
print_info(" Session reset: inactivity (1440 min) + daily (4:00)")
print_info(" Session reset: never (use /reset or compression)")
print_info(" Run `hermes setup agent` later to customize.")
@ -3197,7 +3007,7 @@ def run_setup_wizard(args):
config = load_config()
setup_mode = prompt_choice("How would you like to set up Hermes?", [
"Quick setup — provider, model & messaging (recommended)",
"Quick Setup (Nous Portal) — OAuth login, model & messaging (recommended)",
"Full setup — configure everything",
], 0)
@ -3228,9 +3038,11 @@ def run_setup_wizard(args):
if not (migration_ran and _skip_configured_section(config, "terminal", "Terminal Backend")):
setup_terminal_backend(config)
# Section 3: Agent Settings
if not (migration_ran and _skip_configured_section(config, "agent", "Agent Settings")):
setup_agent_settings(config)
# Section 3: Agent Settings — no longer prompted. First installs get the
# recommended defaults silently; existing installs keep whatever they have.
# Tune later with `hermes setup agent`.
if not is_existing:
_apply_default_agent_settings(config)
# Section 4: Messaging Platforms
if not (migration_ran and _skip_configured_section(config, "gateway", "Messaging Platforms")):
@ -3250,13 +3062,43 @@ def run_setup_wizard(args):
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
"""Streamlined first-time setup: provider, model, terminal & messaging.
"""Streamlined first-time setup via Nous Portal: OAuth, model, terminal & messaging.
Applies sensible defaults for TTS (Edge), agent settings, and tools —
the user can customize later via ``hermes setup <section>``.
Routes straight to the Nous Portal provider — runs the device-code OAuth
login, picks a Nous model, then configures the terminal backend and (optionally)
a messaging platform. Applies sensible defaults for everything else (agent
settings, tools); the user can customize later via ``hermes setup <section>``
or switch providers with ``hermes model``.
"""
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
setup_model_provider(config, quick=True)
from hermes_cli.config import load_config
# Step 1: Nous Portal — OAuth login + model selection.
# _model_flow_nous() handles both the logged-out path (device-code OAuth,
# which selects a model internally) and the already-logged-in path (curated
# Nous model picker). Provider is set to "nous" by the login/model save.
print()
print_header("Nous Portal")
print_info("One subscription, 300+ models, plus the Tool Gateway:")
print_info(" web search, image generation, TTS, browser automation.")
print_info("Sign up: https://portal.nousresearch.com/manage-subscription")
print()
try:
from hermes_cli.main import _model_flow_nous
_model_flow_nous(config)
except (KeyboardInterrupt, EOFError):
print()
print_info("Nous Portal setup cancelled.")
except Exception as exc:
logger.debug("_model_flow_nous error during quick setup: %s", exc)
print_warning(f"Nous Portal setup encountered an error: {exc}")
print_info("You can try again later with: hermes model")
# Re-sync the wizard's config dict from disk — _model_flow_nous (and the
# underlying login/model save) write via their own load/save cycle, and the
# wizard's later save_config(config) must not clobber those values (#4172).
_refreshed = load_config()
config.clear()
config.update(_refreshed)
# Step 2: Terminal Backend — where commands run is a core decision
setup_terminal_backend(config)

View File

@ -146,203 +146,6 @@ def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(
assert reloaded["model"]["provider"] == "zai"
def test_setup_same_provider_rotation_strategy_saved_for_multi_credential_pool(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
save_env_value("OPENROUTER_API_KEY", "or-key")
# Pre-write config so the pool step sees provider="openrouter"
_write_model_config("openrouter", "", "anthropic/claude-opus-4.6")
config = load_config()
class _Entry:
def __init__(self, label):
self.label = label
class _Pool:
def entries(self):
return [_Entry("primary"), _Entry("secondary")]
def fake_select():
pass # no-op — config already has provider set
def fake_prompt_choice(question, choices, default=0):
if "rotation strategy" in question:
return 1 # round robin
tts_idx = _maybe_keep_current_tts(question, choices)
if tts_idx is not None:
return tts_idx
return default
def fake_prompt_yes_no(question, default=True):
return False
# Patch directly on the module objects to ensure local imports pick them up.
import hermes_cli.main as _main_mod
import hermes_cli.setup as _setup_mod
import agent.credential_pool as _pool_mod
import agent.auxiliary_client as _aux_mod
monkeypatch.setattr(_main_mod, "select_provider_and_model", fake_select)
# NOTE: _stub_tts overwrites prompt_choice, so set our mock AFTER it.
_stub_tts(monkeypatch)
monkeypatch.setattr(_setup_mod, "prompt_choice", fake_prompt_choice)
monkeypatch.setattr(_setup_mod, "prompt_yes_no", fake_prompt_yes_no)
monkeypatch.setattr(_setup_mod, "prompt", lambda *args, **kwargs: "")
monkeypatch.setattr(_pool_mod, "load_pool", lambda provider: _Pool())
monkeypatch.setattr(_aux_mod, "get_available_vision_backends", lambda: [])
setup_model_provider(config)
# The pool has 2 entries, so the strategy prompt should fire
strategy = config.get("credential_pool_strategies", {}).get("openrouter")
assert strategy == "round_robin", f"Expected round_robin but got {strategy}"
def test_setup_same_provider_fallback_can_add_another_credential(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
save_env_value("OPENROUTER_API_KEY", "or-key")
# Pre-write config so the pool step sees provider="openrouter"
_write_model_config("openrouter", "", "anthropic/claude-opus-4.6")
config = load_config()
pool_sizes = iter([1, 2])
add_calls = []
class _Entry:
def __init__(self, label):
self.label = label
class _Pool:
def __init__(self, size):
self._size = size
def entries(self):
return [_Entry(f"cred-{idx}") for idx in range(self._size)]
def fake_load_pool(provider):
return _Pool(next(pool_sizes))
def fake_auth_add_command(args):
add_calls.append(args.provider)
def fake_select():
pass # no-op — config already has provider set
def fake_prompt_choice(question, choices, default=0):
if question == "Select same-provider rotation strategy:":
return 0
tts_idx = _maybe_keep_current_tts(question, choices)
if tts_idx is not None:
return tts_idx
return default
yes_no_answers = iter([True, False])
def fake_prompt_yes_no(question, default=True):
if question == "Add another credential for same-provider fallback?":
return next(yes_no_answers)
return False
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
_stub_tts(monkeypatch)
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", fake_prompt_yes_no)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("agent.credential_pool.load_pool", fake_load_pool)
monkeypatch.setattr("hermes_cli.auth_commands.auth_add_command", fake_auth_add_command)
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
setup_model_provider(config)
assert add_calls == ["openrouter"]
assert config.get("credential_pool_strategies", {}).get("openrouter") == "fill_first"
def test_setup_same_provider_single_credential_keeps_existing_rotation_strategy(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
save_env_value("OPENROUTER_API_KEY", "or-key")
_write_model_config("openrouter", "", "anthropic/claude-opus-4.6")
config = load_config()
config["credential_pool_strategies"] = {"openrouter": "round_robin"}
save_config(config)
class _Entry:
def __init__(self, label):
self.label = label
class _Pool:
def entries(self):
return [_Entry("primary")]
def fake_select():
pass
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
_stub_tts(monkeypatch)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
setup_model_provider(config)
assert config.get("credential_pool_strategies", {}).get("openrouter") == "round_robin"
def test_setup_pool_step_shows_manual_vs_auto_detected_counts(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
save_env_value("OPENROUTER_API_KEY", "or-key")
# Pre-write config so the pool step sees provider="openrouter"
_write_model_config("openrouter", "", "anthropic/claude-opus-4.6")
config = load_config()
class _Entry:
def __init__(self, label, source):
self.label = label
self.source = source
class _Pool:
def entries(self):
return [
_Entry("primary", "manual"),
_Entry("secondary", "manual"),
_Entry("OPENROUTER_API_KEY", "env:OPENROUTER_API_KEY"),
]
def fake_select():
pass # no-op — config already has provider set
def fake_prompt_choice(question, choices, default=0):
if "rotation strategy" in question:
return 0
tts_idx = _maybe_keep_current_tts(question, choices)
if tts_idx is not None:
return tts_idx
return default
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
_stub_tts(monkeypatch)
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
setup_model_provider(config)
out = capsys.readouterr().out
assert "Current pooled credentials for openrouter: 3 (2 manual, 1 auto-detected from env/shared auth)" in out
def test_setup_copilot_acp_skips_same_provider_pool_step(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)

View File

@ -122,10 +122,11 @@ class TestExistingInstallDefault:
m["prompt_choice"].assert_not_called()
# Quick-setup path NOT taken.
m["quick"].assert_not_called()
# All five sections ran.
# Model/terminal/gateway/tools run; agent settings are no longer
# prompted on existing installs (they keep their tuned values).
m["model"].assert_called_once()
m["terminal"].assert_called_once()
m["agent"].assert_called_once()
m["agent"].assert_not_called()
m["gateway"].assert_called_once()
m["tools"].assert_called_once()
@ -149,7 +150,7 @@ class TestExistingInstallDefault:
m["prompt_choice"].assert_not_called()
m["model"].assert_called_once()
m["terminal"].assert_called_once()
m["agent"].assert_called_once()
m["agent"].assert_not_called()
m["gateway"].assert_called_once()
m["tools"].assert_called_once()