diff --git a/cli.py b/cli.py index 99e17b8363..66f00a1285 100644 --- a/cli.py +++ b/cli.py @@ -3722,6 +3722,7 @@ class HermesCLI: from hermes_cli.models import ( curated_models_for_provider, list_available_providers, normalize_provider, _PROVIDER_LABELS, + get_pricing_for_provider, format_model_pricing_table, ) from hermes_cli.auth import resolve_provider as _resolve_provider @@ -3755,7 +3756,13 @@ class HermesCLI: marker = " ← active" if is_active else "" print(f" [{p['id']}]{marker}") curated = curated_models_for_provider(p["id"]) - if curated: + # Fetch pricing for providers that support it (openrouter, nous) + pricing_map = get_pricing_for_provider(p["id"]) if p["id"] in ("openrouter", "nous") else {} + if curated and pricing_map: + cur_model = self.model if is_active else "" + for line in format_model_pricing_table(curated, pricing_map, current_model=cur_model): + print(line) + elif curated: for mid, desc in curated: current_marker = " ← current" if (is_active and mid == self.model) else "" print(f" {mid}{current_marker}") diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 94cc08f2a4..6fdaa0ff17 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2143,8 +2143,18 @@ def _reset_config_provider() -> Path: return config_path -def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Optional[str]: - """Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.""" +def _prompt_model_selection( + model_ids: List[str], + current_model: str = "", + pricing: Optional[Dict[str, Dict[str, str]]] = None, +) -> Optional[str]: + """Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None. + + If *pricing* is provided (``{model_id: {prompt, completion}}``), a compact + price indicator is shown next to each model in aligned columns. + """ + from hermes_cli.models import _format_price_per_mtok + # Reorder: current model first, then the rest (deduplicated) ordered = [] if current_model and current_model in model_ids: @@ -2153,15 +2163,44 @@ def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Op if mid not in ordered: ordered.append(mid) - # Build display labels with marker on current + # Column-aligned labels when pricing is available + has_pricing = bool(pricing and any(pricing.get(m) for m in ordered)) + name_col = max((len(m) for m in ordered), default=0) + 2 if has_pricing else 0 + + # Pre-compute formatted prices and dynamic column width + _price_cache: dict[str, tuple[str, str]] = {} + price_col = 3 # minimum width + if has_pricing: + for mid in ordered: + p = pricing.get(mid) # type: ignore[union-attr] + if p: + inp = _format_price_per_mtok(p.get("prompt", "")) + out = _format_price_per_mtok(p.get("completion", "")) + else: + inp, out = "", "" + _price_cache[mid] = (inp, out) + price_col = max(price_col, len(inp), len(out)) + def _label(mid): + if has_pricing: + inp, out = _price_cache.get(mid, ("", "")) + price_part = f" {inp:>{price_col}} {out:>{price_col}}" + base = f"{mid:<{name_col}}{price_part}" + else: + base = mid if mid == current_model: - return f"{mid} ← currently in use" - return mid + base += " ← currently in use" + return base # Default cursor on the current model (index 0 if it was reordered to top) default_idx = 0 + # Build a pricing header hint for the menu title + menu_title = "Select default model:" + if has_pricing: + # Align the header with the model column + menu_title += f"\n {'':>{name_col}} {'In':>{price_col}} {'Out':>{price_col}} /Mtok" + # Try arrow-key menu first, fall back to number input try: from simple_term_menu import TerminalMenu @@ -2176,7 +2215,7 @@ def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Op menu_highlight_style=("fg_green",), cycle_cursor=True, clear_screen=False, - title="Select default model:", + title=menu_title, ) idx = menu.show() if idx is None: @@ -2192,12 +2231,13 @@ def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Op pass # Fallback: numbered list - print("Select default model:") + print(menu_title) + num_width = len(str(len(ordered) + 2)) for i, mid in enumerate(ordered, 1): - print(f" {i}. {_label(mid)}") + print(f" {i:>{num_width}}. {_label(mid)}") n = len(ordered) - print(f" {n + 1}. Enter custom model name") - print(f" {n + 2}. Skip (keep current)") + print(f" {n + 1:>{num_width}}. Enter custom model name") + print(f" {n + 2:>{num_width}}. Skip (keep current)") print() while True: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fb0cf0a85a..159e77138d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1088,10 +1088,13 @@ def _model_flow_openrouter(config, current_model=""): print("API key saved.") print() - from hermes_cli.models import model_ids + from hermes_cli.models import model_ids, get_pricing_for_provider openrouter_models = model_ids() - selected = _prompt_model_selection(openrouter_models, current_model=current_model) + # Fetch live pricing (non-blocking — returns empty dict on failure) + pricing = get_pricing_for_provider("openrouter") + + selected = _prompt_model_selection(openrouter_models, current_model=current_model, pricing=pricing) if selected: _save_model_choice(selected) @@ -1158,7 +1161,7 @@ def _model_flow_nous(config, current_model="", args=None): # Already logged in — use curated model list (same as OpenRouter defaults). # The live /models endpoint returns hundreds of models; the curated list # shows only agentic models users recognize from OpenRouter. - from hermes_cli.models import _PROVIDER_MODELS + from hermes_cli.models import _PROVIDER_MODELS, get_pricing_for_provider model_ids = _PROVIDER_MODELS.get("nous", []) if not model_ids: print("No curated models available for Nous Portal.") @@ -1188,7 +1191,10 @@ def _model_flow_nous(config, current_model="", args=None): print(f"Could not verify credentials: {msg}") return - selected = _prompt_model_selection(model_ids, current_model=current_model) + # Fetch live pricing (non-blocking — returns empty dict on failure) + pricing = get_pricing_for_provider("nous") + + selected = _prompt_model_selection(model_ids, current_model=current_model, pricing=pricing) if selected: _save_model_choice(selected) # Reactivate Nous as the provider and update config diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 74db2f3ae8..72423cfcae 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -327,6 +327,187 @@ def menu_labels() -> list[str]: return labels +# --------------------------------------------------------------------------- +# Pricing helpers — fetch live pricing from OpenRouter-compatible /v1/models +# --------------------------------------------------------------------------- + +# Cache: maps model_id → {"prompt": str, "completion": str} per endpoint +_pricing_cache: dict[str, dict[str, dict[str, str]]] = {} + + +def _format_price_per_mtok(per_token_str: str) -> str: + """Convert a per-token price string to a human-friendly $/Mtok string. + + Always uses 2 decimal places so that prices align vertically when + right-justified in a column (the decimal point stays in the same position). + + Examples: + "0.000003" → "$3.00" (per million tokens) + "0.00003" → "$30.00" + "0.00000015" → "$0.15" + "0.0000001" → "$0.10" + "0.00018" → "$180.00" + "0" → "free" + """ + try: + val = float(per_token_str) + except (TypeError, ValueError): + return "?" + if val == 0: + return "free" + per_m = val * 1_000_000 + return f"${per_m:.2f}" + + +def format_pricing_label(pricing: dict[str, str] | None) -> str: + """Build a compact pricing label like '$3/$15' (input/output per Mtok). + + Returns empty string when pricing is unavailable. + """ + if not pricing: + return "" + prompt_price = pricing.get("prompt", "") + completion_price = pricing.get("completion", "") + if not prompt_price and not completion_price: + return "" + inp = _format_price_per_mtok(prompt_price) + out = _format_price_per_mtok(completion_price) + if inp == "free" and out == "free": + return "free" + if inp == out: + return f"{inp}/Mtok" + return f"in {inp} · out {out}/Mtok" + + +def format_model_pricing_table( + models: list[tuple[str, str]], + pricing_map: dict[str, dict[str, str]], + current_model: str = "", + indent: str = " ", +) -> list[str]: + """Build a column-aligned model+pricing table for terminal display. + + Returns a list of pre-formatted lines ready to print. + *models* is ``[(model_id, description), ...]``. + """ + if not models: + return [] + + # Build rows: (model_id, input_price, output_price, is_current) + rows: list[tuple[str, str, str, bool]] = [] + for mid, _desc in models: + is_cur = mid == current_model + p = pricing_map.get(mid) + if p: + inp = _format_price_per_mtok(p.get("prompt", "")) + out = _format_price_per_mtok(p.get("completion", "")) + else: + inp, out = "", "" + rows.append((mid, inp, out, is_cur)) + + name_col = max(len(r[0]) for r in rows) + 2 + # Compute price column widths from the actual data so decimals align + price_col = max( + max((len(r[1]) for r in rows if r[1]), default=4), + max((len(r[2]) for r in rows if r[2]), default=4), + 3, # minimum: "In" / "Out" header + ) + lines: list[str] = [] + + # Header + lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} /Mtok") + lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col}") + + for mid, inp, out, is_cur in rows: + marker = " ← current" if is_cur else "" + lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}}{marker}") + + return lines + + +def fetch_models_with_pricing( + api_key: str | None = None, + base_url: str = "https://openrouter.ai/api", + timeout: float = 8.0, + *, + force_refresh: bool = False, +) -> dict[str, dict[str, str]]: + """Fetch ``/v1/models`` and return ``{model_id: {prompt, completion}}`` pricing. + + Results are cached per *base_url* so repeated calls are free. + Works with any OpenRouter-compatible endpoint (OpenRouter, Nous Portal). + """ + cache_key = (base_url or "").rstrip("/") + if not force_refresh and cache_key in _pricing_cache: + return _pricing_cache[cache_key] + + url = cache_key.rstrip("/") + "/v1/models" + headers: dict[str, str] = {"Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode()) + except Exception: + _pricing_cache[cache_key] = {} + return {} + + result: dict[str, dict[str, str]] = {} + for item in payload.get("data", []): + mid = item.get("id") + pricing = item.get("pricing") + if mid and isinstance(pricing, dict): + result[mid] = { + "prompt": str(pricing.get("prompt", "")), + "completion": str(pricing.get("completion", "")), + } + + _pricing_cache[cache_key] = result + return result + + +def _resolve_openrouter_api_key() -> str: + """Best-effort OpenRouter API key for pricing fetch.""" + return os.getenv("OPENROUTER_API_KEY", "").strip() + + +def _resolve_nous_pricing_credentials() -> tuple[str, str]: + """Return ``(api_key, base_url)`` for Nous Portal pricing, or empty strings.""" + try: + from hermes_cli.auth import resolve_nous_runtime_credentials + creds = resolve_nous_runtime_credentials() + if creds: + return (creds.get("api_key", ""), creds.get("base_url", "")) + except Exception: + pass + return ("", "") + + +def get_pricing_for_provider(provider: str) -> dict[str, dict[str, str]]: + """Return live pricing for providers that support it (openrouter, nous).""" + normalized = normalize_provider(provider) + if normalized == "openrouter": + return fetch_models_with_pricing( + api_key=_resolve_openrouter_api_key(), + base_url="https://openrouter.ai/api", + ) + if normalized == "nous": + api_key, base_url = _resolve_nous_pricing_credentials() + if base_url: + # Nous base_url typically looks like https://inference-api.nousresearch.com/v1 + # We need the part before /v1 for our fetch function + stripped = base_url.rstrip("/") + if stripped.endswith("/v1"): + stripped = stripped[:-3] + return fetch_models_with_pricing( + api_key=api_key, + base_url=stripped, + ) + return {} + + # All provider IDs and aliases that are valid for the provider:model syntax. _KNOWN_PROVIDER_NAMES: set[str] = ( set(_PROVIDER_LABELS.keys()) diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 370d22d849..53e4850276 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -330,7 +330,7 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_ "hermes_cli.auth.fetch_nous_models", lambda *args, **kwargs: ["claude-opus-4-6"], ) - monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6") + monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None: "claude-opus-4-6") monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None) monkeypatch.setattr( @@ -368,7 +368,7 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat "hermes_cli.auth.fetch_nous_models", lambda *args, **kwargs: ["claude-opus-4-6"], ) - monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6") + monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None: "claude-opus-4-6") monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None) monkeypatch.setattr(