fix(model): require confirmation for expensive model selections

Rebased onto current main and re-ported across the restructured
surfaces: model flows now thread confirm_provider/base_url/api_key
through hermes_cli/model_setup_flows.py, the Discord picker lives in
plugins/platforms/discord/adapter.py, and the web dashboard picker
applies chat-mode switches via config.set so the expensive-model
confirmation can ride the response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Robin Fernandes
2026-06-10 00:24:06 -07:00
committed by Teknium
co-authored by Claude Fable 5
parent 4eadef18a9
commit af978ecb17
27 changed files with 1354 additions and 111 deletions
+3 -3
View File
@@ -133,7 +133,7 @@ def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
captured["access_token"] = access_token
return ["gpt-5.2-codex", "gpt-5.2"]
def _fake_prompt_model_selection(model_ids, current_model=""):
def _fake_prompt_model_selection(model_ids, current_model="", **_kwargs):
captured["model_ids"] = list(model_ids)
captured["current_model"] = current_model
return None
@@ -181,7 +181,7 @@ def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypa
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
lambda model_ids, current_model="", **_kwargs: None,
)
_model_flow_openai_codex({}, current_model="gpt-5.4")
@@ -219,7 +219,7 @@ def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
lambda model_ids, current_model="", **_kwargs: None,
)
monkeypatch.setattr(
"hermes_cli.auth._login_openai_codex",
+97
View File
@@ -0,0 +1,97 @@
from decimal import Decimal
from agent.models_dev import ModelInfo
from agent.usage_pricing import PricingEntry
from hermes_cli.model_cost_guard import expensive_model_warning
def test_no_warning_when_known_prices_are_at_threshold():
info = ModelInfo(
id="edge/model",
name="edge/model",
family="",
provider_id="test",
cost_input=20.0,
cost_output=100.0,
)
assert expensive_model_warning("edge/model", provider="test", model_info=info) is None
def test_warns_when_models_dev_input_price_exceeds_threshold():
info = ModelInfo(
id="expensive/input",
name="expensive/input",
family="",
provider_id="test",
cost_input=20.01,
cost_output=1.0,
)
warning = expensive_model_warning(
"expensive/input",
provider="test",
model_info=info,
)
assert warning is not None
assert warning.input_cost_per_million == Decimal("20.01")
assert "EXPENSIVE MODEL WARNING" in warning.message
assert "$20/M input" in warning.message
def test_warns_when_pricing_entry_output_price_exceeds_threshold(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.get_pricing_entry",
lambda *_args, **_kwargs: PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("100.01"),
source="provider_models_api",
),
)
warning = expensive_model_warning("provider/expensive-output", provider="openrouter")
assert warning is not None
assert warning.output_cost_per_million == Decimal("100.01")
assert "$100.01/M" in warning.message
def test_openai_gpt55_pro_adds_suggestion(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.get_pricing_entry",
lambda *_args, **_kwargs: PricingEntry(
input_cost_per_million=Decimal("25"),
output_cost_per_million=Decimal("125"),
source="provider_models_api",
),
)
warning = expensive_model_warning("openai/gpt-5.5-pro", provider="openrouter")
assert warning is not None
assert "did you mean to select openai/gpt-5.5?" in warning.message
def test_openai_gpt55_pro_warns_for_nous_portal_pricing(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.fetch_endpoint_model_metadata",
lambda base_url, api_key="": {
"openai/gpt-5.5-pro": {
"pricing": {
"prompt": "0.000025",
"completion": "0.000125",
}
}
},
)
warning = expensive_model_warning("openai/gpt-5.5-pro", provider="nous")
assert warning is not None
assert warning.input_cost_per_million == Decimal("25.000000")
assert warning.output_cost_per_million == Decimal("125.000000")
assert "did you mean to select openai/gpt-5.5?" in warning.message
@@ -0,0 +1,64 @@
from types import SimpleNamespace
from hermes_cli.model_switch import ModelSwitchResult
def _bound(fn, instance):
return fn.__get__(instance, type(instance))
def test_prompt_toolkit_model_picker_defers_confirmation_off_key_handler(monkeypatch):
import cli as cli_mod
result = ModelSwitchResult(
success=True,
new_model="openai/gpt-5.5-pro",
target_provider="nous",
)
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model",
lambda **_kwargs: result,
)
captured = {}
class _Thread:
def __init__(self, *, target, args, daemon):
captured["target"] = target
captured["args"] = args
captured["daemon"] = daemon
def start(self):
captured["started"] = True
monkeypatch.setattr(cli_mod.threading, "Thread", _Thread)
self_ = SimpleNamespace(
_app=object(),
_model_picker_state={
"stage": "model",
"provider_data": {"slug": "nous"},
"model_list": ["openai/gpt-5.5-pro"],
"selected": 0,
"user_provs": None,
"custom_provs": None,
},
provider="nous",
model="openai/gpt-5.5",
base_url="",
api_key="",
_restore_modal_input_snapshot=lambda: None,
_invalidate=lambda **_kwargs: None,
)
self_._close_model_picker = _bound(cli_mod.HermesCLI._close_model_picker, self_)
self_._confirm_and_apply_model_switch_result = (
lambda *_args: captured.setdefault("ran_inline", True)
)
_bound(cli_mod.HermesCLI._handle_model_picker_selection, self_)()
assert self_._model_picker_state is None
assert captured["started"] is True
assert captured["daemon"] is True
assert captured["args"] == (result, False)
assert "ran_inline" not in captured
@@ -2,6 +2,7 @@
cannot initialize (e.g. non-TTY, curses unavailable, terminal error)."""
import subprocess
from types import SimpleNamespace
from hermes_cli.config import load_config, save_config
@@ -24,6 +25,46 @@ def test_prompt_model_selection_falls_back_on_menu_runtime_error(monkeypatch):
assert selected == "model-b"
def test_prompt_model_selection_requires_expensive_confirmation(monkeypatch, capsys):
from hermes_cli.auth import _prompt_model_selection
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
responses = iter(["1", "n"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_model_selection(
["openai/gpt-5.5-pro"],
confirm_provider="nous",
)
out = capsys.readouterr().out
assert selected is None
assert "EXPENSIVE MODEL WARNING" in out
def test_prompt_model_selection_allows_confirmed_expensive_model(monkeypatch):
from hermes_cli.auth import _prompt_model_selection
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
responses = iter(["1", "y"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_model_selection(
["openai/gpt-5.5-pro"],
confirm_provider="nous",
)
assert selected == "openai/gpt-5.5-pro"
def test_prompt_reasoning_effort_falls_back_on_menu_runtime_error(monkeypatch):
from hermes_cli.main import _prompt_reasoning_effort_selection
+36
View File
@@ -4,6 +4,7 @@ import os
import json
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
import pytest
@@ -1069,6 +1070,41 @@ class TestWebServerEndpoints:
assert "GATEWAY_PROXY_URL" not in managed
assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS
def test_model_set_requires_confirmation_for_expensive_model(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "openai/gpt-5.5-pro",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert data["confirm_required"] is True
assert data["confirm_message"] == "EXPENSIVE MODEL WARNING"
confirmed = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "openai/gpt-5.5-pro",
"confirm_expensive_model": True,
},
)
assert confirmed.status_code == 200
assert confirmed.json()["ok"] is True
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value