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():