diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 39d4a8018..5753ab83c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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
``. + 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
`` + 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) diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index aa8a9c182..099f4eb94 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -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) diff --git a/tests/hermes_cli/test_setup_reconfigure.py b/tests/hermes_cli/test_setup_reconfigure.py index 6ed49e54a..73e9bfcfa 100644 --- a/tests/hermes_cli/test_setup_reconfigure.py +++ b/tests/hermes_cli/test_setup_reconfigure.py @@ -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()