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:
committed by
Teknium
co-authored by
Claude Fable 5
parent
4eadef18a9
commit
af978ecb17
+56
-4
@@ -6175,6 +6175,40 @@ def _reset_config_provider() -> Path:
|
||||
return config_path
|
||||
|
||||
|
||||
def _confirm_expensive_model_selection(
|
||||
model_id: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
) -> bool:
|
||||
"""Prompt before saving a model whose known pricing exceeds guardrails."""
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
warning = expensive_model_warning(
|
||||
model_id,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
if warning is None:
|
||||
return True
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print(warning.message)
|
||||
print("=" * 72)
|
||||
try:
|
||||
response = input("Switch anyway? [y/N]: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return False
|
||||
return response in {"y", "yes"}
|
||||
|
||||
|
||||
def _prompt_model_selection(
|
||||
model_ids: List[str],
|
||||
current_model: str = "",
|
||||
@@ -6182,6 +6216,9 @@ def _prompt_model_selection(
|
||||
unavailable_models: Optional[List[str]] = None,
|
||||
portal_url: str = "",
|
||||
unavailable_message: str = "",
|
||||
confirm_provider: str = "",
|
||||
confirm_base_url: str = "",
|
||||
confirm_api_key: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
|
||||
|
||||
@@ -6195,6 +6232,18 @@ def _prompt_model_selection(
|
||||
|
||||
_unavailable = unavailable_models or []
|
||||
|
||||
def _confirmed_selection(mid: str) -> Optional[str]:
|
||||
if not mid:
|
||||
return None
|
||||
if confirm_provider and not _confirm_expensive_model_selection(
|
||||
mid,
|
||||
provider=confirm_provider,
|
||||
base_url=confirm_base_url,
|
||||
api_key=confirm_api_key,
|
||||
):
|
||||
return None
|
||||
return mid
|
||||
|
||||
# Reorder: current model first, then the rest (deduplicated)
|
||||
ordered = []
|
||||
if current_model and current_model in model_ids:
|
||||
@@ -6310,13 +6359,13 @@ def _prompt_model_selection(
|
||||
return None
|
||||
print()
|
||||
if idx < len(ordered):
|
||||
return ordered[idx]
|
||||
return _confirmed_selection(ordered[idx])
|
||||
elif idx == len(ordered):
|
||||
try:
|
||||
custom = input("Enter model name: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return custom if custom else None
|
||||
return _confirmed_selection(custom) if custom else None
|
||||
return None
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
@@ -6348,10 +6397,10 @@ def _prompt_model_selection(
|
||||
return None
|
||||
idx = int(choice)
|
||||
if 1 <= idx <= n:
|
||||
return ordered[idx - 1]
|
||||
return _confirmed_selection(ordered[idx - 1])
|
||||
elif idx == n + 1:
|
||||
custom = input("Enter model name: ").strip()
|
||||
return custom if custom else None
|
||||
return _confirmed_selection(custom) if custom else None
|
||||
elif idx == n + 2:
|
||||
return None
|
||||
print(f"Please enter 1-{n + 2}")
|
||||
@@ -7730,6 +7779,9 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_portal,
|
||||
unavailable_message=unavailable_message,
|
||||
confirm_provider="nous",
|
||||
confirm_base_url=inference_base_url,
|
||||
confirm_api_key=runtime_key,
|
||||
)
|
||||
elif unavailable_models:
|
||||
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
|
||||
@@ -3269,6 +3269,7 @@ def _aux_flow_provider_model(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
pricing=pricing,
|
||||
confirm_provider=provider_slug,
|
||||
)
|
||||
if selected is None:
|
||||
print("No change.")
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Expensive-model confirmation helpers for model selection surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Optional
|
||||
|
||||
from agent.models_dev import ModelInfo
|
||||
|
||||
|
||||
INPUT_COST_WARNING_THRESHOLD = Decimal("20")
|
||||
OUTPUT_COST_WARNING_THRESHOLD = Decimal("100")
|
||||
GPT55_PRO_OPENROUTER_ID = "openai/gpt-5.5-pro"
|
||||
GPT55_SUGGESTION = "did you mean to select openai/gpt-5.5?"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpensiveModelWarning:
|
||||
"""Confirmation payload for models above Hermes' cost guardrail."""
|
||||
|
||||
model: str
|
||||
provider: str
|
||||
input_cost_per_million: Optional[Decimal]
|
||||
output_cost_per_million: Optional[Decimal]
|
||||
source: str
|
||||
message: str
|
||||
|
||||
|
||||
def _to_decimal(value: object) -> Optional[Decimal]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_money(value: Optional[Decimal]) -> str:
|
||||
if value is None:
|
||||
return "unknown"
|
||||
return f"${value:.2f}/M"
|
||||
|
||||
|
||||
def _pricing_from_model_info(
|
||||
model_info: Optional[ModelInfo],
|
||||
) -> tuple[Optional[Decimal], Optional[Decimal], str]:
|
||||
if model_info is None or not model_info.has_cost_data():
|
||||
return None, None, ""
|
||||
return (
|
||||
_to_decimal(model_info.cost_input),
|
||||
_to_decimal(model_info.cost_output),
|
||||
"models.dev",
|
||||
)
|
||||
|
||||
|
||||
def expensive_model_warning(
|
||||
model_name: str,
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
model_info: Optional[ModelInfo] = None,
|
||||
) -> Optional[ExpensiveModelWarning]:
|
||||
"""Return a warning payload when known pricing exceeds safety thresholds.
|
||||
|
||||
The guard only triggers when pricing is known. Callers should use this after
|
||||
model resolution so aliases and provider-specific model IDs have settled.
|
||||
"""
|
||||
model = (model_name or "").strip()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
input_cost, output_cost, source = _pricing_from_model_info(model_info)
|
||||
if input_cost is None and output_cost is None and provider:
|
||||
try:
|
||||
from agent.models_dev import get_model_info
|
||||
|
||||
input_cost, output_cost, source = _pricing_from_model_info(
|
||||
get_model_info(provider, model)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if input_cost is None and output_cost is None:
|
||||
try:
|
||||
from agent.usage_pricing import get_pricing_entry
|
||||
|
||||
entry = get_pricing_entry(
|
||||
model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
except Exception:
|
||||
entry = None
|
||||
if entry is not None:
|
||||
input_cost = entry.input_cost_per_million
|
||||
output_cost = entry.output_cost_per_million
|
||||
source = entry.source
|
||||
|
||||
over_input = (
|
||||
input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD
|
||||
)
|
||||
over_output = (
|
||||
output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD
|
||||
)
|
||||
if not over_input and not over_output:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
"!!! EXPENSIVE MODEL WARNING !!!",
|
||||
"",
|
||||
f"{model} has known pricing above Hermes' safety threshold.",
|
||||
f"Input tokens: {_format_money(input_cost)}",
|
||||
f"Output tokens: {_format_money(output_cost)}",
|
||||
(
|
||||
"Threshold: more than $20/M input tokens or more than "
|
||||
"$100/M output tokens."
|
||||
),
|
||||
]
|
||||
if source:
|
||||
lines.append(f"Pricing source: {source}.")
|
||||
if model.lower() == GPT55_PRO_OPENROUTER_ID:
|
||||
lines.append(GPT55_SUGGESTION)
|
||||
lines.append("Confirm only if you intend to use this model.")
|
||||
|
||||
return ExpensiveModelWarning(
|
||||
model=model,
|
||||
provider=(provider or "").strip(),
|
||||
input_cost_per_million=input_cost,
|
||||
output_cost_per_million=output_cost,
|
||||
source=source or "unknown",
|
||||
message="\n".join(lines),
|
||||
)
|
||||
@@ -102,7 +102,12 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
pricing = get_pricing_for_provider("openrouter", force_refresh=True)
|
||||
|
||||
selected = _prompt_model_selection(
|
||||
openrouter_models, current_model=current_model, pricing=pricing
|
||||
openrouter_models,
|
||||
current_model=current_model,
|
||||
pricing=pricing,
|
||||
confirm_provider="openrouter",
|
||||
confirm_base_url=OPENROUTER_BASE_URL,
|
||||
confirm_api_key=_resolved or existing_key,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
@@ -311,6 +316,9 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_nous_portal_url,
|
||||
unavailable_message=unavailable_message,
|
||||
confirm_provider="nous",
|
||||
confirm_base_url=creds.get("base_url", ""),
|
||||
confirm_api_key=creds.get("api_key", ""),
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
@@ -416,7 +424,13 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
|
||||
codex_models = get_codex_model_ids(access_token=_codex_token)
|
||||
|
||||
selected = _prompt_model_selection(codex_models, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
codex_models,
|
||||
current_model=current_model,
|
||||
confirm_provider="openai-codex",
|
||||
confirm_base_url=DEFAULT_CODEX_BASE_URL,
|
||||
confirm_api_key=_codex_token or "",
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
||||
@@ -546,7 +560,12 @@ def _model_flow_qwen_oauth(_config, current_model=""):
|
||||
models = list(_DEFAULT_QWEN_PORTAL_MODELS)
|
||||
|
||||
default = current_model or (models[0] if models else "qwen3-coder-plus")
|
||||
selected = _prompt_model_selection(models, current_model=default)
|
||||
selected = _prompt_model_selection(
|
||||
models,
|
||||
current_model=default,
|
||||
confirm_provider="qwen-oauth",
|
||||
confirm_base_url=DEFAULT_QWEN_BASE_URL,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL)
|
||||
@@ -595,7 +614,12 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
|
||||
selected = _prompt_model_selection(model_ids, current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_ids,
|
||||
current_model,
|
||||
confirm_provider="minimax-oauth",
|
||||
confirm_base_url=creds["base_url"],
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
_save_model_choice(selected)
|
||||
@@ -664,7 +688,12 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
|
||||
|
||||
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
|
||||
default = current_model or (models[0] if models else "gemini-3-flash-preview")
|
||||
selected = _prompt_model_selection(models, current_model=default)
|
||||
selected = _prompt_model_selection(
|
||||
models,
|
||||
current_model=default,
|
||||
confirm_provider="google-gemini-cli",
|
||||
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider(
|
||||
@@ -1589,7 +1618,11 @@ def _model_flow_copilot(config, current_model=""):
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(
|
||||
model_list, current_model=normalized_current_model
|
||||
model_list,
|
||||
current_model=normalized_current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=api_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -1727,6 +1760,9 @@ def _model_flow_copilot_acp(config, current_model=""):
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=normalized_current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=catalog_api_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -1831,7 +1867,13 @@ def _model_flow_kimi(config, current_model=""):
|
||||
model_list = _PROVIDER_MODELS.get("moonshot", [])
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Enter model name: ").strip()
|
||||
@@ -1939,7 +1981,13 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
)
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name: ").strip()
|
||||
@@ -2015,7 +2063,13 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
|
||||
print(f" Showing {len(model_list)} curated models")
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="custom",
|
||||
confirm_base_url=mantle_base_url,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
@@ -2204,7 +2258,12 @@ def _model_flow_bedrock(config, current_model=""):
|
||||
|
||||
# 4. Model selection
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="bedrock",
|
||||
confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
@@ -2488,7 +2547,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
model_list = list(dict.fromkeys(mid for mid in model_list if mid))
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name: ").strip()
|
||||
@@ -2638,7 +2703,11 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
# Model selection
|
||||
model_list = _PROVIDER_MODELS.get("anthropic", [])
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="anthropic",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip()
|
||||
|
||||
+22
-15
@@ -657,21 +657,6 @@ class AudioTranscriptionRequest(BaseModel):
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
scope="main" → writes model.provider + model.default
|
||||
scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
|
||||
scope="auxiliary" with task="" → applied to every auxiliary.* slot
|
||||
scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
|
||||
"""
|
||||
|
||||
scope: str
|
||||
provider: str
|
||||
model: str
|
||||
task: str = ""
|
||||
|
||||
|
||||
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
@@ -713,6 +698,7 @@ class ModelAssignment(BaseModel):
|
||||
# reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is
|
||||
# the path that actually wires a local endpoint into resolution.
|
||||
base_url: str = ""
|
||||
confirm_expensive_model: bool = False
|
||||
|
||||
|
||||
def _apply_main_model_assignment(
|
||||
@@ -2470,6 +2456,27 @@ async def set_model_assignment(body: ModelAssignment):
|
||||
try:
|
||||
cfg = load_config()
|
||||
|
||||
if model and not body.confirm_expensive_model:
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
||||
warning = expensive_model_warning(
|
||||
model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception:
|
||||
warning = None
|
||||
if warning is not None:
|
||||
return {
|
||||
"ok": False,
|
||||
"scope": scope,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"confirm_required": True,
|
||||
"confirm_message": warning.message,
|
||||
}
|
||||
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
|
||||
Reference in New Issue
Block a user