From 8a9b4bb2c22fd6f341543c92f7f13ab8a4fae805 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sun, 31 May 2026 05:48:04 -0400 Subject: [PATCH] feat(desktop): mirror hermes model free/paid curation in GUI onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=. 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. --- apps/desktop/src/hermes.ts | 16 +++++++ apps/desktop/src/store/onboarding.ts | 22 ++++++++- hermes_cli/web_server.py | 72 ++++++++++++++++++++++++++++ tests/hermes_cli/test_web_server.py | 62 ++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a8d32e48bef..5b2833c64e7 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -449,6 +449,22 @@ export function getGlobalModelOptions(): Promise { }) } +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 { + return window.hermesDesktop.api({ + path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` + }) +} + export function setGlobalModel( provider: string, model: string diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 1e68064e873..1754d498b20 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -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 } } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 305784fc96d..4d7ca1330f7 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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. diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e4cf30e18ea..f0906a050f9 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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