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

View file

@ -449,6 +449,22 @@ export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
})
}
export interface RecommendedDefaultModel {
provider: string
model: string
/** True/false for Nous (free vs paid tier); null for other providers. */
free_tier: boolean | null
}
// Recommended default model for a freshly-authenticated provider. Mirrors the
// curation `hermes model` does — for Nous it honors the free/paid tier so a
// free user gets a free model instead of a paid default.
export function getRecommendedDefaultModel(provider: string): Promise<RecommendedDefaultModel> {
return window.hermesDesktop.api<RecommendedDefaultModel>({
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
})
}
export function setGlobalModel(
provider: string,
model: string

View file

@ -3,6 +3,7 @@ import { atom } from 'nanostores'
import {
cancelOAuthSession,
getGlobalModelOptions,
getRecommendedDefaultModel,
listOAuthProviders,
pollOAuthSession,
setEnvVar,
@ -204,9 +205,28 @@ async function fetchProviderDefaultModel(
return null
}
// Prefer the backend's recommended default — it mirrors the curation
// `hermes model` does (for Nous it honors the user's free/paid tier, so a
// free user gets a free model rather than a paid default like opus). Fall
// back to the first curated model if the endpoint can't resolve one.
let defaultModel = String(models[0])
try {
const recommended = await getRecommendedDefaultModel(String(matched.slug))
if (recommended.model && models.map(String).includes(recommended.model)) {
defaultModel = recommended.model
} else if (recommended.model) {
// Recommended model isn't in the curated options list (e.g. a Portal
// free-recommendation the picker list didn't include); trust it anyway.
defaultModel = recommended.model
}
} catch {
// Endpoint unavailable — keep models[0]. Non-fatal: the confirm card still
// shows and the user can change it.
}
return {
providerSlug: String(matched.slug),
defaultModel: String(models[0])
defaultModel
}
}

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.

View file

@ -670,6 +670,68 @@ class TestWebServerEndpoints:
assert data["ok"] is True
assert data.get("gateway_tools", []) == []
def test_recommended_default_nous_honors_free_tier(self, monkeypatch):
"""For a free-tier Nous user, the recommended default must be a free
model (mirroring `hermes model`), not the first curated paid entry."""
import hermes_cli.models as models_mod
monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", lambda: ["paid/expensive", "free/cheap"])
monkeypatch.setattr(
models_mod, "get_pricing_for_provider",
lambda provider: {"paid/expensive": {"input": "1"}, "free/cheap": {"input": "0"}},
)
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: True)
monkeypatch.setattr(
models_mod, "union_with_portal_free_recommendations",
lambda ids, pricing, url: (ids, pricing),
)
# Free partition keeps only the free model selectable.
monkeypatch.setattr(
models_mod, "partition_nous_models_by_tier",
lambda ids, pricing, free_tier: (["free/cheap"], ["paid/expensive"]),
)
resp = self.client.get("/api/model/recommended-default?provider=nous")
assert resp.status_code == 200
data = resp.json()
assert data["provider"] == "nous"
assert data["model"] == "free/cheap"
assert data["free_tier"] is True
def test_recommended_default_nous_paid_uses_curated_default(self, monkeypatch):
"""A paid Nous user gets the first curated/paid-augmented model."""
import hermes_cli.models as models_mod
monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", lambda: ["top/model", "other/model"])
monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda provider: {})
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: False)
monkeypatch.setattr(
models_mod, "union_with_portal_paid_recommendations",
lambda ids, pricing, url: (ids, pricing),
)
resp = self.client.get("/api/model/recommended-default?provider=nous")
assert resp.status_code == 200
data = resp.json()
assert data["provider"] == "nous"
assert data["model"] == "top/model"
assert data["free_tier"] is False
def test_recommended_default_handles_failure_gracefully(self, monkeypatch):
"""Endpoint never 500s — returns empty model on internal error."""
import hermes_cli.models as models_mod
def boom():
raise RuntimeError("portal down")
monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", boom)
resp = self.client.get("/api/model/recommended-default?provider=nous")
assert resp.status_code == 200
data = resp.json()
assert data["model"] == ""
assert data["free_tier"] is None
# ---------------------------------------------------------------------------
# _build_schema_from_config tests