fix(tui): honor launch model overrides

This commit is contained in:
Brooklyn Nicholson 2026-04-25 13:21:59 -05:00
parent ee0728c6c4
commit e9c47c7042
4 changed files with 152 additions and 9 deletions

View file

@ -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.54GB 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(

View file

@ -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

View file

@ -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(),

View file

@ -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"),