Nous portal model pricing (#69579)

* nous portal model pricing

* update top message
This commit is contained in:
rob-maron 2026-07-22 16:47:06 -04:00 committed by GitHub
parent 681abcc897
commit 0ee05d72f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 785 additions and 63 deletions

View file

@ -268,15 +268,43 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
)
}
const onSale =
typeof price.discount_percent === 'number' &&
Boolean(price.was_input || price.was_output)
return (
<span
className={cn(
'shrink-0 text-[0.66rem] tabular-nums',
'shrink-0 inline-flex items-center gap-1.5 text-[0.66rem] tabular-nums',
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
)}
title={copy.priceTitle}
>
{price.input || '?'} / {price.output || '?'}
{onSale ? (
<span
className={cn(
'rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold',
isCurrent
? 'bg-primary-foreground/20'
: 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
)}
>
-{price.discount_percent}%
</span>
) : null}
<span>
{price.input || '?'} / {price.output || '?'}
</span>
{onSale ? (
<span
className={cn(
'line-through decoration-from-font opacity-70',
isCurrent ? 'text-primary-foreground/60' : 'text-muted-foreground/80'
)}
>
{copy.wasPrice} {price.was_input || '?'} / {price.was_output || '?'}
</span>
) : null}
</span>
)
}

View file

@ -2138,7 +2138,8 @@ export const en: Translations = {
proNeedsSubscription: 'Pro models need a paid Nous subscription.',
free: 'Free',
freeTier: 'Free tier',
priceTitle: 'Input / Output price per million tokens'
priceTitle: 'Input / Output price per million tokens',
wasPrice: 'was'
},
modelVisibility: {

View file

@ -2074,7 +2074,8 @@ export const ja = defineLocale({
proNeedsSubscription: 'Pro モデルには有料の Nous サブスクリプションが必要です。',
free: '無料',
freeTier: '無料プラン',
priceTitle: '100 万トークンあたりの入力/出力価格'
priceTitle: '100 万トークンあたりの入力/出力価格',
wasPrice: '旧価格'
},
modelVisibility: {

View file

@ -1762,6 +1762,7 @@ export interface Translations {
free: string
freeTier: string
priceTitle: string
wasPrice: string
}
modelVisibility: {

View file

@ -2005,7 +2005,8 @@ export const zhHant = defineLocale({
proNeedsSubscription: 'Pro 模型需要付費 Nous 訂閱。',
free: '免費',
freeTier: '免費層',
priceTitle: '每百萬 Token 的輸入/輸出價格'
priceTitle: '每百萬 Token 的輸入/輸出價格',
wasPrice: '原價'
},
modelVisibility: {

View file

@ -2316,7 +2316,8 @@ export const zh: Translations = {
proNeedsSubscription: 'Pro 模型需要付费 Nous 订阅。',
free: '免费',
freeTier: '免费层',
priceTitle: '每百万 token 的输入/输出价格'
priceTitle: '每百万 token 的输入/输出价格',
wasPrice: '原价'
},
modelVisibility: {

View file

@ -295,6 +295,12 @@ export interface ModelPricing {
cache: string | null
/** True when the model costs nothing (free tier eligible). */
free: boolean
/** Sale: rounded percent off list when gateway sends pricing.original. */
discount_percent?: number
/** Sale: formatted pre-discount input $/Mtok ("was"). */
was_input?: string
/** Sale: formatted pre-discount output $/Mtok ("was"). */
was_output?: string
}
export interface ModelOptionProvider {

View file

@ -7034,9 +7034,15 @@ def _prompt_model_selection(
If *unavailable_models* is provided, those models are shown grayed out
and unselectable, with an upgrade link to *portal_url*.
"""
from hermes_cli.models import _format_price_per_mtok
from hermes_cli.models import (
_format_price_per_mtok,
compute_sale_discount,
)
_unavailable = unavailable_models or []
# Sale chrome (★ / -N% / was) is Nous Portal-only — never for OpenRouter
# or other providers even if pricing.original is somehow present.
sale_chrome = (confirm_provider or "").strip().lower() == "nous"
def _confirmed_selection(mid: str) -> Optional[str]:
if not mid:
@ -7063,16 +7069,31 @@ def _prompt_model_selection(
# Column-aligned labels when pricing is available
has_pricing = bool(pricing and any(pricing.get(m) for m in all_models))
name_col = max((len(m) for m in all_models), default=0) + 2 if has_pricing else 0
# Leave room for a leading "★ " on sale rows (Nous only).
name_pad = 3 if sale_chrome else 2
name_col = (
max((len(m) for m in all_models), default=0) + name_pad
if has_pricing
else 0
)
# Pre-compute formatted prices and dynamic column widths
_price_cache: dict[str, tuple[str, str, str]] = {}
# Pre-compute formatted prices and sale chrome.
# (inp, out, cache, pct|None, was_inp, was_out)
# Sale chrome is drawn as curses/ANSI segments (yellow % / dim "was"),
# not baked into a single plain string — curses addnstr would otherwise
# render escape bytes literally.
_price_cache: dict[str, tuple[str, str, str, int | None, str, str]] = {}
price_col = 3 # minimum width
cache_col = 0 # only set if any model has cache pricing
has_cache = False
any_on_sale = False
_DIM = "\033[2m"
_RESET = "\033[0m"
if has_pricing:
for mid in all_models:
p = pricing.get(mid) # type: ignore[union-attr]
pct: int | None = None
was_inp = was_out = ""
if p:
inp = _format_price_per_mtok(p.get("prompt", ""))
out = _format_price_per_mtok(p.get("completion", ""))
@ -7080,26 +7101,68 @@ def _prompt_model_selection(
cache = _format_price_per_mtok(cache_read) if cache_read else ""
if cache:
has_cache = True
if sale_chrome:
sale = compute_sale_discount(
p.get("prompt", ""),
p.get("completion", ""),
p.get("original"),
)
if sale is not None:
any_on_sale = True
pct, was_prompt_raw, was_out_raw = sale
was_inp = (
_format_price_per_mtok(was_prompt_raw)
if was_prompt_raw != ""
else "?"
)
was_out = (
_format_price_per_mtok(was_out_raw)
if was_out_raw != ""
else "?"
)
else:
inp, out, cache = "", "", ""
_price_cache[mid] = (inp, out, cache)
_price_cache[mid] = (inp, out, cache, pct, was_inp, was_out)
price_col = max(price_col, len(inp), len(out))
cache_col = max(cache_col, len(cache))
if has_cache:
cache_col = max(cache_col, 5) # minimum: "Cache" header
def _label(mid):
if has_pricing:
inp, out, cache = _price_cache.get(mid, ("", "", ""))
price_part = f" {inp:>{price_col}} {out:>{price_col}}"
if has_cache:
price_part += f" {cache:>{cache_col}}"
base = f"{mid:<{name_col}}{price_part}"
def _label_segments(mid):
"""Build a rich radiolist row: yellow ★/% , dim was, plain prices."""
if not has_pricing:
segs: list[tuple[str, str | None]] = [(mid, None)]
if mid == current_model:
segs.append((" ← currently in use", None))
return segs
inp, out, cache, pct, was_inp, was_out = _price_cache.get(
mid, ("", "", "", None, "", "")
)
on_sale = pct is not None
# Reserve 2 columns for "★ " so sale and non-sale names share alignment.
star_w = 2
if on_sale:
name_segs: list[tuple[str, str | None]] = [
("", "yellow"),
(f"{mid:<{name_col - star_w}}", None),
]
else:
base = mid
name_segs = [(f"{mid:<{name_col}}", None)]
price_part = f" {inp:>{price_col}} {out:>{price_col}}"
if has_cache:
price_part += f" {cache:>{cache_col}}"
segs = [*name_segs, (price_part, None)]
if on_sale:
segs.append((f" -{pct}%", "yellow"))
segs.append((f" was {was_inp}/{was_out}", "dim"))
if mid == current_model:
base += " ← currently in use"
return base
segs.append((" ← currently in use", None))
return segs
def _label(mid):
return "".join(text for text, _style in _label_segments(mid))
# Default cursor on the current model (index 0 if it was reordered to top)
default_idx = 0
@ -7114,11 +7177,11 @@ def _prompt_model_selection(
header = f"\n{pad}{'':>{name_col}} {'In':>{price_col}} {'Out':>{price_col}}"
if has_cache:
header += f" {'Cache':>{cache_col}}"
menu_title += header + " /Mtok"
# ANSI escape for dim text
_DIM = "\033[2m"
_RESET = "\033[0m"
# Legend lives on the column-header line so it reads as a key
# (★ = on sale), not a fake menu row.
menu_title += header + " $/Mtok"
if any_on_sale:
menu_title += " ★ = on sale"
# Try arrow-key menu first, fall back to number input.
# Uses the shared curses radiolist (ESC/arrow-key handling that works
@ -7128,7 +7191,7 @@ def _prompt_model_selection(
try:
from hermes_cli.curses_ui import curses_radiolist
choices = [_label(mid) for mid in ordered]
choices = [_label_segments(mid) for mid in ordered]
choices.append("Enter custom model name")
choices.append("Skip (keep current)")
@ -7142,8 +7205,8 @@ def _prompt_model_selection(
# screen clear. menu_title already embeds the aligned price header.
desc_lines: list[str] = []
if has_pricing:
# menu_title is "Select default model:\n<pad><header> /Mtok"
# Keep only the header portion for the description.
# menu_title is "Select default model:\n<pad><header> $/Mtok\n…"
# Keep only the header/legend portion for the description.
header_part = menu_title.split("\n", 1)
if len(header_part) > 1:
desc_lines.extend(header_part[1].splitlines())
@ -7176,11 +7239,18 @@ def _prompt_model_selection(
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass
# Fallback: numbered list
print(menu_title)
# Fallback: numbered list (ANSI colors for sale chrome)
from hermes_cli.curses_ui import format_radio_item_ansi
from hermes_cli.colors import Colors, color
for line in menu_title.splitlines():
if "" in line:
print(line.replace("", color("", Colors.YELLOW), 1))
else:
print(line)
num_width = len(str(len(ordered) + 2))
for i, mid in enumerate(ordered, 1):
print(f" {i:>{num_width}}. {_label(mid)}")
print(f" {i:>{num_width}}. {format_radio_item_ansi(_label_segments(mid))}")
n = len(ordered)
print(f" {n + 1:>{num_width}}. Enter custom model name")
print(f" {n + 2:>{num_width}}. Skip (keep current)")

View file

@ -6,10 +6,106 @@ text-based numbered fallback for terminals without curses support.
"""
import sys
from dataclasses import dataclass
from typing import Callable, List, Optional, Set
from typing import Callable, List, Optional, Sequence, Set, Tuple, Union
from hermes_cli.colors import Colors, color
# Rich radiolist rows: (text, style). style is None | "yellow" | "dim".
# Plain ``str`` items remain fully supported.
RadioItem = Union[str, Sequence[Tuple[str, Optional[str]]]]
def radio_item_plain(item: RadioItem) -> str:
"""Flatten a radiolist item to searchable/plain display text."""
if isinstance(item, str):
return item
return "".join(text for text, _style in item)
def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool):
"""Map a segment style to a curses attribute.
Cursor rows force the whole line green (pair 1) so selection stays
one solid highlight; unselected sale chrome uses yellow / dim.
"""
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
return attr
if style == "yellow" and curses.has_colors():
return curses.color_pair(2)
if style == "dim":
attr = curses.A_DIM
if curses.has_colors():
# Pair 3 is the dim-gray status color (extra_color_pairs).
try:
attr |= curses.color_pair(3)
except curses.error:
pass
return attr
return curses.A_NORMAL
def _draw_description_line(stdscr, y: int, text: str, max_x: int) -> None:
"""Draw a description line, highlighting ★ in yellow when colors exist."""
import curses
col = 0
i = 0
star_attr = curses.A_NORMAL
if curses.has_colors():
star_attr = curses.color_pair(2)
while i < len(text) and col < max_x - 1:
remaining = max_x - 1 - col
if remaining <= 0:
break
if text[i] == "":
try:
stdscr.addnstr(y, col, "", remaining, star_attr)
except curses.error:
pass
col += 1
i += 1
continue
next_star = text.find("", i)
chunk = text[i:] if next_star < 0 else text[i:next_star]
chunk = chunk[:remaining]
try:
stdscr.addnstr(y, col, chunk, remaining, curses.A_NORMAL)
except curses.error:
pass
col += len(chunk)
i += len(chunk)
def _draw_radio_item(stdscr, y: int, x: int, item: RadioItem, max_x: int, *, is_cursor: bool) -> None:
"""Draw a plain or segmented radiolist item starting at column ``x``."""
import curses
if isinstance(item, str):
attr = _curses_style_attr(curses, None, is_cursor=is_cursor)
try:
stdscr.addnstr(y, x, item, max(0, max_x - 1 - x), attr)
except curses.error:
pass
return
col = x
for text, style in item:
if col >= max_x - 1:
break
remaining = max_x - 1 - col
if remaining <= 0:
break
chunk = text[:remaining]
attr = _curses_style_attr(curses, style, is_cursor=is_cursor)
try:
stdscr.addnstr(y, col, chunk, remaining, attr)
except curses.error:
pass
col += len(chunk)
def _query_matches(label: str, query: str) -> bool:
"""Return True when every query token is a case-insensitive subsequence."""
@ -621,7 +717,7 @@ def curses_checklist(
def curses_radiolist(
title: str,
items: List[str],
items: List[RadioItem],
selected: int = 0,
*,
cancel_returns: int | None = None,
@ -632,7 +728,11 @@ def curses_radiolist(
Args:
title: Header line displayed above the list.
items: Display labels for each row.
items: Display labels for each row. Each entry is either a plain
string or a sequence of ``(text, style)`` segments where
``style`` is ``None``, ``"yellow"``, or ``"dim"``. Cursor rows
force the whole line green; unselected rows honor segment styles
(used for sale chrome in the model picker).
selected: Index that starts selected (pre-selected).
cancel_returns: Returned on ESC/q. Defaults to the original *selected*.
description: Optional multi-line text shown between the title and
@ -649,6 +749,8 @@ def curses_radiolist(
if description:
desc_lines = description.splitlines()
plain_labels = [radio_item_plain(item) for item in items]
def _draw_header(stdscr, max_y, max_x, search=None):
import curses
row = 0
@ -659,11 +761,11 @@ def curses_radiolist(
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
row += 1
# Description lines
# Description lines — paint ★ yellow so the sale legend matches rows.
for dline in desc_lines:
if row >= max_y - 1:
break
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
_draw_description_line(stdscr, row, dline, max_x)
row += 1
if searchable and search is not None and search.active:
@ -683,16 +785,15 @@ def curses_radiolist(
import curses
radio = "\u25cf" if i == selected else "\u25cb"
arrow = "\u2192" if is_cursor else " "
line = f" {arrow} ({radio}) {items[i]}"
attr = curses.A_NORMAL
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
prefix = f" {arrow} ({radio}) "
prefix_attr = _curses_style_attr(curses, None, is_cursor=is_cursor)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
stdscr.addnstr(y, 0, prefix, max_x - 1, prefix_attr)
except curses.error:
pass
_draw_radio_item(
stdscr, y, len(prefix), items[i], max_x, is_cursor=is_cursor
)
def _on_action(action, cursor):
if action in (NAV_SELECT, NAV_TOGGLE):
@ -706,16 +807,33 @@ def curses_radiolist(
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=1,
# Dim gray (pair 3) for unselected "was …" sale chrome.
extra_color_pairs=True,
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
cancel_value=cancel_returns,
searchable=searchable,
search_labels=list(items) if searchable else None,
search_labels=plain_labels if searchable else None,
)
def format_radio_item_ansi(item: RadioItem) -> str:
"""Apply ANSI colors to a rich radiolist item (numbered fallback / prints)."""
if isinstance(item, str):
return item
parts: list[str] = []
for text, style in item:
if style == "yellow":
parts.append(color(text, Colors.YELLOW))
elif style == "dim":
parts.append(color(text, Colors.DIM))
else:
parts.append(text)
return "".join(parts)
def _radio_numbered_fallback(
title: str,
items: List[str],
items: List[RadioItem],
selected: int,
cancel_returns: int,
) -> int:
@ -725,7 +843,7 @@ def _radio_numbered_fallback(
for i, label in enumerate(items):
marker = color("(\u25cf)", Colors.GREEN) if i == selected else "(\u25cb)"
print(f" {marker} {i + 1:>2}. {label}")
print(f" {marker} {i + 1:>2}. {format_radio_item_ansi(label)}")
print()
try:
val = input(color(f" Choice [default {selected + 1}]: ", Colors.DIM)).strip()

View file

@ -537,6 +537,7 @@ def _apply_pricing(
from hermes_cli.models import (
_format_price_per_mtok,
check_nous_free_tier,
compute_sale_discount,
get_pricing_for_provider,
partition_nous_models_by_tier,
)
@ -569,12 +570,32 @@ def _apply_pricing(
cache = _format_price_per_mtok(cache_raw) if cache_raw else None
# A model is "free" when both input and output cost nothing.
is_free = inp == "free" and (out == "free" or out == "")
formatted[mid] = {
entry: dict = {
"input": inp,
"output": out,
"cache": cache,
"free": is_free,
}
# Sale chrome is Nous Portal-only. Other providers (OpenRouter,
# Novita, …) never get discount_percent / was_* even if a nested
# pricing.original somehow appeared in their catalog. Free / $0
# models never get sale chrome either — even if original leaked.
if slug == "nous" and not is_free:
sale = compute_sale_discount(
inp_raw, out_raw, p.get("original")
)
if sale is not None:
discount_percent, was_prompt_raw, was_out_raw = sale
entry["discount_percent"] = discount_percent
if was_prompt_raw != "":
entry["was_input"] = _format_price_per_mtok(
was_prompt_raw
)
if was_out_raw != "":
entry["was_output"] = _format_price_per_mtok(
was_out_raw
)
formatted[mid] = entry
if formatted:
row["pricing"] = formatted

View file

@ -1573,23 +1573,107 @@ def _format_price_per_mtok(per_token_str: str) -> str:
return f"${per_m:.2f}"
def compute_sale_discount(
prompt: str,
completion: str,
original: Any,
) -> tuple[int, str, str] | None:
"""Derive sale chrome from gateway ``pricing.original`` when cheaper.
Nous Portal-only feature: callers gate on the provider; this helper only
sees ``original`` because the Nous fetch path opted in via
``include_sale_original=True``.
Returns ``(discount_percent, was_prompt_raw, was_completion_raw)`` only when
``original`` is a dict and the current prompt (fallback: completion) rate
is strictly below the corresponding original. Percent is
``round((1 - current/original) * 100)`` never hardcoded, and a discount
that rounds below 1% is treated as no sale (never render "-0%"). Returns
``None`` when there is no sale (missing/equal/invalid original), so UIs
show normal prices.
"""
if not isinstance(original, dict):
return None
was_prompt = original.get("prompt")
was_completion = original.get("completion")
if was_prompt in (None, "") and was_completion in (None, ""):
return None
def _finite(raw: Any) -> float | None:
try:
n = float(raw)
except (TypeError, ValueError):
return None
return n if n > 0 and n == n else None # n == n rejects NaN
def _nonneg(raw: Any) -> float | None:
try:
n = float(raw)
except (TypeError, ValueError):
return None
return n if n >= 0 and n == n else None
# Free / $0 models never show sale chrome, even if a leftover list price
# is higher (e.g. a :free sibling that inherited pricing.original).
cur_prompt_any = _nonneg(prompt) if prompt not in (None, "") else None
cur_comp_any = _nonneg(completion) if completion not in (None, "") else None
if cur_prompt_any == 0 and cur_comp_any == 0:
return None
cur_prompt = _finite(prompt) if prompt not in (None, "") else None
orig_prompt = _finite(was_prompt) if was_prompt not in (None, "") else None
if cur_prompt is not None and orig_prompt is not None and cur_prompt < orig_prompt:
pct = int(round((1.0 - (cur_prompt / orig_prompt)) * 100))
if pct < 1:
return None
return (
pct,
str(was_prompt),
str(was_completion) if was_completion not in (None, "") else "",
)
cur_comp = _finite(completion) if completion not in (None, "") else None
orig_comp = _finite(was_completion) if was_completion not in (None, "") else None
if cur_comp is not None and orig_comp is not None and cur_comp < orig_comp:
pct = int(round((1.0 - (cur_comp / orig_comp)) * 100))
if pct < 1:
return None
return (
pct,
str(was_prompt) if was_prompt not in (None, "") else "",
str(was_completion),
)
return None
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.
include_sale_original: bool = False,
) -> dict[str, dict[str, Any]]:
"""Fetch ``/v1/models`` and return ``{model_id: {prompt, completion, ...}}``.
Results are cached per *base_url* so repeated calls are free.
Works with any OpenRouter-compatible endpoint (OpenRouter, Nous Portal).
When *include_sale_original* is true (Nous Portal only) and the gateway
advertises a global discount under ``pricing.original``, those
pre-discount rates are copied through as a nested ``original`` dict so
pickers can show sale chrome. Other providers never opt in OpenRouter
(and anything else sharing this helper) keeps the legacy
``{prompt, completion}`` shape even if a response happens to nest
``original``.
"""
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"
url = cache_key + "/v1/models"
headers: dict[str, str] = {
"Accept": "application/json",
"User-Agent": _HERMES_USER_AGENT,
@ -1605,12 +1689,12 @@ def fetch_models_with_pricing(
_pricing_cache[cache_key] = {}
return {}
result: dict[str, dict[str, str]] = {}
result: dict[str, dict[str, Any]] = {}
for item in payload.get("data", []):
mid = item.get("id")
pricing = item.get("pricing")
if mid and isinstance(pricing, dict):
entry: dict[str, str] = {
entry: dict[str, Any] = {
"prompt": str(pricing.get("prompt", "")),
"completion": str(pricing.get("completion", "")),
}
@ -1618,6 +1702,22 @@ def fetch_models_with_pricing(
entry["input_cache_read"] = str(pricing["input_cache_read"])
if pricing.get("input_cache_write"):
entry["input_cache_write"] = str(pricing["input_cache_write"])
# Sale chrome is Nous Portal-only. Never copy pricing.original for
# OpenRouter / other OpenAI-compatible catalogs.
if include_sale_original:
original = pricing.get("original")
if isinstance(original, dict):
orig_entry: dict[str, str] = {}
for key in (
"prompt",
"completion",
"input_cache_read",
"input_cache_write",
):
if original.get(key) not in (None, ""):
orig_entry[key] = str(original[key])
if orig_entry.get("prompt") or orig_entry.get("completion"):
entry["original"] = orig_entry
result[mid] = entry
_pricing_cache[cache_key] = result
@ -1638,20 +1738,43 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
The Nous inference ``/v1/models`` endpoint exposes pricing without
authentication, so the api_key is best-effort: when runtime credential
resolution fails (expired refresh token, missing auth.json, etc.) we
still return the default inference base URL so the picker keeps
working with anonymous pricing data. Free-tier users in particular
need this pricing drives the free/paid partition, and silently
returning empty pricing because of an auth blip makes the picker
look broken ("No free models currently available").
still return a usable inference base URL so the picker keeps working
with anonymous pricing data. Free-tier users in particular need this
pricing drives the free/paid partition, and silently returning empty
pricing because of an auth blip makes the picker look broken ("No free
models currently available").
Base URL precedence (mirrors runtime credential resolution):
1. ``NOUS_INFERENCE_BASE_URL`` env override (staging / preview)
2. Resolved runtime credential ``base_url``
3. Production default
Without (1), a staging profile's sale ``pricing.original`` never
reaches the pickers the anonymous fallback would hit prod, which
has no ``original`` field.
"""
env_base = None
try:
from hermes_cli.auth import _nous_inference_env_override
env_base = _nous_inference_env_override()
except Exception:
env_base = None
api_key = ""
creds_base = ""
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", ""))
api_key = creds.get("api_key", "") or ""
creds_base = (creds.get("base_url", "") or "").strip()
except Exception:
pass
return ("", _DEFAULT_NOUS_INFERENCE_BASE)
base_url = (env_base or creds_base or _DEFAULT_NOUS_INFERENCE_BASE).rstrip("/")
return (api_key, base_url)
def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
@ -1681,6 +1804,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d
api_key=api_key,
base_url=stripped,
force_refresh=force_refresh,
# Sale chrome (pricing.original) is Nous Portal-only.
include_sale_original=True,
)
return {}

View file

@ -96,3 +96,117 @@ def test_apply_pricing_failure_is_swallowed(monkeypatch):
inv._apply_pricing(rows) # must not raise
assert "pricing" not in rows[0]
def test_apply_pricing_emits_sale_fields_when_original_cheaper(monkeypatch):
"""Gateway pricing.original → was_* + discount_percent for Desktop."""
_patch_pricing(
monkeypatch,
free_tier=False,
pricing={
"nous": {
"a/sale": {
"prompt": "0.0000016",
"completion": "0.000008",
"original": {
"prompt": "0.000002",
"completion": "0.00001",
},
},
"b/normal": {
"prompt": "0.000003",
"completion": "0.000015",
},
}
},
)
rows = [{"slug": "nous", "models": ["a/sale", "b/normal"]}]
inv._apply_pricing(rows)
sale = rows[0]["pricing"]["a/sale"]
assert sale["input"] == "$1.60"
assert sale["output"] == "$8.00"
assert sale["discount_percent"] == 20
assert sale["was_input"] == "$2.00"
assert sale["was_output"] == "$10.00"
normal = rows[0]["pricing"]["b/normal"]
assert "discount_percent" not in normal
assert "was_input" not in normal
assert "was_output" not in normal
def test_apply_pricing_omits_sale_for_free_models_even_with_original(monkeypatch):
"""Free models must not get was_*/discount_percent even if original leaked."""
_patch_pricing(
monkeypatch,
free_tier=False,
pricing={
"nous": {
"a/free": {
"prompt": "0",
"completion": "0",
"original": {
"prompt": "0.000002",
"completion": "0.00001",
},
},
}
},
)
rows = [{"slug": "nous", "models": ["a/free"]}]
inv._apply_pricing(rows)
free = rows[0]["pricing"]["a/free"]
assert free["free"] is True
assert "discount_percent" not in free
assert "was_input" not in free
assert "was_output" not in free
def test_apply_pricing_omits_sale_when_original_not_cheaper(monkeypatch):
_patch_pricing(
monkeypatch,
free_tier=False,
pricing={
"nous": {
"a/eq": {
"prompt": "0.000002",
"completion": "0.00001",
"original": {
"prompt": "0.000002",
"completion": "0.00001",
},
},
}
},
)
rows = [{"slug": "nous", "models": ["a/eq"]}]
inv._apply_pricing(rows)
assert "discount_percent" not in rows[0]["pricing"]["a/eq"]
def test_apply_pricing_sale_chrome_nous_only(monkeypatch):
"""OpenRouter (and other non-nous slugs) must never emit sale fields."""
_patch_pricing(
monkeypatch,
free_tier=False,
pricing={
"openrouter": {
"a/sale": {
"prompt": "0.0000016",
"completion": "0.000008",
"original": {
"prompt": "0.000002",
"completion": "0.00001",
},
},
}
},
)
rows = [{"slug": "openrouter", "models": ["a/sale"]}]
inv._apply_pricing(rows)
entry = rows[0]["pricing"]["a/sale"]
assert entry["input"] == "$1.60"
assert "discount_percent" not in entry
assert "was_input" not in entry
assert "was_output" not in entry

View file

@ -0,0 +1,232 @@
"""Sale UI pricing helpers: gateway pricing.original → discount chrome."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import hermes_cli.models as models_mod
from hermes_cli.models import (
compute_sale_discount,
fetch_models_with_pricing,
)
def test_compute_sale_discount_from_prompt():
sale = compute_sale_discount(
"0.0000016000",
"0.0000080000",
{"prompt": "0.0000020000", "completion": "0.0000100000"},
)
assert sale is not None
pct, was_prompt, was_completion = sale
assert pct == 20
assert was_prompt == "0.0000020000"
assert was_completion == "0.0000100000"
def test_compute_sale_discount_omits_when_no_original():
assert compute_sale_discount("0.0000016", "0.000008", None) is None
assert compute_sale_discount("0.0000016", "0.000008", "not-a-dict") is None
def test_compute_sale_discount_omits_for_free_models_even_with_higher_original():
"""Free / $0 models must never show sale chrome against a list price."""
assert (
compute_sale_discount(
"0",
"0",
{"prompt": "0.000002", "completion": "0.00001"},
)
is None
)
def test_compute_sale_discount_omits_sub_one_percent_discounts():
"""A discount that rounds below 1% must not render as '-0%'."""
assert (
compute_sale_discount(
"0.0000099999",
"0.00005",
{"prompt": "0.00001", "completion": "0.00005"},
)
is None
)
def test_compute_sale_discount_omits_when_not_cheaper():
assert (
compute_sale_discount(
"0.000002",
"0.00001",
{"prompt": "0.000002", "completion": "0.00001"},
)
is None
)
def test_compute_sale_discount_falls_back_to_completion():
sale = compute_sale_discount(
"",
"0.000008",
{"prompt": "", "completion": "0.00001"},
)
assert sale is not None
assert sale[0] == 20
def test_fetch_models_with_pricing_copies_nested_original(monkeypatch):
models_mod._pricing_cache.clear()
payload = {
"data": [
{
"id": "anthropic/claude-sonnet-5",
"pricing": {
"prompt": "0.0000016",
"completion": "0.000008",
"input_cache_read": "0.00000016",
"original": {
"prompt": "0.000002",
"completion": "0.00001",
"input_cache_read": "0.0000002",
},
},
},
{
"id": "free/model",
"pricing": {"prompt": "0", "completion": "0"},
},
]
}
body = json.dumps(payload).encode()
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda self: self
resp.__exit__ = lambda *a: False
monkeypatch.setattr(
models_mod,
"_urlopen_model_catalog_request",
lambda req, timeout=8.0: resp,
)
# Nous Portal opts in via include_sale_original=True.
result = fetch_models_with_pricing(
api_key="sk-test",
base_url="https://example.test",
force_refresh=True,
include_sale_original=True,
)
paid = result["anthropic/claude-sonnet-5"]
assert paid["prompt"] == "0.0000016"
assert paid["completion"] == "0.000008"
assert paid["original"] == {
"prompt": "0.000002",
"completion": "0.00001",
"input_cache_read": "0.0000002",
}
assert "original" not in result["free/model"]
def test_fetch_models_with_pricing_omits_original_when_absent(monkeypatch):
models_mod._pricing_cache.clear()
payload = {
"data": [
{
"id": "x/y",
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
}
]
}
body = json.dumps(payload).encode()
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda self: self
resp.__exit__ = lambda *a: False
monkeypatch.setattr(
models_mod,
"_urlopen_model_catalog_request",
lambda req, timeout=8.0: resp,
)
result = fetch_models_with_pricing(
base_url="https://example.test/no-sale",
force_refresh=True,
include_sale_original=True,
)
assert "original" not in result["x/y"]
def test_fetch_models_with_pricing_ignores_original_unless_opted_in(monkeypatch):
"""OpenRouter / default path must never surface pricing.original."""
models_mod._pricing_cache.clear()
payload = {
"data": [
{
"id": "anthropic/claude-sonnet-5",
"pricing": {
"prompt": "0.0000016",
"completion": "0.000008",
"original": {
"prompt": "0.000002",
"completion": "0.00001",
},
},
}
]
}
body = json.dumps(payload).encode()
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda self: self
resp.__exit__ = lambda *a: False
monkeypatch.setattr(
models_mod,
"_urlopen_model_catalog_request",
lambda req, timeout=8.0: resp,
)
# Default (OpenRouter path): strip original even when the payload has it.
result = fetch_models_with_pricing(
base_url="https://openrouter.ai/api",
force_refresh=True,
)
assert "original" not in result["anthropic/claude-sonnet-5"]
assert result["anthropic/claude-sonnet-5"]["prompt"] == "0.0000016"
def test_resolve_nous_pricing_credentials_honors_inference_env_override(monkeypatch):
"""Staging profiles set NOUS_INFERENCE_BASE_URL — pricing must follow it.
Without this, anonymous/failed-auth fallback hits prod and sale
``pricing.original`` never reaches Desktop/CLI pickers.
"""
monkeypatch.setenv(
"NOUS_INFERENCE_BASE_URL",
"https://stg-inference-api.nousresearch.com/v1",
)
# Auth resolution fails / returns nothing — the env override must still win.
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda: None,
)
api_key, base_url = models_mod._resolve_nous_pricing_credentials()
assert api_key == ""
assert base_url == "https://stg-inference-api.nousresearch.com/v1"
def test_resolve_nous_pricing_credentials_env_wins_over_stored_prod(monkeypatch):
monkeypatch.setenv(
"NOUS_INFERENCE_BASE_URL",
"https://stg-inference-api.nousresearch.com/v1",
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda: {
"api_key": "ak-test",
"base_url": "https://inference-api.nousresearch.com/v1",
},
)
api_key, base_url = models_mod._resolve_nous_pricing_credentials()
assert api_key == "ak-test"
assert base_url == "https://stg-inference-api.nousresearch.com/v1"

View file

@ -10,6 +10,7 @@ from unittest.mock import patch
def test_prompt_model_selection_uses_curses_radiolist():
from hermes_cli.auth import _prompt_model_selection
from hermes_cli.curses_ui import radio_item_plain
seen = {}
@ -24,9 +25,11 @@ def test_prompt_model_selection_uses_curses_radiolist():
assert result == "model-b"
assert seen["title"] == "Select default model:"
# Items are the models plus the custom/skip entries.
assert seen["items"][:2] == ["model-a", "model-b"]
assert "Skip (keep current)" in seen["items"]
# Items are the models plus the custom/skip entries. Model rows may be
# rich (text, style) segments for sale chrome — compare the plain text.
plain = [radio_item_plain(item) for item in seen["items"]]
assert plain[:2] == ["model-a", "model-b"]
assert "Skip (keep current)" in plain
def test_prompt_model_selection_esc_cancels():