* feat(desktop): free-tier state over RPC, status routes that name it, and a sign-in that keeps connectors The desktop learns about the Nous free tier by reading local auth state (pull): free_tier.status answers has_guest / enabled / carries_inference / notice_pending with zero network, and free_tier.ack_notice persists the one-time notice flag on the identity itself. setup.runtime_check reports free_tier for the selected route; /api/portal, the Nous card in /api/providers/oauth and billing.state carry free_tier (billing answers the free tier locally instead of a portal call that can only fail). The free-tier picker row carries an explicit free_tier_row flag and is never priced or locked. POST /api/providers/oauth/nous/start over a free-tier identity registers the connector transfer and returns its code and consent URL; the poller waits for the transfer before the token grant, persists the account, runs settle_after_upgrade, and the poll response gains reason, account_email and model. * feat(desktop): free tier on Hermes Desktop: ready screen, notice strip, status chip, Billing view, one sign-in dialog The renderer reads the free tier from free_tier.status (pull) into one store; the first-launch intro is the same state rendered two ways, keyed on the backend's one-time flag: the onboarding overlay opens on a ready screen when the free tier carries inference, else a one-time strip above the composer. Settings > Billing gains a free_tier view (notice with one Sign in, Plan / Model / Connectors summary, plan card, footnote; no payment or usage rows). A status-bar chip names the tier and model while it carries inference. Every entry point opens one claimed sign-in dialog that drives the extended oauth/nous route and maps the poll's status and reason to the ruled screens; Done settles billing, model options, providers and re-homes a session still on nous/welcome. The picker badge also fires on free_tier_row. Docs: Desktop section in the free-tier guide, AGENTS notes. * fix(desktop): free_tier.status starts the free tier's background setup when no identity exists A served backend has no session-setup moment like the CLI's, so beside an explicit provider the free tier was never set up on the desktop: no connectors, no notice strip. The first status read now starts the same one-attempt background setup; the call itself never waits. * fix(desktop): one Sign in on the Billing page; Settings > Providers names the free tier, never Connected The free-tier plan card is the what-you-get text alone (the notice carries the page's one Sign in). The Nous provider row reads Nous · free tier with a Free tier tag while the identity is the free tier, instead of Nous Portal · Connected. * fix(desktop): Settings > Providers never files the free tier under Connected * fix(desktop): the intro's shape is keyed on the route, not on the identity free_tier.status reports available (an identity exists and the tier is on); whether inference runs on the free tier is setup.runtime_check.free_tier, keyed on the resolved endpoint. The ready screen shows when that route is the free tier; the composer strip when the user's own provider carries inference. An own-key install used to get the ready screen. * docs(desktop): say what the free-tier chip is keyed on * fix(desktop): the featured Nous row's pitch on the free tier says what signing in adds * fix(desktop): a cancelled or superseded sign-in attempt can no longer change the identity or hide the intro Four lifecycle holes from review. The Nous poller checks the session's cancelled flag after the transfer wait, after the token grant, and once more under the session lock together with the save, so a sign-in the user abandoned never persists. The renderer's sign-in store carries an attempt generation that every continuation checks after each await, so a poll from a closed attempt cannot publish over the one on screen (and its backend session is cancelled). The ready screen comes down only after the backend recorded the acknowledgement. A composer still mounted takes over the notice claim when its owner unmounts. One thin test per hole.
750 lines
36 KiB
Python
750 lines
36 KiB
Python
"""Provider/model inventory — shared substrate for the dashboard ``/api/model/options``, the TUI
|
|
``model.options``/``model.save_key`` RPC handlers, and the interactive picker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextvars import copy_context
|
|
from dataclasses import dataclass, replace
|
|
from threading import Lock, Thread, current_thread
|
|
from typing import Any, Optional
|
|
|
|
_pricing_prewarm_lock = Lock()
|
|
_pricing_prewarm_threads: dict[tuple[str, tuple[tuple[str, str], ...]], Thread] = {}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConfigContext:
|
|
"""Disk-config snapshot (``load_picker_context()``); the TUI overlays live agent state via
|
|
``with_overrides()``."""
|
|
|
|
current_provider: str
|
|
current_model: str
|
|
current_base_url: str
|
|
user_providers: dict
|
|
custom_providers: list
|
|
excluded_providers: list = None
|
|
|
|
def with_overrides(
|
|
self, *, current_provider: Optional[str] = None, current_model: Optional[str] = None,
|
|
current_base_url: Optional[str] = None,
|
|
) -> "ConfigContext":
|
|
"""Copy with TRUTHY overrides applied: the TUI reads agent attributes that may be empty strings
|
|
before an agent is spawned — empties must not clobber the disk-config values."""
|
|
overrides = (("current_provider", current_provider), ("current_model", current_model),
|
|
("current_base_url", current_base_url))
|
|
kw = {k: v for k, v in overrides if v}
|
|
return replace(self, **kw) if kw else self
|
|
|
|
|
|
def load_picker_context() -> ConfigContext:
|
|
"""Load the disk-config snapshot every consumer needs."""
|
|
from hermes_cli.config import (
|
|
coerce_provider_id, get_compatible_custom_providers, load_config, stringify_provider_map,
|
|
)
|
|
cfg = load_config()
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict):
|
|
# PyYAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options
|
|
# paths never call `.strip()` on an int.
|
|
current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "")
|
|
current_provider = coerce_provider_id(model_cfg.get("provider", ""))
|
|
current_base_url = str(model_cfg.get("base_url", "") or "")
|
|
else: # config.model can be a bare string in older configs
|
|
current_model, current_provider, current_base_url = (str(model_cfg) if model_cfg else ""), "", ""
|
|
excluded = cfg.get("model_catalog", {}).get("excluded_providers") or []
|
|
return ConfigContext(
|
|
current_provider=current_provider, current_model=current_model, current_base_url=current_base_url,
|
|
user_providers=stringify_provider_map(cfg.get("providers")),
|
|
custom_providers=get_compatible_custom_providers(cfg),
|
|
excluded_providers=excluded if isinstance(excluded, list) else [],
|
|
)
|
|
|
|
|
|
def _slug(row: dict) -> str:
|
|
return str(row.get("slug") or "").strip().lower()
|
|
|
|
|
|
def _without_slug(rows: list[dict], slug: str) -> list[dict]:
|
|
return [r for r in rows if _slug(r) != slug]
|
|
|
|
|
|
# ─── Public: payload builder ────────────────────────────────────────────
|
|
|
|
|
|
def build_models_payload(
|
|
ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False,
|
|
picker_hints: bool = False, canonical_order: bool = False, pricing: bool = False,
|
|
pricing_cache_only: bool = False,
|
|
capabilities: bool = False, featured: bool = False, force_fresh_nous_tier: bool = False,
|
|
refresh: bool = False, probe_custom_providers: bool = True, probe_current_custom_provider: bool = False,
|
|
for_picker: bool = False, max_models: int | None = None,
|
|
) -> dict:
|
|
"""Build the ``{providers, model, provider}`` shape every consumer needs. ``explicit_only`` keeps
|
|
only providers the user explicitly configured — hides ambient/auto-seeded credentials from
|
|
desktop chat pickers. ``pricing_cache_only``: with ``pricing``, use only values already resident
|
|
in process caches (normal picker opens, while a background worker warms cold endpoints)."""
|
|
from hermes_cli.model_switch import list_authenticated_providers
|
|
|
|
rows = list_authenticated_providers(
|
|
current_provider=ctx.current_provider, current_base_url=ctx.current_base_url,
|
|
current_model=ctx.current_model, user_providers=ctx.user_providers,
|
|
custom_providers=ctx.custom_providers, force_fresh_nous_tier=force_fresh_nous_tier,
|
|
max_models=max_models, refresh=refresh, probe_custom_providers=probe_custom_providers,
|
|
probe_current_custom_provider=probe_current_custom_provider, for_picker=for_picker,
|
|
excluded_providers=ctx.excluded_providers or [],
|
|
)
|
|
|
|
# Managed local runtime: staged GGUFs are selectable like any provider's models, but
|
|
# list_authenticated_providers can't know about them (no credential — reachability is the
|
|
# credential), so inject the row here where every picker surface inherits it.
|
|
local_row = _local_runtime_row(ctx)
|
|
if local_row is not None:
|
|
rows = _without_slug(rows, "llamacpp") + [local_row]
|
|
# A live session on the managed server reports provider "custom" (raw base_url label), which
|
|
# would materialize a duplicate "Custom endpoint" row with the same staged models stealing the
|
|
# checkmark. The Local row owns the managed server's identity — drop such custom rows.
|
|
if local_row.get("is_current"):
|
|
staged = set(local_row["models"])
|
|
|
|
def _is_managed_custom(row: dict) -> bool:
|
|
models = {str(m) for m in (row.get("models") or [])}
|
|
return _slug(row) == "custom" and bool(models) and models <= staged
|
|
|
|
rows = [r for r in rows if not _is_managed_custom(r)]
|
|
|
|
moa_row = _moa_provider_row(ctx.current_provider)
|
|
if moa_row is not None:
|
|
rows = [moa_row] + _without_slug(rows, "moa")
|
|
|
|
if explicit_only:
|
|
rows = _filter_explicit_provider_rows(rows, ctx)
|
|
# If the current provider lost its credential, list_authenticated_providers() omits it; keep
|
|
# that one row so the UI shows the saved selection + a re-auth affordance instead of appearing
|
|
# to jump providers. Exception: a "custom" current on the managed local server is already
|
|
# represented by the Local row — the skeleton would resurrect the duplicate removed above.
|
|
_local_owns_current = bool(local_row and local_row.get("is_current")
|
|
and (ctx.current_provider or "").lower() == "custom")
|
|
if not _local_owns_current:
|
|
rows = list(rows) + _append_unconfigured_rows(rows, ctx, current_only=True)
|
|
|
|
# A local proxy serving a model also in an aggregator's catalog would show under both, and picking
|
|
# the aggregator row silently breaks the call — aggregators only list models no specific provider has.
|
|
_strip_aggregator_overlaps(rows)
|
|
|
|
if include_unconfigured:
|
|
rows = list(rows) + _without_slug(_append_unconfigured_rows(rows, ctx), "moa")
|
|
if picker_hints:
|
|
_apply_picker_hints(rows)
|
|
if canonical_order:
|
|
rows = _reorder_canonical(rows)
|
|
if pricing:
|
|
_apply_pricing(rows, force_fresh_nous_tier=force_fresh_nous_tier, cached_only=pricing_cache_only)
|
|
if capabilities:
|
|
_apply_capabilities(rows)
|
|
if featured:
|
|
_apply_featured(rows)
|
|
_apply_custom_aliases(rows)
|
|
|
|
return {"providers": rows, "model": ctx.current_model, "provider": ctx.current_provider}
|
|
|
|
|
|
def _strip_aggregator_overlaps(rows: list[dict]) -> None:
|
|
"""Drop models from TRUE routing aggregators (OpenRouter, custom:* proxies) that a user-defined
|
|
provider also serves. The is_user_defined guard matters: is_routing_aggregator() is True for every
|
|
custom:* slug, so without it the dedup would empty a user's own custom row. Flat-namespace
|
|
resellers (opencode-go/zen) serve every model first-party and keep shared names."""
|
|
try:
|
|
from hermes_cli.providers import is_routing_aggregator
|
|
except Exception:
|
|
return
|
|
|
|
user_models: set[str] = set()
|
|
for row in rows:
|
|
if row.get("is_user_defined"):
|
|
user_models.update(m.lower() for m in (row.get("models") or []))
|
|
if not user_models:
|
|
return
|
|
for row in rows:
|
|
if row.get("is_user_defined") or not is_routing_aggregator(row.get("slug", "")):
|
|
continue
|
|
# Only strip overlaps from TRUE routing aggregators (OpenRouter, custom:* proxies). Flat-namespace
|
|
# resellers (opencode-go / opencode-zen) serve every listed model as a first-party model, so their
|
|
# rows must keep models that a user's proxy happens to share a name with — otherwise a subscription
|
|
# provider's own catalog (minimax-m3, glm-5, deepseek-v4-flash, ...) is silently gutted in the
|
|
# picker. (#47077)
|
|
original = row.get("models") or []
|
|
filtered = [m for m in original if m.lower() not in user_models]
|
|
if len(filtered) < len(original):
|
|
row["models"] = filtered
|
|
row["total_models"] = len(filtered)
|
|
|
|
|
|
def build_model_options_payload(
|
|
ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False,
|
|
refresh: bool = False,
|
|
) -> dict:
|
|
"""Shared API-server/dashboard/TUI payload. Normal open probes only the current custom provider so
|
|
offline saved endpoints don't block the picker; explicit refresh probes all and busts the cache."""
|
|
refresh = bool(refresh)
|
|
payload = build_models_payload(
|
|
ctx, explicit_only=bool(explicit_only), include_unconfigured=bool(include_unconfigured),
|
|
picker_hints=True, canonical_order=True, pricing=True, pricing_cache_only=not refresh,
|
|
capabilities=True, featured=True,
|
|
refresh=refresh, probe_custom_providers=refresh, probe_current_custom_provider=not refresh,
|
|
)
|
|
if not refresh:
|
|
_prewarm_pricing_async(payload["providers"], current_provider=ctx.current_provider,
|
|
current_base_url=ctx.current_base_url)
|
|
return payload
|
|
|
|
|
|
# ─── Public: auxiliary-task pickers ─────────────────────────────────────
|
|
|
|
|
|
def build_aux_picker_rows(
|
|
*, current_provider: str = "", current_model: str = "", current_base_url: str = "",
|
|
max_models: int | None = None,
|
|
) -> list[dict]:
|
|
"""Provider rows for any auxiliary-task picker (vision, compression, …). Honours
|
|
``excluded_providers``; exhausted-pool providers stay visible (``for_picker``); only the active
|
|
custom endpoint is probed. ``moa`` is excluded: auxiliary_client unwraps it to its aggregator
|
|
anyway, so offering it would be a choice silently rewritten.
|
|
|
|
Aux pickers kept re-deriving their own kwargs and each one silently dropped a different slice of the
|
|
user's configuration. Two independent contributor PRs landed against the same two call sites for exactly
|
|
this: 52642 (user ``providers:`` / ``custom_providers:`` entries never appeared) and #66624 (providers
|
|
with an exhausted credential pool were hidden). Both were per-site kwarg patches, so the next aux picker
|
|
would have reintroduced the same gap. Routing through one function makes the correct behaviour the
|
|
default that a new caller cannot forget:
|
|
"""
|
|
ctx = load_picker_context().with_overrides(
|
|
current_provider=current_provider, current_model=current_model, current_base_url=current_base_url,
|
|
)
|
|
rows = build_models_payload(
|
|
ctx, for_picker=True, probe_custom_providers=False, probe_current_custom_provider=True,
|
|
max_models=max_models,
|
|
)["providers"]
|
|
return _without_slug(rows, "moa")
|
|
|
|
|
|
def format_aux_picker_entries(
|
|
rows: list[dict], *, current_provider: str = "", current_base_url: str = "",
|
|
) -> list[tuple[str, str, list[str]]]:
|
|
"""Render aux-picker rows as ``(slug, label, models)``. A raw ``base_url`` custom endpoint is
|
|
"current" only through that URL, never a slug — so with ``current_base_url`` set no row is marked."""
|
|
entries: list[tuple[str, str, list[str]]] = []
|
|
current_slug = str(current_provider or "").strip().lower()
|
|
has_base_url = bool(str(current_base_url or "").strip())
|
|
for row in rows:
|
|
slug = str(row.get("slug") or "")
|
|
name = row.get("name") or slug
|
|
total = row.get("total_models") or len(row.get("models") or [])
|
|
model_hint = f" — {total} models" if total else ""
|
|
marker = " ← current" if slug.lower() == current_slug and current_slug and not has_base_url else ""
|
|
entries.append((slug, f"{name}{model_hint}{marker}", list(row.get("models") or [])))
|
|
return entries
|
|
|
|
|
|
def _reasoning_catalog_reader(slug: str):
|
|
"""Per-model reasoning-capability reader for aggregators that publish one. Cache-only — the picker
|
|
must never block on HTTP; a cold cache warms in the background and reports no restriction until then."""
|
|
try:
|
|
from hermes_cli.models_reasoning_caps import (
|
|
nous_model_reasoning_capabilities,
|
|
openrouter_model_reasoning_capabilities,
|
|
warm_nous_reasoning_caps_async,
|
|
warm_openrouter_reasoning_caps_async,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
readers = {
|
|
"nous": (warm_nous_reasoning_caps_async, nous_model_reasoning_capabilities),
|
|
"openrouter": (warm_openrouter_reasoning_caps_async, openrouter_model_reasoning_capabilities),
|
|
}
|
|
if slug not in readers:
|
|
return None
|
|
warm, read = readers[slug]
|
|
warm()
|
|
return read
|
|
|
|
|
|
def _apply_capabilities(rows: list[dict]) -> None:
|
|
"""Attach ``{model: {fast, reasoning, ...}}`` per row. ``reasoning`` defaults True when the catalog is
|
|
silent (the dial is a no-op on models that ignore it; hiding it from a capable model is worse). A
|
|
serving aggregator's detail overrides models.dev (adds ``can_disable_reasoning``). ``supported_efforts``
|
|
is deliberately NOT forwarded — it under-reports levels that work."""
|
|
from hermes_cli.models import model_supports_fast_mode
|
|
|
|
try:
|
|
from agent.models_dev import get_model_capabilities
|
|
except Exception:
|
|
get_model_capabilities = None # type: ignore[assignment]
|
|
|
|
for row in rows:
|
|
slug = row.get("slug") or ""
|
|
caps: dict[str, dict[str, Any]] = {}
|
|
read_reasoning_catalog = _reasoning_catalog_reader(slug.lower())
|
|
|
|
for model in row.get("models") or []:
|
|
reasoning = True
|
|
if get_model_capabilities is not None and slug:
|
|
try:
|
|
meta = get_model_capabilities(slug, model)
|
|
if meta is not None:
|
|
reasoning = bool(meta.supports_reasoning)
|
|
except Exception:
|
|
reasoning = True
|
|
|
|
entry: dict[str, Any] = {"fast": bool(model_supports_fast_mode(model)), "reasoning": reasoning}
|
|
|
|
if reasoning and read_reasoning_catalog is not None:
|
|
try:
|
|
detail = read_reasoning_catalog(model)
|
|
except Exception:
|
|
detail = None
|
|
if detail and not detail.get("supports_reasoning"):
|
|
# Aggregator catalog beats models.dev for a route it serves: no reasoning param
|
|
# means no reasoning controls, so no disable to describe either.
|
|
entry["reasoning"] = False
|
|
elif detail:
|
|
entry["can_disable_reasoning"] = not detail.get("mandatory")
|
|
|
|
caps[model] = entry
|
|
|
|
row["capabilities"] = caps
|
|
|
|
|
|
# Newest N models per lab an aggregator row features by default (older tail behind search/show-all);
|
|
# 5 keeps a lab's headliners without letting a prolific vendor flood the view.
|
|
_FEATURED_PER_LAB = 5
|
|
|
|
|
|
def _apply_featured(rows: list[dict]) -> None:
|
|
"""Attach a ``featured_models`` shortlist to each aggregator row: newest ``_FEATURED_PER_LAB`` per
|
|
vendor by models.dev ``release_date`` (ranked within the row, never vs. today, so it is stable);
|
|
ties keep curated order. Non-aggregators get an empty list and keep top-N behaviour."""
|
|
try:
|
|
from agent.models_dev import get_model_info
|
|
except Exception:
|
|
get_model_info = None # type: ignore[assignment]
|
|
|
|
for row in rows:
|
|
slug = str(row.get("slug") or "").strip().lower()
|
|
models = row.get("models") or []
|
|
|
|
by_lab: dict[str, list[tuple[int, str, str]]] = {} # only multi-lab aggregators get a shortlist
|
|
for pos, model in enumerate(models):
|
|
lab = model.split("/", 1)[0] if "/" in model else ""
|
|
if not lab: # no vendor prefix → single-namespace provider, not an aggregator
|
|
by_lab = {}
|
|
break
|
|
date = ""
|
|
if get_model_info is not None:
|
|
info = get_model_info(slug, model) or get_model_info("openrouter", model)
|
|
date = getattr(info, "release_date", "") if info else ""
|
|
by_lab.setdefault(lab, []).append((pos, date, model))
|
|
|
|
if len(by_lab) < 2:
|
|
row["featured_models"] = []
|
|
continue
|
|
|
|
featured: list[str] = []
|
|
for entries in by_lab.values():
|
|
# Newest release_date first; earlier list position breaks ties (sole key when undated).
|
|
ranked = sorted(entries, key=lambda e: (e[1], -e[0]), reverse=True)
|
|
featured.extend(model for _pos, _date, model in ranked[:_FEATURED_PER_LAB])
|
|
order = {m: i for i, m in enumerate(models)} # keep the row's model order for stable rendering
|
|
row["featured_models"] = sorted(featured, key=lambda m: order[m])
|
|
|
|
|
|
def _apply_custom_aliases(rows: list[dict]) -> None:
|
|
"""Attach the accepted identity set to each user-defined row: ``model.options`` reports the canonical
|
|
``custom:<key>`` while rows carry the bare key as ``slug``, so GUI exact-match never finds the row.
|
|
|
|
GUI pickers compare the two to decide which row is active; exact equality never matches for custom
|
|
providers (#87035). Exposing ``aliases`` — every current and legacy spelling from
|
|
:func:`hermes_cli.providers.custom_provider_aliases` — lets the frontend do a membership check instead.
|
|
"""
|
|
from hermes_cli.providers import custom_provider_aliases
|
|
|
|
for row in rows:
|
|
if not row.get("is_user_defined"):
|
|
continue
|
|
try:
|
|
row["aliases"] = sorted(
|
|
custom_provider_aliases(str(row.get("name", "")), str(row.get("slug", ""))))
|
|
except Exception:
|
|
continue
|
|
|
|
|
|
# ─── Internal: row post-processing ──────────────────────────────────────
|
|
|
|
|
|
def _provider_auth_hint(slug: str) -> tuple[str, str]:
|
|
"""``(auth_type, key_env)`` for a canonical provider (``("api_key", "")`` when unregistered)."""
|
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
|
|
|
cfg = PROVIDER_REGISTRY.get(slug)
|
|
auth_type = cfg.auth_type if cfg else "api_key"
|
|
key_env = cfg.api_key_env_vars[0] if (cfg and cfg.api_key_env_vars) else ""
|
|
return auth_type, key_env
|
|
|
|
|
|
def _row(slug: str, name: str, is_current: bool, **extra: Any) -> dict:
|
|
return {"slug": slug, "name": name, "is_current": is_current, "is_user_defined": False, **extra}
|
|
|
|
|
|
def _canonical_row(entry, cur: str, **extra: Any) -> dict:
|
|
from hermes_cli.models import _PROVIDER_LABELS
|
|
|
|
return _row(entry.slug, _PROVIDER_LABELS.get(entry.slug, entry.label), entry.slug.lower() == cur, **extra)
|
|
|
|
|
|
def _append_unconfigured_rows(
|
|
rows: list[dict], ctx: ConfigContext, *, current_only: bool = False,
|
|
) -> list[dict]:
|
|
"""Empty setup skeletons for canonical providers missing from ``rows`` — except the *current* one:
|
|
if config.yaml still points at it but credentials are gone, keep a row carrying the saved model so
|
|
GUI pickers don't silently snap to another provider."""
|
|
from hermes_cli.models import CANONICAL_PROVIDERS, _model_requires_account_discovery
|
|
|
|
seen = {r["slug"].lower() for r in rows}
|
|
cur = (ctx.current_provider or "").lower()
|
|
cur_model = str(ctx.current_model or "").strip()
|
|
extras: list[dict] = []
|
|
for entry in CANONICAL_PROVIDERS:
|
|
if entry.slug.lower() in seen:
|
|
continue
|
|
if current_only and entry.slug.lower() != cur:
|
|
continue
|
|
if entry.slug.lower() == cur:
|
|
saved_model = "" if _model_requires_account_discovery(entry.slug, cur_model) else cur_model
|
|
auth_type, key_env = _provider_auth_hint(entry.slug)
|
|
tail = (
|
|
"Astra requires successful account-scoped model discovery."
|
|
if cur_model and not saved_model else "Showing the saved model only."
|
|
)
|
|
warning = (
|
|
f"Configured provider missing usable credentials; paste {key_env} to reactivate. {tail}"
|
|
if auth_type == "api_key" and key_env
|
|
else f"Configured provider is not authenticated; run `hermes model` to reactivate. {tail}"
|
|
)
|
|
extras.append(_canonical_row(
|
|
entry, cur, models=[saved_model] if saved_model else [], total_models=1 if saved_model else 0,
|
|
source="configured-current", authenticated=False, auth_type=auth_type, key_env=key_env,
|
|
warning=warning,
|
|
))
|
|
continue
|
|
extras.append(_canonical_row(entry, cur, models=[], total_models=0, source="canonical"))
|
|
return extras
|
|
|
|
|
|
def _anthropic_oauth_credentials_present() -> bool:
|
|
"""True when the user explicitly authenticated Anthropic via OAuth (Hermes device flow or Claude Code
|
|
login) — those leave no trace in active_provider / model.provider / API-key env vars."""
|
|
try:
|
|
from agent.anthropic_credentials import read_claude_code_credentials, read_hermes_oauth_credentials
|
|
|
|
readers = (read_hermes_oauth_credentials, read_claude_code_credentials)
|
|
if any((read() or {}).get("accessToken") for read in readers):
|
|
return True
|
|
except Exception:
|
|
return False
|
|
# Pool-only OAuth entries (auth.json credential_pool.anthropic) are equally deliberate — discovery
|
|
# accepts them via pool.has_credentials(), so the filter must too or those rows are built then
|
|
# silently dropped. Read-only (no load_pool) so a picker open never mutates auth.json.
|
|
try:
|
|
from agent.credential_pool import AUTH_TYPE_OAUTH
|
|
from hermes_cli.auth import read_credential_pool
|
|
|
|
for entry in read_credential_pool("anthropic"):
|
|
if (isinstance(entry, dict) and entry.get("auth_type") == AUTH_TYPE_OAUTH
|
|
and str(entry.get("access_token") or "").strip()):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
|
|
"""Keep only rows backed by explicit user configuration — ``list_authenticated_providers`` also
|
|
discovers ambient credentials (e.g. GitHub CLI -> Copilot) Desktop chat pickers must not show."""
|
|
from hermes_cli.auth import is_provider_explicitly_configured
|
|
|
|
current_slug = str(ctx.current_provider or "").strip().lower()
|
|
|
|
def _is_explicit(row: dict, slug: str) -> bool:
|
|
# Managed local models are explicit configuration by existence (gigabytes downloaded into the
|
|
# machine-scoped dir); there is deliberately no config credential, so without the source clause
|
|
# the row would only survive on the profile where Use was last clicked.
|
|
if (row.get("is_user_defined") or (current_slug and slug == current_slug)
|
|
or row.get("source") == "local-runtime"):
|
|
return True
|
|
if slug == "moa":
|
|
# Virtual routing mode, not a configured provider: hide unless current (above) or the user
|
|
# wrote an enabled preset into RAW config (the DEFAULT_CONFIG preset must not show MoA).
|
|
return _raw_config_has_enabled_moa_preset()
|
|
return (
|
|
_provider_is_keyless(slug) # zero-setup providers need no configuration at all
|
|
# Anthropic OAuth (device flow / Claude Code) and external-process CLIs (copilot-acp) are
|
|
# deliberate sign-ins that leave no trace in config/env; keep the rows discovery accepted.
|
|
or (slug == "anthropic" and _anthropic_oauth_credentials_present())
|
|
or _external_process_signed_in(slug)
|
|
or is_provider_explicitly_configured(slug)
|
|
)
|
|
|
|
return [row for row in rows
|
|
if (slug := str(row.get("slug", "")).strip().lower()) and _is_explicit(row, slug)]
|
|
|
|
|
|
def _external_process_signed_in(slug: str) -> bool:
|
|
"""True when an external-process provider has verified CLI credentials."""
|
|
try:
|
|
from hermes_cli.auth import PROVIDER_REGISTRY, get_external_process_provider_status
|
|
pconfig = PROVIDER_REGISTRY.get(slug)
|
|
return bool(pconfig and pconfig.auth_type == "external_process"
|
|
and get_external_process_provider_status(slug).get("auth_verified"))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _provider_is_keyless(slug: str) -> bool:
|
|
"""True when the provider's Hermes overlay declares it keyless."""
|
|
try:
|
|
from hermes_cli.providers import HERMES_OVERLAYS
|
|
overlay = HERMES_OVERLAYS.get(slug)
|
|
return bool(overlay is not None and getattr(overlay, "keyless", False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _raw_config_has_enabled_moa_preset() -> bool:
|
|
"""True when the user's RAW config enables MoA: ``load_config()`` merges the DEFAULT_CONFIG preset for
|
|
everyone, which is not a user choice; visible once one enabled preset (or legacy flat config) is saved."""
|
|
try:
|
|
from hermes_cli.config import read_raw_config
|
|
|
|
raw = read_raw_config()
|
|
except Exception:
|
|
return False
|
|
|
|
moa = raw.get("moa") if isinstance(raw, dict) else None
|
|
if not isinstance(moa, dict):
|
|
return False
|
|
|
|
presets = moa.get("presets")
|
|
if isinstance(presets, dict):
|
|
return any(
|
|
not isinstance(preset, dict) or preset.get("enabled", True)
|
|
for name, preset in presets.items() if str(name or "").strip()
|
|
)
|
|
|
|
legacy_keys = {"reference_models", "aggregator", "reference_temperature", "aggregator_temperature",
|
|
"max_tokens", "reference_max_tokens", "fanout"}
|
|
return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True))
|
|
|
|
|
|
def _apply_picker_hints(rows: list[dict]) -> None:
|
|
"""Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row."""
|
|
for row in rows:
|
|
if "authenticated" in row:
|
|
continue
|
|
# Skeleton rows (_append_unconfigured_rows) have empty `models` AND source="canonical".
|
|
is_skeleton = row.get("source") == "canonical" and not row.get("models")
|
|
row["authenticated"] = not is_skeleton
|
|
if not is_skeleton or row.get("is_user_defined"):
|
|
continue
|
|
auth_type, key_env = _provider_auth_hint(row["slug"])
|
|
row["auth_type"] = auth_type
|
|
row["key_env"] = key_env
|
|
row["warning"] = (f"paste {key_env} to activate" if auth_type == "api_key" and key_env
|
|
else f"run `hermes model` to configure ({auth_type})")
|
|
|
|
|
|
def _reorder_canonical(rows: list[dict]) -> list[dict]:
|
|
"""Canonical slugs in ``CANONICAL_PROVIDERS`` order, truly-custom rows last. Keys on slug membership,
|
|
NOT ``is_user_defined`` — ``providers:`` config rows carry that flag even for canonical slugs."""
|
|
from hermes_cli.models import CANONICAL_PROVIDERS
|
|
|
|
order = {e.slug: i for i, e in enumerate(CANONICAL_PROVIDERS)}
|
|
canon = sorted((r for r in rows if r["slug"] in order), key=lambda r: order[r["slug"]])
|
|
extras = [r for r in rows if r["slug"] not in order]
|
|
return canon + extras
|
|
|
|
|
|
def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cached_only: bool = False) -> None:
|
|
"""Set ``row["pricing"] = {model_id: {input, output, cache | None, free}}``; for Nous also
|
|
``free_tier`` (account is free-tier) and ``unavailable_models`` (paid models a free user can't pick).
|
|
``cached_only`` never hits the network: unknown Nous entitlement fails closed (``free_tier_pending``,
|
|
all models locked) and missing pricing is marked ``pricing_pending``."""
|
|
from hermes_cli.models_pricing import (
|
|
_format_price_per_mtok,
|
|
compute_sale_discount,
|
|
get_pricing_for_provider,
|
|
)
|
|
from hermes_cli.models import (
|
|
check_nous_free_tier,
|
|
get_cached_nous_free_tier,
|
|
partition_nous_models_by_tier,
|
|
)
|
|
|
|
nous_free_tier: Optional[bool] = None # resolved once (cached in models.py for the TTL window)
|
|
|
|
for row in rows:
|
|
slug = str(row.get("slug", "")).lower()
|
|
models = row.get("models") or []
|
|
if not models:
|
|
continue
|
|
if row.get("free_tier_row"):
|
|
# The free tier's one model has no Portal pricing and no entitlement to read: pricing
|
|
# it would lock the only row a free-tier install can select.
|
|
continue
|
|
try:
|
|
pricing_kwargs = {"cached_only": True} if cached_only else {}
|
|
raw_pricing = get_pricing_for_provider(slug, **pricing_kwargs) or {}
|
|
except Exception:
|
|
raw_pricing = {}
|
|
cached_nous_tier: Optional[bool] = None
|
|
if slug == "nous" and cached_only:
|
|
cached_nous_tier = get_cached_nous_free_tier()
|
|
if cached_nous_tier is None:
|
|
# Entitlement unknown: stay nonblocking but fail closed until the prewarm has populated
|
|
# both caches, else a free account could briefly select paid models on first open.
|
|
row["free_tier_pending"] = True
|
|
row["unavailable_models"] = list(models)
|
|
if not row.get("warning"): # say why every model renders locked
|
|
row["warning"] = ("Checking Nous plan entitlement… models unlock on the "
|
|
"next picker open or refresh.")
|
|
continue
|
|
if not raw_pricing:
|
|
if slug == "nous":
|
|
row["free_tier"] = bool(cached_nous_tier)
|
|
row["pricing_pending"] = True
|
|
row["unavailable_models"] = list(models) if cached_nous_tier else []
|
|
continue
|
|
|
|
formatted: dict[str, dict] = {}
|
|
for mid in models:
|
|
p = raw_pricing.get(mid)
|
|
if not p:
|
|
continue
|
|
inp_raw, out_raw = p.get("prompt", ""), 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 ""
|
|
entry: dict = {
|
|
"input": inp, "output": out,
|
|
"cache": _format_price_per_mtok(cache_raw) if cache_raw else None,
|
|
"free": inp == "free" and out in ("free", ""), # both input and output cost nothing
|
|
}
|
|
# Sale chrome is Nous Portal-only (other catalogs' nested pricing.original is ignored); free
|
|
# models get flat -100% chrome, was_* only when the gateway served an original.
|
|
if slug == "nous":
|
|
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
|
|
for key, was_raw in (("was_input", was_prompt_raw), ("was_output", was_out_raw)):
|
|
if was_raw != "":
|
|
entry[key] = _format_price_per_mtok(was_raw)
|
|
formatted[mid] = entry
|
|
|
|
if formatted:
|
|
row["pricing"] = formatted
|
|
|
|
if slug == "nous":
|
|
try:
|
|
if nous_free_tier is None:
|
|
nous_free_tier = (cached_nous_tier if cached_only
|
|
else check_nous_free_tier(force_fresh=force_fresh_nous_tier))
|
|
row["free_tier"] = bool(nous_free_tier)
|
|
row["unavailable_models"] = (
|
|
partition_nous_models_by_tier(list(models), raw_pricing, free_tier=True)[1]
|
|
if nous_free_tier else [])
|
|
except Exception: # tier detection failed — fail open (no gating)
|
|
row["free_tier"] = False
|
|
row["unavailable_models"] = []
|
|
|
|
|
|
def _local_runtime_row(ctx: "ConfigContext") -> dict | None:
|
|
"""The ``llamacpp`` row from staged GGUFs (``None`` when none) — downloaded models must be selectable
|
|
before the server runs (selection starts it via the runtime_provider seam)."""
|
|
try:
|
|
from hermes_cli.local_runtime.bootstrap import staged_model_ids
|
|
|
|
staged = staged_model_ids()
|
|
if not staged:
|
|
return None
|
|
current = (ctx.current_provider or "").strip().lower() in ("llamacpp", "llama.cpp", "llama-cpp")
|
|
if not current:
|
|
# A LIVE session on the managed server reports provider "custom" with the managed base_url;
|
|
# match on the endpoint so the session being chatted in still shows a selection.
|
|
try:
|
|
from hermes_cli.local_runtime.endpoint import _state_endpoint
|
|
|
|
managed = _state_endpoint()
|
|
current = bool(managed and (ctx.current_base_url or "").strip().rstrip("/")
|
|
== managed["base_url"].rstrip("/"))
|
|
except Exception:
|
|
current = False
|
|
# Bare "Local" user-facing (engine name is an implementation detail); authenticated = reachability.
|
|
return _row("llamacpp", "Local", current, models=staged, total_models=len(staged),
|
|
source="local-runtime", authenticated=True, auth_type="local", warning=None)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _prewarm_pricing_async(
|
|
rows: list[dict], *, current_provider: str = "", current_base_url: str = "",
|
|
) -> Optional[Thread]:
|
|
"""Warm picker pricing caches without delaying the current payload (one worker per
|
|
profile + endpoint scope; a live worker is reused)."""
|
|
from hermes_constants import hermes_home_key
|
|
from hermes_cli.models_pricing import pricing_cache_scope
|
|
|
|
slugs = {str(row.get("slug") or "").lower() for row in rows if row.get("slug")}
|
|
endpoint_scope = tuple(sorted(
|
|
(slug, pricing_cache_scope(slug, current_provider=current_provider, current_base_url=current_base_url))
|
|
for slug in slugs))
|
|
prewarm_key = (hermes_home_key(), endpoint_scope)
|
|
|
|
with _pricing_prewarm_lock:
|
|
current = _pricing_prewarm_threads.get(prewarm_key)
|
|
if current is not None and current.is_alive():
|
|
return current
|
|
# The worker mutates only private copies; the pricing helpers populate shared process caches.
|
|
worker_rows = [{**row, "models": list(row.get("models") or [])} for row in rows]
|
|
|
|
def _worker() -> None:
|
|
try:
|
|
_apply_pricing(worker_rows)
|
|
finally:
|
|
with _pricing_prewarm_lock:
|
|
if _pricing_prewarm_threads.get(prewarm_key) is current_thread():
|
|
_pricing_prewarm_threads.pop(prewarm_key, None)
|
|
|
|
thread = Thread(target=copy_context().run, args=(_worker,),
|
|
name="hermes-picker-pricing-prewarm", daemon=True)
|
|
_pricing_prewarm_threads[prewarm_key] = thread
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
def _moa_provider_row(current_provider: str = "") -> dict | None:
|
|
"""The virtual ``moa`` row shared by the CLI inventory and gateway picker; ``None`` without presets."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.moa_config import normalize_moa_config
|
|
|
|
cfg = normalize_moa_config(load_config().get("moa") or {})
|
|
models = list(cfg.get("presets", {}).keys())
|
|
if not models:
|
|
return None
|
|
return _row(
|
|
"moa", "Mixture of Agents", (current_provider or "").lower() == "moa", models=models,
|
|
total_models=len(models), source="virtual", authenticated=True, auth_type="virtual",
|
|
warning="Aggregator acts as the selected model; references provide analysis before each call.")
|
|
except Exception:
|
|
return None
|