From 0ee05d72f2b30385d4861fd8e30511c6d51100e4 Mon Sep 17 00:00:00 2001
From: rob-maron <132852777+rob-maron@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:47:06 -0400
Subject: [PATCH] Nous portal model pricing (#69579)
* nous portal model pricing
* update top message
---
apps/desktop/src/components/model-picker.tsx | 32 ++-
apps/desktop/src/i18n/en.ts | 3 +-
apps/desktop/src/i18n/ja.ts | 3 +-
apps/desktop/src/i18n/types.ts | 1 +
apps/desktop/src/i18n/zh-hant.ts | 3 +-
apps/desktop/src/i18n/zh.ts | 3 +-
apps/desktop/src/types/hermes.ts | 6 +
hermes_cli/auth.py | 122 +++++++--
hermes_cli/curses_ui.py | 148 +++++++++--
hermes_cli/inventory.py | 23 +-
hermes_cli/models.py | 149 ++++++++++-
tests/hermes_cli/test_inventory_pricing.py | 114 +++++++++
tests/hermes_cli/test_sale_pricing.py | 232 ++++++++++++++++++
.../test_setup_menu_curses_migration.py | 9 +-
14 files changed, 785 insertions(+), 63 deletions(-)
create mode 100644 tests/hermes_cli/test_sale_pricing.py
diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx
index c7e095e61ea1..33028073522d 100644
--- a/apps/desktop/src/components/model-picker.tsx
+++ b/apps/desktop/src/components/model-picker.tsx
@@ -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 (
- {price.input || '?'} / {price.output || '?'}
+ {onSale ? (
+
+ -{price.discount_percent}%
+
+ ) : null}
+
+ {price.input || '?'} / {price.output || '?'}
+
+ {onSale ? (
+
+ {copy.wasPrice} {price.was_input || '?'} / {price.was_output || '?'}
+
+ ) : null}
)
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 30d6015d00b2..79351a815174 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -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: {
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 552bcd2ebd9d..afa02b44bf8b 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -2074,7 +2074,8 @@ export const ja = defineLocale({
proNeedsSubscription: 'Pro モデルには有料の Nous サブスクリプションが必要です。',
free: '無料',
freeTier: '無料プラン',
- priceTitle: '100 万トークンあたりの入力/出力価格'
+ priceTitle: '100 万トークンあたりの入力/出力価格',
+ wasPrice: '旧価格'
},
modelVisibility: {
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 53f40cf60c01..4ba7fc84c38c 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1762,6 +1762,7 @@ export interface Translations {
free: string
freeTier: string
priceTitle: string
+ wasPrice: string
}
modelVisibility: {
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index e30b1e1a0472..e3c382a8ef59 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -2005,7 +2005,8 @@ export const zhHant = defineLocale({
proNeedsSubscription: 'Pro 模型需要付費 Nous 訂閱。',
free: '免費',
freeTier: '免費層',
- priceTitle: '每百萬 Token 的輸入/輸出價格'
+ priceTitle: '每百萬 Token 的輸入/輸出價格',
+ wasPrice: '原價'
},
modelVisibility: {
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 9785d5bdbd65..578a6faa7409 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2316,7 +2316,8 @@ export const zh: Translations = {
proNeedsSubscription: 'Pro 模型需要付费 Nous 订阅。',
free: '免费',
freeTier: '免费层',
- priceTitle: '每百万 token 的输入/输出价格'
+ priceTitle: '每百万 token 的输入/输出价格',
+ wasPrice: '原价'
},
modelVisibility: {
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index fc70e5d4db3a..4651a92b5dae 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -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 {
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index fdb9bb58b2ca..507dd08a0220 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -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 /Mtok"
- # Keep only the header portion for the description.
+ # menu_title is "Select default model:\n $/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)")
diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py
index acaa614b0673..09f5821541b6 100644
--- a/hermes_cli/curses_ui.py
+++ b/hermes_cli/curses_ui.py
@@ -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()
diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py
index cd9817bb5a1b..ff6b49ed505b 100644
--- a/hermes_cli/inventory.py
+++ b/hermes_cli/inventory.py
@@ -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
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index dbb14e05f684..81dfbecac920 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -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 {}
diff --git a/tests/hermes_cli/test_inventory_pricing.py b/tests/hermes_cli/test_inventory_pricing.py
index 39c099572963..61edb33a726d 100644
--- a/tests/hermes_cli/test_inventory_pricing.py
+++ b/tests/hermes_cli/test_inventory_pricing.py
@@ -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
diff --git a/tests/hermes_cli/test_sale_pricing.py b/tests/hermes_cli/test_sale_pricing.py
new file mode 100644
index 000000000000..4cb647639d5d
--- /dev/null
+++ b/tests/hermes_cli/test_sale_pricing.py
@@ -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"
diff --git a/tests/hermes_cli/test_setup_menu_curses_migration.py b/tests/hermes_cli/test_setup_menu_curses_migration.py
index 46b1515bf79e..eaf382272c52 100644
--- a/tests/hermes_cli/test_setup_menu_curses_migration.py
+++ b/tests/hermes_cli/test_setup_menu_curses_migration.py
@@ -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():