mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(desktop): show model pricing + free/paid tier gating in GUI picker
The CLI `hermes model` picker shows per-model $/Mtok pricing and gates paid
models on free Nous accounts. The GUI picker showed bare model names. Bring it
to parity across both the model-picker dialog and onboarding confirm card.
Backend:
- inventory.build_models_payload gains a pricing=True flag → _apply_pricing
enriches each provider row with formatted per-model pricing
({input,output,cache,free}) via the same _format_price_per_mtok the CLI uses,
and for Nous adds free_tier + unavailable_models (paid models a free user
can't select) via check_nous_free_tier + partition_nous_models_by_tier.
Best-effort: any pricing/tier failure is swallowed and fails open (no gating).
- /api/model/options and TUI model.options now pass pricing=True so the
global picker and in-session picker both carry pricing.
Frontend:
- ModelOptionProvider gains pricing/free_tier/unavailable_models; new
ModelPricing type.
- model-picker dialog renders In/Out $/Mtok (or a Free pill) per model, a
Free tier/Pro badge on the Nous heading, and disables + grays unavailable
paid models for free users with a 'Pro models need a paid subscription' note.
- onboarding confirm card shows the chosen model's price + tier badge.
Tests: test_inventory_pricing covers price formatting, free-tier gating,
paid no-gating, providers without pricing, and swallowed failures.
This commit is contained in:
parent
8a9b4bb2c2
commit
3c04527e1e
7 changed files with 307 additions and 6 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ModelPickerDialog } from '@/components/model-picker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
|
|
@ -660,6 +662,18 @@ function ConfirmingModelPanel({
|
|||
// a familiar UI for users who'll see this picker again later.
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
// Pull pricing + tier for the just-picked default so the confirm card
|
||||
// shows the same $/Mtok + Free/Pro info the picker and CLI do.
|
||||
const options = useQuery({
|
||||
queryKey: ['onboarding-model-options', flow.providerSlug],
|
||||
queryFn: () => getGlobalModelOptions()
|
||||
})
|
||||
const providerRow = options.data?.providers?.find(
|
||||
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
|
||||
)
|
||||
const price = providerRow?.pricing?.[flow.currentModel]
|
||||
const freeTier = providerRow?.free_tier
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
|
|
@ -670,8 +684,25 @@ function ConfirmingModelPanel({
|
|||
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
|
||||
{freeTier === true && (
|
||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
Free tier
|
||||
</span>
|
||||
)}
|
||||
{freeTier === false && (
|
||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
|
||||
{price && (price.input || price.output) && (
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||||
{price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
|
||||
Change
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
||||
|
||||
import type { HermesGateway } from '../hermes'
|
||||
import { getGlobalModelOptions } from '../hermes'
|
||||
|
|
@ -164,6 +164,8 @@ function ModelResults({
|
|||
return null
|
||||
}
|
||||
|
||||
const unavailable = new Set(provider.unavailable_models ?? [])
|
||||
|
||||
return (
|
||||
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
|
||||
{provider.warning && (
|
||||
|
|
@ -175,22 +177,37 @@ function ModelResults({
|
|||
)}
|
||||
{models.map(model => {
|
||||
const isCurrent = model === currentModel && provider.slug === currentProvider
|
||||
const price = provider.pricing?.[model]
|
||||
const locked = unavailable.has(model)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(
|
||||
'pl-6 font-mono',
|
||||
'flex items-center gap-2 pl-6 font-mono',
|
||||
isCurrent &&
|
||||
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground'
|
||||
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground',
|
||||
locked && 'cursor-not-allowed opacity-45'
|
||||
)}
|
||||
disabled={locked}
|
||||
key={`${provider.slug}:${model}`}
|
||||
onSelect={() => onSelectModel(provider, model)}
|
||||
onSelect={() => {
|
||||
if (!locked) {
|
||||
onSelectModel(provider, model)
|
||||
}
|
||||
}}
|
||||
value={`${provider.name} ${provider.slug} ${model}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{model}</span>
|
||||
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>}
|
||||
<ModelPrice isCurrent={isCurrent} price={price} />
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{unavailable.size > 0 && (
|
||||
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
|
||||
Pro models need a paid Nous subscription.
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
|
|
@ -198,6 +215,39 @@ function ModelResults({
|
|||
)
|
||||
}
|
||||
|
||||
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
|
||||
// Renders nothing when pricing is unavailable for the model.
|
||||
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
|
||||
if (!price || (!price.input && !price.output)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (price.free) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide',
|
||||
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
Free
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-[0.66rem] tabular-nums',
|
||||
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
|
||||
)}
|
||||
title="Input / Output price per million tokens"
|
||||
>
|
||||
{price.input || '?'} / {price.output || '?'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingResults() {
|
||||
return (
|
||||
<CommandGroup heading={<Skeleton className="h-3 w-32" />}>
|
||||
|
|
@ -211,12 +261,25 @@ function LoadingResults() {
|
|||
}
|
||||
|
||||
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
|
||||
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
|
||||
const tierBadge =
|
||||
provider.free_tier === true ? (
|
||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
Free tier
|
||||
</span>
|
||||
) : provider.free_tier === false ? (
|
||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||
Pro
|
||||
</span>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{provider.name}</span>
|
||||
<span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground">
|
||||
{provider.slug} · {provider.total_models ?? provider.models?.length ?? 0}
|
||||
</span>
|
||||
{tierBadge}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,17 @@ export interface ModelInfoResponse {
|
|||
provider: string
|
||||
}
|
||||
|
||||
export interface ModelPricing {
|
||||
/** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */
|
||||
input: string
|
||||
/** Formatted $/Mtok output price. */
|
||||
output: string
|
||||
/** Formatted $/Mtok cached-input price, or null when the model has none. */
|
||||
cache: string | null
|
||||
/** True when the model costs nothing (free tier eligible). */
|
||||
free: boolean
|
||||
}
|
||||
|
||||
export interface ModelOptionProvider {
|
||||
is_current?: boolean
|
||||
models?: string[]
|
||||
|
|
@ -192,6 +203,13 @@ export interface ModelOptionProvider {
|
|||
slug: string
|
||||
total_models?: number
|
||||
warning?: string
|
||||
/** Per-model pricing keyed by model id (present when the picker requested
|
||||
* pricing and the provider supports live pricing). */
|
||||
pricing?: Record<string, ModelPricing>
|
||||
/** Nous only: whether the current account is on the free tier. */
|
||||
free_tier?: boolean
|
||||
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
|
||||
unavailable_models?: string[]
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ def build_models_payload(
|
|||
include_unconfigured: bool = False,
|
||||
picker_hints: bool = False,
|
||||
canonical_order: bool = False,
|
||||
pricing: bool = False,
|
||||
max_models: int = 50,
|
||||
) -> dict:
|
||||
"""Build the ``{providers, model, provider}`` shape every consumer
|
||||
|
|
@ -128,6 +129,11 @@ def build_models_payload(
|
|||
- ``canonical_order``: reorder canonical-slug rows to
|
||||
``CANONICAL_PROVIDERS`` declaration order; truly-custom rows go
|
||||
last (TUI display order).
|
||||
- ``pricing``: enrich each row with formatted per-model pricing and,
|
||||
for Nous, ``free_tier``/``unavailable_models`` so the GUI picker can
|
||||
show $/Mtok columns and gate paid models on free accounts —
|
||||
mirroring the ``hermes model`` CLI picker. Adds network calls
|
||||
(pricing fetch + Nous tier check); only set for interactive pickers.
|
||||
"""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
|
@ -146,6 +152,8 @@ def build_models_payload(
|
|||
_apply_picker_hints(rows)
|
||||
if canonical_order:
|
||||
rows = _reorder_canonical(rows)
|
||||
if pricing:
|
||||
_apply_pricing(rows)
|
||||
|
||||
return {
|
||||
"providers": rows,
|
||||
|
|
@ -238,3 +246,85 @@ def _reorder_canonical(rows: list[dict]) -> list[dict]:
|
|||
)
|
||||
extras = [r for r in rows if r["slug"] not in order]
|
||||
return canon + extras
|
||||
|
||||
|
||||
def _apply_pricing(rows: list[dict]) -> None:
|
||||
"""Enrich each provider row with per-model pricing + Nous tier gating.
|
||||
|
||||
Mutates ``rows`` in-place. For every row whose provider supports live
|
||||
pricing (openrouter / nous / novita) adds::
|
||||
|
||||
row["pricing"] = {model_id: {"input": "$3.00", "output": "$15.00",
|
||||
"cache": "$0.30" | None, "free": bool}}
|
||||
|
||||
For Nous additionally adds::
|
||||
|
||||
row["free_tier"] = bool # current account is free-tier
|
||||
row["unavailable_models"] = [...] # paid models a free user can't pick
|
||||
|
||||
Prices are pre-formatted via ``_format_price_per_mtok`` so the GUI just
|
||||
renders strings — identical formatting to the CLI picker. All failures
|
||||
are swallowed (best-effort): a row simply gets no ``pricing`` key.
|
||||
"""
|
||||
from hermes_cli.models import (
|
||||
_format_price_per_mtok,
|
||||
check_nous_free_tier,
|
||||
get_pricing_for_provider,
|
||||
partition_nous_models_by_tier,
|
||||
)
|
||||
|
||||
# Resolve Nous free-tier once (cached in models.py for the TTL window).
|
||||
nous_free_tier: Optional[bool] = None
|
||||
|
||||
for row in rows:
|
||||
slug = str(row.get("slug", "")).lower()
|
||||
models = row.get("models") or []
|
||||
if not models:
|
||||
continue
|
||||
try:
|
||||
raw_pricing = get_pricing_for_provider(slug) or {}
|
||||
except Exception:
|
||||
raw_pricing = {}
|
||||
if not raw_pricing:
|
||||
continue
|
||||
|
||||
formatted: dict[str, dict] = {}
|
||||
for mid in models:
|
||||
p = raw_pricing.get(mid)
|
||||
if not p:
|
||||
continue
|
||||
inp_raw = p.get("prompt", "")
|
||||
out_raw = p.get("completion", "")
|
||||
cache_raw = p.get("input_cache_read", "")
|
||||
inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else ""
|
||||
out = _format_price_per_mtok(out_raw) if out_raw != "" else ""
|
||||
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] = {
|
||||
"input": inp,
|
||||
"output": out,
|
||||
"cache": cache,
|
||||
"free": is_free,
|
||||
}
|
||||
|
||||
if formatted:
|
||||
row["pricing"] = formatted
|
||||
|
||||
if slug == "nous":
|
||||
try:
|
||||
if nous_free_tier is None:
|
||||
nous_free_tier = check_nous_free_tier(force_fresh=True)
|
||||
row["free_tier"] = bool(nous_free_tier)
|
||||
if nous_free_tier:
|
||||
_selectable, unavailable = partition_nous_models_by_tier(
|
||||
list(models), raw_pricing, free_tier=True
|
||||
)
|
||||
row["unavailable_models"] = unavailable
|
||||
else:
|
||||
row["unavailable_models"] = []
|
||||
except Exception:
|
||||
# Tier detection failed — fail open (no gating) so the user
|
||||
# is never blocked from picking a model.
|
||||
row["free_tier"] = False
|
||||
row["unavailable_models"] = []
|
||||
|
|
|
|||
|
|
@ -1311,7 +1311,7 @@ def get_model_options():
|
|||
try:
|
||||
from hermes_cli.inventory import build_models_payload, load_picker_context
|
||||
|
||||
return build_models_payload(load_picker_context(), max_models=50)
|
||||
return build_models_payload(load_picker_context(), max_models=50, pricing=True)
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/options failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to list model options")
|
||||
|
|
|
|||
98
tests/hermes_cli/test_inventory_pricing.py
Normal file
98
tests/hermes_cli/test_inventory_pricing.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Tests for inventory._apply_pricing — the pricing/tier enrichment that
|
||||
|
||||
feeds the desktop GUI model picker (and onboarding) so it can show $/Mtok
|
||||
columns + Free/Pro badges and gate paid models on free Nous accounts, the
|
||||
same way the `hermes model` CLI picker does.
|
||||
"""
|
||||
|
||||
import hermes_cli.inventory as inv
|
||||
import hermes_cli.models as models_mod
|
||||
|
||||
|
||||
def _patch_pricing(monkeypatch, *, free_tier, pricing, unavailable=None):
|
||||
monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda slug, **kw: pricing.get(slug, {}))
|
||||
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: free_tier)
|
||||
monkeypatch.setattr(
|
||||
models_mod, "partition_nous_models_by_tier",
|
||||
lambda ids, pr, free_tier: (
|
||||
[m for m in ids if m not in (unavailable or [])],
|
||||
list(unavailable or []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_apply_pricing_formats_per_model_prices(monkeypatch):
|
||||
"""Each model gets formatted input/output/cache + a free flag."""
|
||||
_patch_pricing(
|
||||
monkeypatch,
|
||||
free_tier=False,
|
||||
pricing={
|
||||
"openrouter": {
|
||||
"a/paid": {"prompt": "0.000003", "completion": "0.000015", "input_cache_read": "0.0000003"},
|
||||
"b/free": {"prompt": "0", "completion": "0"},
|
||||
}
|
||||
},
|
||||
)
|
||||
rows = [{"slug": "openrouter", "models": ["a/paid", "b/free"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
pricing = rows[0]["pricing"]
|
||||
assert pricing["a/paid"] == {"input": "$3.00", "output": "$15.00", "cache": "$0.30", "free": False}
|
||||
assert pricing["b/free"]["free"] is True
|
||||
assert pricing["b/free"]["input"] == "free"
|
||||
|
||||
|
||||
def test_apply_pricing_nous_free_tier_gates_paid_models(monkeypatch):
|
||||
"""A free-tier Nous account marks paid models unavailable and sets the flag."""
|
||||
_patch_pricing(
|
||||
monkeypatch,
|
||||
free_tier=True,
|
||||
pricing={
|
||||
"nous": {
|
||||
"free/model": {"prompt": "0", "completion": "0"},
|
||||
"paid/model": {"prompt": "0.000005", "completion": "0.00001"},
|
||||
}
|
||||
},
|
||||
unavailable=["paid/model"],
|
||||
)
|
||||
rows = [{"slug": "nous", "models": ["free/model", "paid/model"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
assert rows[0]["free_tier"] is True
|
||||
assert rows[0]["unavailable_models"] == ["paid/model"]
|
||||
assert rows[0]["pricing"]["free/model"]["free"] is True
|
||||
|
||||
|
||||
def test_apply_pricing_nous_paid_tier_no_gating(monkeypatch):
|
||||
"""A paid Nous account gates nothing."""
|
||||
_patch_pricing(
|
||||
monkeypatch,
|
||||
free_tier=False,
|
||||
pricing={"nous": {"x/model": {"prompt": "0.000001", "completion": "0.000002"}}},
|
||||
)
|
||||
rows = [{"slug": "nous", "models": ["x/model"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
assert rows[0]["free_tier"] is False
|
||||
assert rows[0]["unavailable_models"] == []
|
||||
|
||||
|
||||
def test_apply_pricing_skips_providers_without_pricing(monkeypatch):
|
||||
"""A provider with no live pricing simply gets no pricing key."""
|
||||
_patch_pricing(monkeypatch, free_tier=False, pricing={})
|
||||
rows = [{"slug": "anthropic", "models": ["claude-x"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
assert "pricing" not in rows[0]
|
||||
|
||||
|
||||
def test_apply_pricing_failure_is_swallowed(monkeypatch):
|
||||
"""A pricing fetch that raises must not break the whole payload."""
|
||||
def boom(slug, **kw):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
monkeypatch.setattr(models_mod, "get_pricing_for_provider", boom)
|
||||
rows = [{"slug": "openrouter", "models": ["a/b"]}]
|
||||
inv._apply_pricing(rows) # must not raise
|
||||
|
||||
assert "pricing" not in rows[0]
|
||||
|
|
@ -6425,6 +6425,7 @@ def _(rid, params: dict) -> dict:
|
|||
include_unconfigured=True,
|
||||
picker_hints=True,
|
||||
canonical_order=True,
|
||||
pricing=True,
|
||||
max_models=50,
|
||||
)
|
||||
return _ok(rid, payload)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue