mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
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:
parent
c3a21c5d49
commit
8a9b4bb2c2
4 changed files with 171 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue