feat(aux): use Portal /api/nous/recommended-models for auxiliary models
Wire the auxiliary client (compaction, vision, session search, web extract)
to the Nous Portal's curated recommended-models endpoint when running on
Nous Portal, with a TTL-cached fetch that mirrors how we pull /models for
pricing.
hermes_cli/models.py
- fetch_nous_recommended_models(portal_base_url, force_refresh=False)
10-minute TTL cache, keyed per portal URL (staging vs prod don't
collide). Public endpoint, no auth required. Returns {} on any
failure so callers always get a dict.
- get_nous_recommended_aux_model(vision, free_tier=None, ...)
Tier-aware pick from the payload:
- Paid tier → paidRecommended{Vision,Compaction}Model, falling back
to freeRecommended* when the paid field is null (common during
staged rollouts of new paid models).
- Free tier → freeRecommended* only, never leaks paid models.
When free_tier is None, auto-detects via the existing
check_nous_free_tier() helper (already cached 3 min against
/api/oauth/account). Detection errors default to paid so we never
silently downgrade a paying user.
agent/auxiliary_client.py — _try_nous()
- Replaces the hardcoded xiaomi/mimo free-tier branch with a single call
to get_nous_recommended_aux_model(vision=vision).
- Falls back to _NOUS_MODEL (google/gemini-3-flash-preview) when the
Portal is unreachable or returns a null recommendation.
- The Portal is now the source of truth for aux model selection; the
xiaomi allowlist we used to carry is effectively dead.
Tests (15 new)
- tests/hermes_cli/test_models.py::TestNousRecommendedModels
Fetch caching, per-portal keying, network failure, force_refresh;
paid-prefers-paid, paid-falls-to-free, free-never-leaks-paid,
auto-detect, detection-error → paid default, null/blank modelName
handling.
- tests/agent/test_auxiliary_client.py::TestNousAuxiliaryRefresh
_try_nous honors Portal recommendation for text + vision, falls
back to google/gemini-3-flash-preview on None or exception.
Behavior won't visibly change today — both tier recommendations currently
point at google/gemini-3-flash-preview — but the moment the Portal ships
a better paid recommendation, subscribers pick it up within 10 minutes
without a Hermes release.
This commit is contained in:
@@ -513,6 +513,157 @@ def check_nous_free_tier() -> bool:
|
||||
return False # default to paid on error — don't block users
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nous Portal recommended models
|
||||
#
|
||||
# The Portal publishes a curated list of suggested models (separated into
|
||||
# paid and free tiers) plus dedicated recommendations for compaction (text
|
||||
# summarisation / auxiliary) and vision tasks. We fetch it once per process
|
||||
# with a TTL cache so callers can ask "what's the best aux model right now?"
|
||||
# without hitting the network on every lookup.
|
||||
#
|
||||
# Shape of the response (fields we care about):
|
||||
# {
|
||||
# "paidRecommendedModels": [ {modelName, ...}, ... ],
|
||||
# "freeRecommendedModels": [ {modelName, ...}, ... ],
|
||||
# "paidRecommendedCompactionModel": {modelName, ...} | null,
|
||||
# "paidRecommendedVisionModel": {modelName, ...} | null,
|
||||
# "freeRecommendedCompactionModel": {modelName, ...} | null,
|
||||
# "freeRecommendedVisionModel": {modelName, ...} | null,
|
||||
# }
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOUS_RECOMMENDED_MODELS_PATH = "/api/nous/recommended-models"
|
||||
_NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
|
||||
# (result_dict, timestamp) keyed by portal_base_url so staging vs prod don't collide.
|
||||
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
|
||||
def fetch_nous_recommended_models(
|
||||
portal_base_url: str = "",
|
||||
timeout: float = 5.0,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch the Nous Portal's curated recommended-models payload.
|
||||
|
||||
Hits ``<portal>/api/nous/recommended-models``. The endpoint is public —
|
||||
no auth is required. Results are cached per portal URL for
|
||||
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds; pass ``force_refresh=True`` to
|
||||
bypass the cache.
|
||||
|
||||
Returns the parsed JSON dict on success, or ``{}`` on any failure
|
||||
(network, parse, non-2xx). Callers must treat missing/null fields as
|
||||
"no recommendation" and fall back to their own default.
|
||||
"""
|
||||
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
||||
now = time.monotonic()
|
||||
cached = _nous_recommended_cache.get(base)
|
||||
if not force_refresh and cached is not None:
|
||||
payload, cached_at = cached
|
||||
if now - cached_at < _NOUS_RECOMMENDED_CACHE_TTL:
|
||||
return payload
|
||||
|
||||
url = f"{base}{NOUS_RECOMMENDED_MODELS_PATH}"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
_nous_recommended_cache[base] = (data, now)
|
||||
return data
|
||||
|
||||
|
||||
def _resolve_nous_portal_url() -> str:
|
||||
"""Best-effort lookup of the Portal base URL the user is authed against."""
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
DEFAULT_NOUS_PORTAL_URL,
|
||||
get_provider_auth_state,
|
||||
)
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
portal = str(state.get("portal_base_url") or "").strip()
|
||||
if portal:
|
||||
return portal.rstrip("/")
|
||||
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
except Exception:
|
||||
return "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
def _extract_model_name(entry: Any) -> Optional[str]:
|
||||
"""Pull the ``modelName`` field from a recommended-model entry, else None."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
model_name = entry.get("modelName")
|
||||
if isinstance(model_name, str) and model_name.strip():
|
||||
return model_name.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_nous_recommended_aux_model(
|
||||
*,
|
||||
vision: bool = False,
|
||||
free_tier: Optional[bool] = None,
|
||||
portal_base_url: str = "",
|
||||
force_refresh: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Return the Portal's recommended model name for an auxiliary task.
|
||||
|
||||
Picks the best field from the Portal's recommended-models payload:
|
||||
|
||||
* ``vision=True`` → ``paidRecommendedVisionModel`` (paid tier) or
|
||||
``freeRecommendedVisionModel`` (free tier)
|
||||
* ``vision=False`` → ``paidRecommendedCompactionModel`` or
|
||||
``freeRecommendedCompactionModel``
|
||||
|
||||
When ``free_tier`` is ``None`` (default) the user's tier is auto-detected
|
||||
via :func:`check_nous_free_tier`. Pass an explicit bool to bypass the
|
||||
detection — useful for tests or when the caller already knows the tier.
|
||||
|
||||
For paid-tier users we prefer the paid recommendation but gracefully fall
|
||||
back to the free recommendation if the Portal returned ``null`` for the
|
||||
paid field (common during the staged rollout of new paid models).
|
||||
|
||||
Returns ``None`` when every candidate is missing, null, or the fetch
|
||||
fails — callers should fall back to their own default (currently
|
||||
``google/gemini-3-flash-preview``).
|
||||
"""
|
||||
base = portal_base_url or _resolve_nous_portal_url()
|
||||
payload = fetch_nous_recommended_models(base, force_refresh=force_refresh)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
if free_tier is None:
|
||||
try:
|
||||
free_tier = check_nous_free_tier()
|
||||
except Exception:
|
||||
# On any detection error, assume paid — paid users see both fields
|
||||
# anyway so this is a safe default that maximises model quality.
|
||||
free_tier = False
|
||||
|
||||
if vision:
|
||||
paid_key, free_key = "paidRecommendedVisionModel", "freeRecommendedVisionModel"
|
||||
else:
|
||||
paid_key, free_key = "paidRecommendedCompactionModel", "freeRecommendedCompactionModel"
|
||||
|
||||
# Preference order:
|
||||
# free tier → free only
|
||||
# paid tier → paid, then free (if paid field is null)
|
||||
candidates = [free_key] if free_tier else [paid_key, free_key]
|
||||
for key in candidates:
|
||||
name = _extract_model_name(payload.get(key))
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical provider list — single source of truth for provider identity.
|
||||
# Every code path that lists, displays, or iterates providers derives from
|
||||
|
||||
Reference in New Issue
Block a user