mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Nous portal model pricing (#69579)
* nous portal model pricing * update top message
This commit is contained in:
parent
681abcc897
commit
0ee05d72f2
14 changed files with 785 additions and 63 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -2074,7 +2074,8 @@ export const ja = defineLocale({
|
|||
proNeedsSubscription: 'Pro モデルには有料の Nous サブスクリプションが必要です。',
|
||||
free: '無料',
|
||||
freeTier: '無料プラン',
|
||||
priceTitle: '100 万トークンあたりの入力/出力価格'
|
||||
priceTitle: '100 万トークンあたりの入力/出力価格',
|
||||
wasPrice: '旧価格'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
|
|
|||
|
|
@ -1762,6 +1762,7 @@ export interface Translations {
|
|||
free: string
|
||||
freeTier: string
|
||||
priceTitle: string
|
||||
wasPrice: string
|
||||
}
|
||||
|
||||
modelVisibility: {
|
||||
|
|
|
|||
|
|
@ -2005,7 +2005,8 @@ export const zhHant = defineLocale({
|
|||
proNeedsSubscription: 'Pro 模型需要付費 Nous 訂閱。',
|
||||
free: '免費',
|
||||
freeTier: '免費層',
|
||||
priceTitle: '每百萬 Token 的輸入/輸出價格'
|
||||
priceTitle: '每百萬 Token 的輸入/輸出價格',
|
||||
wasPrice: '原價'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
|
|
|||
|
|
@ -2316,7 +2316,8 @@ export const zh: Translations = {
|
|||
proNeedsSubscription: 'Pro 模型需要付费 Nous 订阅。',
|
||||
free: '免费',
|
||||
freeTier: '免费层',
|
||||
priceTitle: '每百万 token 的输入/输出价格'
|
||||
priceTitle: '每百万 token 的输入/输出价格',
|
||||
wasPrice: '原价'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
232
tests/hermes_cli/test_sale_pricing.py
Normal file
232
tests/hermes_cli/test_sale_pricing.py
Normal 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"
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue