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:
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user