Hermes-agent
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"""Provider/model inventory context — shared substrate for the dashboard
|
||||
``/api/model/options``, the TUI ``model.options``/``model.save_key``
|
||||
JSON-RPC handlers, and the interactive picker.
|
||||
|
||||
Before this module the three call-sites each duplicated:
|
||||
|
||||
1. The 17-LOC config-slice that pulls ``model.{default,name,provider,base_url}``,
|
||||
``providers:``, and ``custom_providers:`` out of ``load_config()``;
|
||||
2. The call into ``list_authenticated_providers`` with the resulting kwargs;
|
||||
3. (TUI only) a 45-LOC post-pass that merges authenticated rows with
|
||||
unconfigured ``CANONICAL_PROVIDERS`` rows and emits ``authenticated``/
|
||||
``auth_type``/``key_env``/``warning`` hints for the picker UI.
|
||||
|
||||
Consolidating those three steps into one entry point eliminates two bugs
|
||||
the duplicates were hiding:
|
||||
|
||||
- The dashboard read ``cfg.get("custom_providers")`` directly, missing the
|
||||
v12+ keyed ``providers:`` form (which the TUI handled via
|
||||
``get_compatible_custom_providers``).
|
||||
- The TUI's canonical-merge keyed on ``is_user_defined`` to decide
|
||||
ordering. Section 3 of ``list_authenticated_providers`` sets
|
||||
``is_user_defined=True`` even for canonical slugs that appear in the
|
||||
``providers:`` config dict, which silently demoted them to the tail of
|
||||
the picker. ``_reorder_canonical`` keys on slug membership instead.
|
||||
|
||||
Substrate facts (verified May 2026):
|
||||
- ``list_authenticated_providers`` already populates each row's
|
||||
``models`` from the curated catalog (same source as the picker). Do
|
||||
NOT call ``provider_model_ids()`` per row to "freshen" — that bypasses
|
||||
curation and pulls in non-agentic models (Nous /models returns ~400
|
||||
IDs including TTS, embeddings, rerankers, image/video generators).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ─── Public types ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigContext:
|
||||
"""Snapshot of the model + provider config every inventory caller
|
||||
needs. Built once via ``load_picker_context()``; the TUI overlays
|
||||
live agent state via ``with_overrides()`` before passing through.
|
||||
"""
|
||||
|
||||
current_provider: str
|
||||
current_model: str
|
||||
current_base_url: str
|
||||
user_providers: dict
|
||||
custom_providers: list
|
||||
|
||||
def with_overrides(
|
||||
self,
|
||||
*,
|
||||
current_provider: Optional[str] = None,
|
||||
current_model: Optional[str] = None,
|
||||
current_base_url: Optional[str] = None,
|
||||
) -> "ConfigContext":
|
||||
"""Return a copy with truthy overrides applied.
|
||||
|
||||
Truthy-only because the TUI reads agent attributes that may be
|
||||
empty strings before an agent is spawned — empties must NOT
|
||||
clobber the disk-config values.
|
||||
"""
|
||||
kw: dict = {}
|
||||
if current_provider:
|
||||
kw["current_provider"] = current_provider
|
||||
if current_model:
|
||||
kw["current_model"] = current_model
|
||||
if current_base_url:
|
||||
kw["current_base_url"] = current_base_url
|
||||
return replace(self, **kw) if kw else self
|
||||
|
||||
|
||||
def load_picker_context() -> ConfigContext:
|
||||
"""Load the disk-config snapshot every consumer needs.
|
||||
|
||||
Replaces the inline 17-LOC config-slice that ``web_server.py`` and
|
||||
``tui_gateway/server.py`` (×2 sites) used to do.
|
||||
"""
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
current_model = model_cfg.get("default", model_cfg.get("name", "")) or ""
|
||||
current_provider = model_cfg.get("provider", "") or ""
|
||||
current_base_url = model_cfg.get("base_url", "") or ""
|
||||
else:
|
||||
# config.model can be a bare string in older configs.
|
||||
current_model = str(model_cfg) if model_cfg else ""
|
||||
current_provider = ""
|
||||
current_base_url = ""
|
||||
raw = cfg.get("providers")
|
||||
return ConfigContext(
|
||||
current_provider=current_provider,
|
||||
current_model=current_model,
|
||||
current_base_url=current_base_url,
|
||||
user_providers=raw if isinstance(raw, dict) else {},
|
||||
custom_providers=get_compatible_custom_providers(cfg),
|
||||
)
|
||||
|
||||
|
||||
# ─── Public: payload builder ────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_models_payload(
|
||||
ctx: ConfigContext,
|
||||
*,
|
||||
include_unconfigured: bool = False,
|
||||
picker_hints: bool = False,
|
||||
canonical_order: bool = False,
|
||||
pricing: bool = False,
|
||||
capabilities: bool = False,
|
||||
force_fresh_nous_tier: bool = False,
|
||||
max_models: int = 50,
|
||||
) -> dict:
|
||||
"""Build the ``{providers, model, provider}`` shape every consumer
|
||||
needs from a single substrate call.
|
||||
|
||||
Flags:
|
||||
- ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that
|
||||
``list_authenticated_providers`` didn't emit (TUI uses this to show
|
||||
the full provider universe in the picker).
|
||||
- ``picker_hints``: add ``authenticated``/``auth_type``/``key_env``/
|
||||
``warning`` per row (TUI ``ModelPickerDialog`` shape).
|
||||
- ``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.
|
||||
- ``capabilities``: add a per-row ``capabilities`` map
|
||||
``{model: {fast, reasoning}}`` so pickers can gate the model-options
|
||||
controls (fast toggle / reasoning) to what each model actually
|
||||
supports, instead of offering knobs the backend would reject.
|
||||
- ``force_fresh_nous_tier``: bypass the short Nous free-tier cache when
|
||||
selecting Portal-recommended Nous models and applying tier gating. Keep
|
||||
this false for UI picker opens; explicit auth/model flows can opt in
|
||||
when they need freshly-purchased credits to show up immediately.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
if include_unconfigured:
|
||||
rows = list(rows) + _append_unconfigured_rows(rows, ctx)
|
||||
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)
|
||||
if capabilities:
|
||||
_apply_capabilities(rows)
|
||||
|
||||
return {
|
||||
"providers": rows,
|
||||
"model": ctx.current_model,
|
||||
"provider": ctx.current_provider,
|
||||
}
|
||||
|
||||
|
||||
def _apply_capabilities(rows: list[dict]) -> None:
|
||||
"""Attach a ``{model: {fast, reasoning}}`` map to each provider row.
|
||||
|
||||
`fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
|
||||
enforces). `reasoning` comes from the models.dev catalog when known and
|
||||
defaults to True otherwise — the effort dial is broadly accepted and a
|
||||
no-op on models that ignore it, whereas hiding it from a capable-but-
|
||||
uncatalogued model is the worse failure.
|
||||
"""
|
||||
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, bool]] = {}
|
||||
|
||||
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
|
||||
|
||||
caps[model] = {
|
||||
"fast": bool(model_supports_fast_mode(model)),
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
|
||||
row["capabilities"] = caps
|
||||
|
||||
|
||||
# ─── Internal: row post-processing ──────────────────────────────────────
|
||||
|
||||
|
||||
def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
|
||||
"""Build skeleton rows for canonical providers missing from ``rows``."""
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
|
||||
|
||||
seen = {r["slug"].lower() for r in rows}
|
||||
cur = (ctx.current_provider or "").lower()
|
||||
extras: list[dict] = []
|
||||
for entry in CANONICAL_PROVIDERS:
|
||||
if entry.slug.lower() in seen:
|
||||
continue
|
||||
extras.append(
|
||||
{
|
||||
"slug": entry.slug,
|
||||
"name": _PROVIDER_LABELS.get(entry.slug, entry.label),
|
||||
"is_current": entry.slug.lower() == cur,
|
||||
"is_user_defined": False,
|
||||
"models": [],
|
||||
"total_models": 0,
|
||||
"source": "canonical",
|
||||
}
|
||||
)
|
||||
return extras
|
||||
|
||||
|
||||
def _apply_picker_hints(rows: list[dict]) -> None:
|
||||
"""Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.
|
||||
|
||||
Mutates ``rows`` in-place. Rows already from
|
||||
``list_authenticated_providers`` are marked ``authenticated=True``;
|
||||
the unconfigured skeleton rows from ``_append_unconfigured_rows`` get
|
||||
the picker's setup-hint shape.
|
||||
"""
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
for row in rows:
|
||||
if "authenticated" in row:
|
||||
continue
|
||||
# Distinguish authenticated rows (returned by
|
||||
# list_authenticated_providers) from skeleton rows (from
|
||||
# _append_unconfigured_rows). The skeleton rows have empty
|
||||
# `models` AND source="canonical"; authenticated rows have
|
||||
# populated `models` OR a non-canonical source.
|
||||
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
|
||||
cfg = PROVIDER_REGISTRY.get(row["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 ""
|
||||
)
|
||||
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`` declaration order;
|
||||
truly-custom rows last.
|
||||
|
||||
Keys on slug membership, NOT ``is_user_defined`` — section 3 of
|
||||
``list_authenticated_providers`` sets ``is_user_defined=True`` on
|
||||
rows from the ``providers:`` config dict even when the slug is
|
||||
canonical. Keying on the flag would silently demote canonical
|
||||
providers configured via the new keyed schema.
|
||||
"""
|
||||
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,
|
||||
) -> 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=force_fresh_nous_tier
|
||||
)
|
||||
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"] = []
|
||||
Reference in New Issue
Block a user