fix(auth): a sign-in from the free tier settles the default model and route (#105259)

* fix(auth): a sign-in from the free tier settles the default model and route once, for every caller

Picking the free-tier row leaves model.default at nous/welcome pinned to the welcome host. After a
sign-in an account cannot keep either: the welcome host refuses account tokens, and the portal host
serves nous/welcome as a paid model. One completion step, anon_auth.settle_after_upgrade, now runs
after the account is persisted: a config on the free tier's route moves to the account's inference
host and the recommended default for the account's plan, through the same config write a plain
Nous login uses; a config on the user's own model is left alone. The pick is the one
GET /api/model/recommended-default already makes, factored into models.recommended_nous_default_model
so the CLI and the desktop land on the same model. hermes auth upgrade prints the new default.

* fix(auth): a sign-in completion with no eligible recommendation leaves no default model

The static provider-wide default is not narrowed by the account's plan or org policy, so writing it
as a fallback could persist a model the account may not use. When the recommendation cannot yield a
model, the route still moves to the account's host but model.default is left unset; the CLI says so
and points at `hermes model`.

* docs(free-tier): say what happens when no recommendation is available after sign-in

* fix(auth): sign-in completion moves the host and clears the default in one config write

Two writes could fail between them and leave the account host paired with nous/welcome.
_update_config_for_provider gains clear_default so the caller with no model to offer removes
model.default in the same atomic write that sets the host.
This commit is contained in:
Siddharth Balyan
2026-09-11 03:45:31 +05:30
committed by GitHub
parent a2db110ccc
commit 04a76c4109
7 changed files with 176 additions and 27 deletions
+7
View File
@@ -130,3 +130,10 @@ matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87
every `get_hermes_home()` scopes to the active profile (rules in root). Profiles are independent
islands by design — no live config inheritance; `--clone` copies at creation. Multiplex
(`gateway.multiplex_profiles`) secret-scope rules: `gateway/AGENTS.md`.
## Nous free tier (`hermes_cli/anon_auth.py`)
Sign-in completion is one function, `settle_after_upgrade`, called by every caller that persists an
account over a free-tier identity (CLI `upgrade_guest`, the desktop poller): it moves a config on the
welcome route to the account's host and the tier's recommended default
(`models.recommended_nous_default_model`, shared with `GET /api/model/recommended-default`).
+57
View File
@@ -560,6 +560,59 @@ def _account_state_from_token(
return state
def settle_after_upgrade(account_state: Dict[str, Any]) -> Dict[str, Any]:
"""After a sign-in from the free tier persisted the account: move the config off the free tier's route.
Picking the free-tier row may have written ``model.default: nous/welcome`` and ``model.base_url``
= welcome host. An account cannot keep either: the welcome host refuses account tokens, and the
portal host serves ``nous/welcome`` as a paid model. When the config is on the free tier's route,
``model.base_url`` becomes the account's inference host and ``model.default`` the recommended
default for the account's tier (:func:`hermes_cli.models.recommended_nous_default_model`, the
same pick as ``GET /api/model/recommended-default``), through the same config write a plain Nous
login uses. A config on the user's own model and host is left alone.
Every sign-in completion (CLI ``hermes auth upgrade``, the desktop poller) calls this once, after
``persist_nous_credentials``. Returns ``{"model": str, "changed": bool}``: ``model`` is the default
the config now carries (``""`` when it carries none); ``changed`` says whether this call wrote it.
Never raises: a failed pick or write is logged and reported as ``changed: False`` so the sign-in
itself still counts.
"""
from hermes_cli.config import load_config_readonly
try:
raw = load_config_readonly().get("model")
except Exception as exc:
logger.warning("sign-in completion: config unreadable, default model left as is: %s", exc)
return {"model": "", "changed": False}
model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {})
current = str(model_cfg.get("default") or "").strip()
on_welcome_model = current == GUEST_MODEL
on_welcome_host = route_is_welcome_host(model_cfg.get("base_url"))
if not (on_welcome_model or on_welcome_host):
return {"model": current, "changed": False}
model = current
if on_welcome_model:
from hermes_cli.models import recommended_nous_default_model
try:
model = str(recommended_nous_default_model().get("model") or "")
except Exception as exc:
logger.debug("sign-in completion: recommended default unavailable: %s", exc)
model = ""
try:
from hermes_cli.auth import _update_config_for_provider
# One write: host and default move together, so a failure leaves the config as it was
# rather than the account host paired with the welcome model. No eligible recommendation
# (Portal unreachable, or the plan and org policy admit nothing) clears the default in that
# same write; the runtime's silent default applies until the user picks one with `hermes model`.
_update_config_for_provider(
"nous", str(account_state.get("inference_base_url") or ""),
default_model=model if on_welcome_model else None,
clear_default=on_welcome_model and not model)
except Exception as exc:
logger.warning("sign-in completion: could not update the default model: %s", exc)
return {"model": current, "changed": False}
return {"model": model, "changed": True}
def _print_promotion_outcome(outcome: Dict[str, Any]) -> None:
status = str(outcome.get("status") or "unknown")
reason = str(outcome.get("reason") or "")
@@ -645,6 +698,10 @@ def upgrade_guest(args) -> int:
print(f"Sign-in failed: {exc}")
return 1
persist_nous_credentials(account_state)
settled = settle_after_upgrade(account_state)
email = str(outcome.get("account_email") or "").strip()
print(f"Signed in as {email}. Your connectors are kept." if email else "Signed in. Your connectors are kept.")
if settled["changed"]:
print(f"Default model is now {settled['model']}." if settled["model"]
else "No default model is set yet; run `hermes model` to pick one.")
return 0
+7 -2
View File
@@ -2128,12 +2128,15 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str,
# ── CLI Commands — login / logout ───────────────────────────────────────────────────────────────────
def _update_config_for_provider(
provider_id: str, inference_base_url: str, default_model: Optional[str] = None) -> Path:
provider_id: str, inference_base_url: str, default_model: Optional[str] = None,
*, clear_default: bool = False) -> Path:
"""Update config.yaml and auth.json to reflect the active provider.
*default_model*, when given, is written as ``model.default`` in the same step so the gateway
(which re-reads config per message) can't pick up the new provider before model selection
finishes and send an OpenRouter-style ``vendor/model`` name to a direct API."""
finishes and send an OpenRouter-style ``vendor/model`` name to a direct API. *clear_default*
removes ``model.default`` in that same write, for a caller that has no model to offer and must
not leave the previous provider's model paired with the new host."""
with _auth_store_lock(): # so auto-resolution picks this provider
auth_store = _load_auth_store()
auth_store["active_provider"] = provider_id
@@ -2165,6 +2168,8 @@ def _update_config_for_provider(
cur_default = model_cfg.get("default", "")
if not cur_default or "/" in cur_default:
model_cfg["default"] = default_model
elif clear_default:
model_cfg.pop("default", None)
config["model"] = model_cfg
atomic_yaml_write(config_path, config, sort_keys=False)
return config_path
+31
View File
@@ -440,6 +440,37 @@ def pick_silent_default_model(model_ids: list[str], provider: str = "openrouter"
return preferred if preferred in model_ids else (model_ids[0] if model_ids else "")
def recommended_nous_default_model() -> dict[str, Any]:
"""The model a Nous account lands on without choosing one, honouring the account's tier.
Curated catalog plus the Portal's recommendations for the tier, narrowed to the org's policy,
then (free tier) to the rows the tier may select, then :func:`pick_silent_default_model`.
Contacts the Portal for a fresh tier read, so never call it on a hot path. Returns
``{"provider": "nous", "model": str, "free_tier": bool}``; ``model`` may be ``""`` when nothing
is selectable (callers degrade). Shared by ``GET /api/model/recommended-default`` and the
sign-in completion in ``hermes_cli.anon_auth`` so both land on the same model.
"""
from hermes_cli import models_pricing as mp
from hermes_cli.auth import get_provider_auth_state
model_ids = get_curated_nous_model_ids()
pricing = mp.get_pricing_for_provider("nous") or {}
free_tier = check_nous_free_tier(force_fresh=True)
try:
portal_url = (get_provider_auth_state("nous") or {}).get("portal_base_url", "") or ""
except Exception:
portal_url = ""
# Narrow to policy BEFORE the tier split, so a rescued id still has to pass the free/paid predicate.
policy_allowed = mp.nous_policy_allowed_ids()
union = union_with_portal_free_recommendations if free_tier else union_with_portal_paid_recommendations
model_ids, pricing = union(model_ids, pricing, portal_url)
model_ids = mp.restrict_to_nous_policy(model_ids, policy_allowed, rescue_empty=True)
if free_tier:
model_ids, _unavailable = partition_nous_models_by_tier(model_ids, pricing, free_tier=True)
return {"provider": "nous", "model": pick_silent_default_model(model_ids, provider="nous"),
"free_tier": bool(free_tier)}
def get_default_model_for_provider(provider: str) -> str:
"""Cost-safe default model for a provider, or "" — the NON-INTERACTIVE fallback when a provider
is configured but no model was ever selected."""
+2 -25
View File
@@ -131,31 +131,8 @@ async def get_model_options(
def _nous_recommended_default() -> dict:
from hermes_cli import models as m
from hermes_cli import models_pricing as mp
from hermes_cli.auth import get_provider_auth_state
model_ids = m.get_curated_nous_model_ids()
pricing = mp.get_pricing_for_provider("nous") or {}
free_tier = m.check_nous_free_tier(force_fresh=True)
try:
portal_url = (get_provider_auth_state("nous") or {}).get("portal_base_url", "") or ""
except Exception:
portal_url = ""
# This endpoint picks the model a user lands on without choosing it, so an unreachable
# one is worse than in a picker. Narrow to policy BEFORE the tier split, so a rescued
# id still has to pass the free/paid predicate.
policy_allowed = mp.nous_policy_allowed_ids()
union = m.union_with_portal_free_recommendations if free_tier else m.union_with_portal_paid_recommendations
model_ids, pricing = union(model_ids, pricing, portal_url)
model_ids = mp.restrict_to_nous_policy(model_ids, policy_allowed, rescue_empty=True)
if free_tier:
model_ids, _unavailable = m.partition_nous_models_by_tier(model_ids, pricing, free_tier=True)
model = m.pick_silent_default_model(model_ids, provider="nous")
return {"provider": "nous", "model": model, "free_tier": bool(free_tier)}
from hermes_cli.models import recommended_nous_default_model
return recommended_nous_default_model()
@router.get("/api/model/recommended-default")
+66
View File
@@ -176,3 +176,69 @@ class TestUpgrade:
shared = _shared_store(tmp_path)
assert shared.get("refresh_token") == REFRESH_TOKEN
assert "anon_token" not in shared
FREE_PICK = "upstage/solar-pro4:free"
@pytest.fixture
def free_account(monkeypatch):
"""The signed-in account is a $0 (free-plan) account: the Portal's tier read and its recommended
free list are the only network egress the default pick has, stubbed at their seams."""
from hermes_cli import models as m
from hermes_cli import models_pricing as mp
monkeypatch.setattr(m, "check_nous_free_tier", lambda **kw: True)
monkeypatch.setattr(m, "fetch_nous_recommended_models", lambda *a, **kw: {
"freeRecommendedModels": [{"modelName": FREE_PICK}]})
monkeypatch.setattr(mp, "get_pricing_for_provider", lambda *a, **kw: {})
monkeypatch.setattr(mp, "nous_policy_allowed_ids", lambda **kw: None)
def _write_model_config(model_cfg: dict) -> None:
from hermes_cli.config import load_config, save_config
config = load_config()
config["model"] = model_cfg
save_config(config)
def _model_config() -> dict:
from hermes_cli.config import load_config_readonly
return dict(load_config_readonly().get("model") or {})
class TestSignInCompletionSettlesTheModel:
def test_config_on_the_free_tier_route_moves_to_the_account_host_and_the_recommended_free_model(
self, portal, free_account, capsys):
anon_auth.ensure_portal_identity(blocking=True)
# What picking the free-tier row leaves behind: the welcome model pinned to the welcome host.
_write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME})
assert anon_auth.upgrade_guest(_args()) == 0
model_cfg = _model_config()
assert model_cfg["default"] == FREE_PICK
assert model_cfg["base_url"] == INFERENCE.rstrip("/")
assert not anon_auth.route_is_welcome_host(model_cfg["base_url"])
assert f"Default model is now {FREE_PICK}." in capsys.readouterr().out
def test_config_on_the_users_own_model_is_left_alone(self, portal, free_account, capsys):
anon_auth.ensure_portal_identity(blocking=True)
own = {"provider": "openrouter", "default": "anthropic/claude-sonnet-4"}
_write_model_config(own)
assert anon_auth.upgrade_guest(_args()) == 0
assert {k: _model_config().get(k) for k in own} == own
assert "Default model is now" not in capsys.readouterr().out
def test_no_eligible_recommendation_leaves_no_default_rather_than_a_model_the_account_may_not_use(
self, portal, free_account, monkeypatch, capsys):
from hermes_cli import models as m
anon_auth.ensure_portal_identity(blocking=True)
_write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME})
def _portal_down():
raise RuntimeError("recommended models unavailable")
monkeypatch.setattr(m, "recommended_nous_default_model", _portal_down)
assert anon_auth.upgrade_guest(_args()) == 0
model_cfg = _model_config()
assert "default" not in model_cfg
assert model_cfg["base_url"] == INFERENCE.rstrip("/")
out = capsys.readouterr().out
assert "Default model is now" not in out
assert "run `hermes model` to pick one" in out
+6
View File
@@ -72,9 +72,15 @@ The command name is provisional and may change in a later release; the behaviour
or you are in an SSH session. Never share the code.
2. Sign in to Nous Portal in the browser and confirm.
3. Back in the terminal: `Signed in as you@example.com. Your connectors are kept.`
If your default model was `nous/welcome`, a fourth line names the model your account now
uses, for example `Default model is now upstage/solar-pro4:free.`
Connectors you linked on the free tier carry over. Inference moves to the Nous Portal catalog,
paid tools unlock, and `hermes auth status` shows your account instead of the free-tier line.
`nous/welcome` stays with the free tier: an account that was using it lands on the recommended
model for its plan (the same one a fresh `hermes model` pick would suggest), and a default model
you chose yourself is left alone. If no recommendation is available at that moment, no default is
set and Hermes tells you to run `hermes model`.
`hermes auth upgrade` is offered wherever the free tier is present, including installs that
run inference on their own API key. Signing in still unlocks paid tools for those installs.