diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e821e9bac0..5ba269c223 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1028,7 +1028,12 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [node, str(root / "dist" / "entry.js")], root -def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): +def _launch_tui( + resume_session_id: Optional[str] = None, + tui_dev: bool = False, + model: Optional[str] = None, + provider: Optional[str] = None, +): """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" @@ -1038,6 +1043,12 @@ def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): ) env.setdefault("HERMES_PYTHON", sys.executable) env.setdefault("HERMES_CWD", os.getcwd()) + if model: + env["HERMES_MODEL"] = model + env["HERMES_INFERENCE_MODEL"] = model + if provider: + env["HERMES_TUI_PROVIDER"] = provider + env["HERMES_INFERENCE_PROVIDER"] = provider # Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is # ~1.5–4GB depending on version and can fatal-OOM on long sessions with # large transcripts / reasoning blobs. Token-level merge: respect any @@ -1176,6 +1187,8 @@ def cmd_chat(args): _launch_tui( getattr(args, "resume", None), tui_dev=getattr(args, "tui_dev", False), + model=getattr(args, "model", None), + provider=getattr(args, "provider", None), ) # Import and run the CLI @@ -6913,7 +6926,7 @@ For more help on a command: default=None, help=( "Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). " - "Applies to -z/--oneshot. Also settable via HERMES_INFERENCE_MODEL env var." + "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var." ), ) parser.add_argument( @@ -6921,7 +6934,7 @@ For more help on a command: default=None, help=( "Provider override for this invocation (e.g. openrouter, anthropic). " - "Applies to -z/--oneshot. Also settable via HERMES_INFERENCE_PROVIDER env var." + "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var." ), ) parser.add_argument( diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index c7e551ea1c..6044b04a4b 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -1,4 +1,5 @@ from argparse import Namespace +from pathlib import Path import sys import types @@ -8,8 +9,11 @@ import pytest def _args(**overrides): base = { "continue_last": None, + "model": None, + "provider": None, "resume": None, "tui": True, + "tui_dev": False, } base.update(overrides) return Namespace(**base) @@ -31,7 +35,7 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod): calls.append(source) return "20260408_235959_a1b2c3" if source == "tui" else None - def fake_launch(resume_session_id=None, tui_dev=False): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -58,7 +62,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai return "20260408_235959_d4e5f6" return None - def fake_launch(resume_session_id=None, tui_dev=False): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -76,7 +80,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod): captured = {} - def fake_launch(resume_session_id=None, tui_dev=False): + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): captured["resume"] = resume_session_id raise SystemExit(0) @@ -89,6 +93,60 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod) assert captured["resume"] == "20260409_000000_aa11bb" +def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod): + captured = {} + + def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None): + captured.update( + { + "model": model, + "provider": provider, + "resume": resume_session_id, + "tui_dev": tui_dev, + } + ) + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat( + _args(model="anthropic/claude-sonnet-4.6", provider="anthropic") + ) + + assert captured == { + "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "resume": None, + "tui_dev": False, + } + + +def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod): + captured = {} + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), + ) + + def fake_call(argv, cwd=None, env=None): + captured.update({"argv": argv, "cwd": cwd, "env": env}) + return 1 + + monkeypatch.setattr(main_mod.subprocess, "call", fake_call) + + with pytest.raises(SystemExit): + main_mod._launch_tui(model="nous/hermes-test", provider="nous") + + env = captured["env"] + assert env["HERMES_MODEL"] == "nous/hermes-test" + assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test" + assert env["HERMES_TUI_PROVIDER"] == "nous" + assert env["HERMES_INFERENCE_PROVIDER"] == "nous" + + def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): import hermes_cli.main as main_mod diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 107d238977..4b4e837c5e 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -83,6 +83,40 @@ def test_status_callback_accepts_single_message_argument(): ) +def test_resolve_model_uses_inference_model_env(monkeypatch): + monkeypatch.delenv("HERMES_MODEL", raising=False) + monkeypatch.setenv("HERMES_INFERENCE_MODEL", "anthropic/claude-sonnet-4.6") + + assert server._resolve_model() == "anthropic/claude-sonnet-4.6" + + +def test_startup_runtime_uses_tui_provider_env(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test") + monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous") + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + + assert server._resolve_startup_runtime() == ("nous/hermes-test", "nous") + + +def test_startup_runtime_detects_provider_for_model_env(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "sonnet") + monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}}) + + def fake_detect(model, current_provider): + assert model == "sonnet" + assert current_provider == "auto" + return "anthropic", "anthropic/claude-sonnet-4.6" + + monkeypatch.setattr("hermes_cli.models.detect_provider_for_model", fake_detect) + + assert server._resolve_startup_runtime() == ( + "anthropic/claude-sonnet-4.6", + "anthropic", + ) + + def _session(agent=None, **extra): return { "agent": agent if agent is not None else types.SimpleNamespace(), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 891b6128e3..f7a0dd08ee 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -560,7 +560,7 @@ def resolve_skin() -> dict: def _resolve_model() -> str: - env = os.environ.get("HERMES_MODEL", "") + env = os.environ.get("HERMES_MODEL", "") or os.environ.get("HERMES_INFERENCE_MODEL", "") if env: return env m = _load_cfg().get("model", "") @@ -571,6 +571,40 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" +def _resolve_startup_runtime() -> tuple[str, str | None]: + model = _resolve_model() + explicit_provider = ( + os.environ.get("HERMES_TUI_PROVIDER", "") + or os.environ.get("HERMES_INFERENCE_PROVIDER", "") + ).strip() + if explicit_provider: + return model, explicit_provider + + explicit_model = ( + os.environ.get("HERMES_MODEL", "") + or os.environ.get("HERMES_INFERENCE_MODEL", "") + ).strip() + if not explicit_model: + return model, None + + try: + from hermes_cli.models import detect_provider_for_model + + cfg = _load_cfg().get("model") or {} + current_provider = ( + str(cfg.get("provider") or "").strip().lower() + if isinstance(cfg, dict) + else "" + ) or "auto" + detected = detect_provider_for_model(explicit_model, current_provider) + if detected: + provider, detected_model = detected + return detected_model, provider + except Exception: + pass + return model, None + + def _write_config_key(key_path: str, value): cfg = _load_cfg() current = cfg @@ -1277,9 +1311,13 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): cfg = _load_cfg() system_prompt = ((cfg.get("agent") or {}).get("system_prompt", "") or "").strip() - runtime = resolve_runtime_provider(requested=None) + model, requested_provider = _resolve_startup_runtime() + runtime = resolve_runtime_provider( + requested=requested_provider, + target_model=model or None, + ) return AIAgent( - model=_resolve_model(), + model=model, provider=runtime.get("provider"), base_url=runtime.get("base_url"), api_key=runtime.get("api_key"),