feat(desktop): mirror hermes model free/paid curation in GUI onboarding

GUI onboarding picked models[0] from /api/model/options, which ignores the
Nous free/paid tier — a free user could land on a paid default (e.g.
anthropic/claude-opus-4). Now the recommended default mirrors what `hermes
model` does.

- web_server.py: new GET /api/model/recommended-default?provider=<slug>. For
  Nous it runs the same curation as the CLI (get_curated_nous_model_ids +
  pricing + check_nous_free_tier + union_with_portal_{free,paid}_recommendations
  + partition_nous_models_by_tier) so free users get a free model and paid users
  get the curated default. Other providers fall back to the first curated model.
  Never 500s — returns empty model on error so onboarding degrades gracefully.
- hermes.ts: getRecommendedDefaultModel client + RecommendedDefaultModel type.
- onboarding.ts: fetchProviderDefaultModel prefers the recommended endpoint,
  falls back to models[0] when unavailable.
- tests: free-tier picks free model, paid-tier picks curated default, failure
  returns empty without 500.
This commit is contained in:
emozilla
2026-05-31 05:48:04 -04:00
parent c3a21c5d49
commit 8a9b4bb2c2
4 changed files with 171 additions and 1 deletions
+72
View File
@@ -1317,6 +1317,78 @@ def get_model_options():
raise HTTPException(status_code=500, detail="Failed to list model options")
@app.get("/api/model/recommended-default")
def get_recommended_default_model(provider: str = ""):
"""Return the recommended default model for a freshly-authenticated provider.
Mirrors the model-curation `hermes model` does so GUI onboarding lands on a
sensible default instead of blindly taking the first curated entry. For
Nous this honors the user's free/paid tier: free users get a free model,
paid users get the full curated default. For any other provider it falls
back to the first curated model (same as before).
Response: {"provider": str, "model": str, "free_tier": bool | None}
where free_tier is True/False for Nous and None otherwise. `model` may be
empty if nothing could be resolved (caller degrades gracefully).
"""
slug = (provider or "").strip().lower()
if slug == "nous":
try:
from hermes_cli.models import (
get_curated_nous_model_ids,
get_pricing_for_provider,
check_nous_free_tier,
partition_nous_models_by_tier,
union_with_portal_free_recommendations,
union_with_portal_paid_recommendations,
)
from hermes_cli.auth import get_provider_auth_state
model_ids = get_curated_nous_model_ids()
pricing = get_pricing_for_provider("nous") or {}
free_tier = check_nous_free_tier(force_fresh=True)
portal_url = ""
try:
state = get_provider_auth_state("nous") or {}
portal_url = state.get("portal_base_url", "") or ""
except Exception:
portal_url = ""
if free_tier:
model_ids, pricing = union_with_portal_free_recommendations(
model_ids, pricing, portal_url
)
model_ids, _unavailable = partition_nous_models_by_tier(
model_ids, pricing, free_tier=True
)
else:
model_ids, pricing = union_with_portal_paid_recommendations(
model_ids, pricing, portal_url
)
model = model_ids[0] if model_ids else ""
return {"provider": "nous", "model": model, "free_tier": bool(free_tier)}
except Exception:
_log.exception("GET /api/model/recommended-default (nous) failed")
return {"provider": "nous", "model": "", "free_tier": None}
# Non-Nous: first curated model for the provider, matching prior behaviour.
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
payload = build_models_payload(load_picker_context(), max_models=50)
for row in payload.get("providers", []):
if str(row.get("slug", "")).lower() == slug:
models = row.get("models") or []
return {"provider": slug, "model": models[0] if models else "", "free_tier": None}
return {"provider": slug, "model": "", "free_tier": None}
except Exception:
_log.exception("GET /api/model/recommended-default failed")
return {"provider": slug, "model": "", "free_tier": None}
@app.get("/api/model/auxiliary")
def get_auxiliary_models():
"""Return current auxiliary task assignments.