Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c827454
fix(auth): close the free tier's gaps against the gateway's welcome-t…
rewbs Sep 8, 2026
c75554d
feat(auth): the free tier is set up on request; nous.guest_setup deci…
rewbs Sep 8, 2026
8375015
feat(auth): the guided setup provisions the free tier explicitly; nou…
rewbs Sep 8, 2026
2d0a870
fix(auth): remove the nous.guest_setup knob; the free tier is created…
alt-glitch Sep 9, 2026
3e64410
fix(gateway): a server-driven model switch off nous/welcome does not …
alt-glitch Sep 9, 2026
479ece6
fix(auth): the free tier outranks implicit host credentials in provid…
alt-glitch Sep 9, 2026
a346a0f
fix(auth): review follow-ups for the free-tier rung (NS-829)
alt-glitch Sep 9, 2026
dd09b33
feat(auth): HERMES_GUEST_ONBOARDING=1 is the one launch gate for the …
alt-glitch Sep 10, 2026
57c99de
feat(auth): the free-tier identity is created in one place, at boot; …
alt-glitch Sep 10, 2026
b9bc170
fix(credits): the welcome host is free-tier evidence, so a free-tier …
alt-glitch Sep 10, 2026
0e272bd
fix(copy): free-tier text stops promising a connector transfer and ne…
alt-glitch Sep 10, 2026
6df20a3
feat(desktop): the free-tier launch flag is decided once in Electron …
alt-glitch Sep 10, 2026
3a0eb5d
feat(desktop): the renderer learns free-tier readiness from one `setu…
alt-glitch Sep 10, 2026
c25611b
fix(cli): the banner names the free tier's model instead of "no model…
alt-glitch Sep 10, 2026
da4be17
fix(aux): vision on the free tier uses nous/welcome too
rewbs Sep 8, 2026
8b31d1b
fix(gateway): hermes gateway run is a boot owner of the free tier too
alt-glitch Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2234,13 +2234,36 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
return None, None
if runtime is None and nous:
logger.debug("Auxiliary Nous: runtime JWT refresh failed; checking stored auth.json token.")
if runtime is not None:
api_key, base_url = runtime
else:
api_key = _nous_api_key(nous or {})
if not api_key:
logger.warning(
"Auxiliary Nous client unavailable: no usable inference JWT found "
"(run: hermes auth add nous)."
)
_mark_provider_unhealthy("nous", ttl=60)
return None, None
base_url = str(
(nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL)
).rstrip("/")
lane = "vision" if vision else "text"
# The free tier's host serves exactly one model, for every lane: asking it for the Portal's
# recommended aux model is a guaranteed 429 ``model_not_free``. Pin the route's model instead.
# Vision rides the same id (the backing model is multimodal; a backing that is not answers
# the request with the upstream's own error, which the ladder handles like any other).
from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host
global auxiliary_is_nous
if route_is_welcome_host(base_url):
auxiliary_is_nous = True
logger.debug("Auxiliary/%s: Nous free tier; using %s", lane, GUEST_MODEL)
return _create_openai_client(api_key=api_key, base_url=base_url), GUEST_MODEL
auxiliary_is_nous = True
logger.debug("Auxiliary client: Nous Portal")
# Portal recommended-models is authoritative (tier-aware); _NOUS_MODEL when unreachable/null.
# Probes skip the lookup: exact model is irrelevant and it hits the network.
model = _NOUS_MODEL
lane = "vision" if vision else "text"
if not _aux_probe_active():
try:
from hermes_cli.models import get_nous_recommended_aux_model
Expand All @@ -2256,20 +2279,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
"falling back to %s",
lane, exc, model,
)
if runtime is not None:
api_key, base_url = runtime
else:
api_key = _nous_api_key(nous or {})
if not api_key:
logger.warning(
"Auxiliary Nous client unavailable: no usable inference JWT found "
"(run: hermes auth add nous)."
)
_mark_provider_unhealthy("nous", ttl=60)
return None, None
base_url = str(
(nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL)
).rstrip("/")
return _create_openai_client(api_key=api_key, base_url=base_url), model


Expand Down Expand Up @@ -2998,7 +3007,7 @@ def _contains_any(text: str, needles: Tuple[str, ...]) -> bool:
_PAYMENT_KEYWORDS = (
"credits", "insufficient funds", "can only afford", "billing", "payment required",
"out of funds", "run out of funds", "balance_depleted", "no usable credits",
"model_not_supported_on_free_tier", "not available on the free tier",
"model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier",
"requires a subscription", "upgrade for access", "upgrade for higher limits",
"reached your session usage limit", "quota exceeded", "quota_exceeded",
"too many tokens per day", "daily limit", "tokens per day", "daily quota", "resource exhausted",
Expand Down Expand Up @@ -3031,7 +3040,7 @@ def _nous_portal_account_has_fresh_paid_access() -> bool:
_RATE_LIMIT_BILLING_KEYWORDS = (
"credits", "insufficient funds", "billing", "payment required", "can only afford",
"out of funds", "run out of funds", "balance_depleted", "no usable credits",
"model_not_supported_on_free_tier", "not available on the free tier",
"model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier",
)


Expand Down
1 change: 1 addition & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2707,6 +2707,7 @@ def _chat_stream_created(self, raw_stream: Any) -> None:
response = self._attempt_stream_response = getattr(raw_stream, "response", None)
self.agent._capture_rate_limits(response)
self.agent._capture_credits(response)
self.agent._capture_nous_model_switch(response)
self.agent._stream_diag_capture_response(self.clients.diag, response)
self.agent._check_openrouter_cache_status(response)
self._writer_token = claim_stream_writer(self.agent)
Expand Down
16 changes: 16 additions & 0 deletions agent/credits_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ def _sticky_notice(text: str, level: str, key: str) -> AgentNotice:
return AgentNotice(text=text, level=level, kind=CREDITS_NOTICE_KIND, key=key, id=key)


def _is_nous_welcome_route(base_url: str) -> bool:
"""True when *base_url* is the Nous welcome host, which serves only the free tier. Local data only;
False wherever the free tier is not built in. The host is the evidence, not the model name: the paid
inference host can serve ``nous/welcome`` to a named account, and that account's depletion is real."""
try:
from hermes_cli.anon_auth import route_is_welcome_host
except ImportError:
return False
return route_is_welcome_host(base_url)


def is_free_tier_model(model: str, base_url: str = "") -> bool:
"""True when *model* is a Nous free-tier model, using ONLY local data: (1) ``:free`` suffix — canonical
Nous free SKU marker; (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix
Expand All @@ -130,6 +141,11 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool:
return True
if not base_url:
return False
# (4) the Nous free tier: the welcome host serves only the free tier. A free-tier identity carries $0
# by design, so the portal seed reports paid_access=False for it; that is not a depleted account, and
# "run /topup" means nothing to it. Local data only, same as the rules above.
if _is_nous_welcome_route(base_url):
return True
try:
from hermes_cli.models import _is_model_free
from hermes_cli.models_pricing import peek_cached_pricing
Expand Down
36 changes: 36 additions & 0 deletions agent/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import enum
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Iterator, Optional, Sequence

Expand Down Expand Up @@ -514,9 +515,44 @@ def _plugin_verdict(c: _Ctx) -> Optional[Verdict]:
return verdict


def _nous_welcome_tier(c: _Ctx) -> Optional[Verdict]:
"""The Nous inference gateway's welcome-tier (free tier) refusals, read from the structured body.

A 429 carrying a fairshare ``reason`` is either a tier gate (``model_not_free`` /
``feature_not_free``: the model or feature is never served on the free tier, so retrying is
pointless — abort this route and fall back) or capacity (``at_capacity`` / ``admission_closed``
/ ``rate_limited``: honour ``retry_after``, never rotate the free tier's only credential). A
400/403 whose message names the wrong host or a dark tier is deterministic for the request.
The parsed refusal rides ``error_context`` so the terminal copy can say what happened.
"""
from hermes_cli.anon_auth import (
WELCOME_TIER_GATE_REASONS, parse_welcome_refusal, welcome_route_refusal)
status = c.status_code
if status == 429:
refusal = parse_welcome_refusal(c.body)
if refusal is None:
return None
ctx = {"welcome_refusal": refusal}
if refusal["reason"] in WELCOME_TIER_GATE_REASONS:
return _v(_R.model_not_found, retryable=False, should_fallback=True, error_context=ctx)
if refusal["retry_after"] > 0:
ctx["reset_at"] = time.time() + refusal["retry_after"]
return _v(_R.rate_limit, should_fallback=True, error_context=ctx)
kind = welcome_route_refusal(status, c.msg)
if kind is None:
return None
ctx = {"welcome_route": kind}
if status == 403:
return _v(_R.auth_permanent, retryable=False, should_fallback=True, error_context=ctx)
return _v(_R.format_error, retryable=False, should_fallback=True, error_context=ctx)


def _provider_special_cases(c: _Ctx) -> Optional[Verdict]:
"""Highest-priority provider-specific shapes that a status code would misroute."""
msg, status = c.msg, c.status_code
welcome = _nous_welcome_tier(c)
if welcome is not None:
return welcome
# Safety refusal before status classification so a 400 block isn't downgraded
# to format_error and a status-less block isn't left retryable (#18028).
if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS):
Expand Down
13 changes: 13 additions & 0 deletions agent/rate_limit_credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ def get_rate_limit_state(self):
"""Return the last captured RateLimitState, or None."""
return self._rate_limit_state

def _capture_nous_model_switch(self, http_response: Any) -> None:
"""Record the Nous gateway's ``x-nous-model-switch`` header (a named account asked for the
free tier's model; the gateway served its backing model and named it). Applied between
calls by ``hermes_cli.anon_auth.apply_model_switch``. Fail-open."""
headers = _response_headers(http_response)
if not headers:
return
try:
from hermes_cli.anon_auth import note_model_switch
note_model_switch(self, headers)
except Exception:
pass # Never let header parsing break the agent loop

def _capture_anthropic_response_headers(self, http_response: Any) -> None:
"""Capture rate-limit + credits state from Anthropic Messages response headers (the SDK's
aggregated ``Message`` drops them). Fail-open."""
Expand Down
7 changes: 7 additions & 0 deletions agent/turn_api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> NousRateGu
)

if agent.provider == "nous":
# A gateway ``x-nous-model-switch`` recorded on the previous response moves this session
# (and the config default, when it still names the free tier's model) before the next call.
try:
from hermes_cli.anon_auth import apply_model_switch
apply_model_switch(agent)
except Exception:
pass
try:
from agent.nous_rate_guard import (
nous_rate_limit_remaining, format_remaining as _fmt_nous_remaining
Expand Down
30 changes: 28 additions & 2 deletions agent/turn_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,19 @@ def _print_nonretryable_auth_guidance(
_vlines(agent, " • Check credits: https://openrouter.ai/settings/credits")


def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool) -> str:
"""Copy for a Nous free-tier refusal the classifier parsed (``welcome_refusal`` /
``welcome_route`` in ``error_context``); empty for every other error."""
ctx = getattr(classified, "error_context", None) or {}
refusal, route = ctx.get("welcome_refusal"), ctx.get("welcome_route")
if not refusal and not route:
return ""
from hermes_cli.anon_auth import welcome_refusal_copy, welcome_route_refusal_copy
if refusal:
return welcome_refusal_copy(refusal, model=str(model or ""), in_chat=in_chat)
return welcome_route_refusal_copy(str(route), in_chat=in_chat)


# Terminal status label per non-retryable reason (default names the HTTP status).
_NONRETRYABLE_LABELS = {
FailoverReason.content_policy_blocked: "Provider safety filter blocked this request",
Expand Down Expand Up @@ -683,7 +696,12 @@ def nonretryable_client_error_result(
f" 🔌 Provider: {provider} Model: {model}",
f" 🌐 Endpoint: {base_url}",
)
if classified.is_auth or classified.reason == FailoverReason.billing:
_welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False)
if _welcome_hint:
# A free-tier gate or a wrong-host refusal: the way forward is a sign-in or another
# provider, never the key/credits advice below.
_vlines(agent, f" 💡 {_welcome_hint}")
elif classified.is_auth or classified.reason == FailoverReason.billing:
_print_nonretryable_auth_guidance(
agent, classified, status_code=status_code, provider=provider, base_url=base_url, model=model
)
Expand Down Expand Up @@ -738,7 +756,10 @@ def nonretryable_client_error_result(
classified=classified, summary=_nonretryable_summary, messages=messages,
api_call_count=api_call_count, provider=provider, base_url=base_url, model=model,
)
result = _failed_turn_result(_nonretryable_summary, messages, api_call_count, _nonretryable_summary)
_final_response = _nonretryable_summary
if _welcome_hint:
_final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}"
result = _failed_turn_result(_final_response, messages, api_call_count, _nonretryable_summary)
# Same verdict fields as the max-retries path: without them the UI descriptor
# (agent/error_surface.py) reads a rejected OAuth token as a retryable
# "Provider error" and offers Retry instead of a re-login.
Expand Down Expand Up @@ -795,6 +816,9 @@ def max_retries_exhausted_result(
else:
agent._emit_status(f"❌ API failed after {max_retries} retries — {_final_summary}")
_vlines(agent, f" 💀 Final error: {_final_summary}")
_welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False)
if _welcome_hint:
_vlines(agent, f" 💡 {_welcome_hint}")

# SSE stream-drop (e.g. "Network connection lost"): usually a proxy/CDN cutting a very
# large tool call mid-response.
Expand Down Expand Up @@ -849,6 +873,8 @@ def max_retries_exhausted_result(
)
else:
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
if _welcome_hint:
_final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}"
if _is_thinking_timeout:
# Thinking-timeout guidance overrides stream-drop guidance, which would wrongly
# suggest splitting large file writes.
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/electron/guest-onboarding-flag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'

import { test } from 'vitest'

import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding'
import { buildSpawnCommand } from './remote-lifecycle'

test('guestOnboardingEnabled: exactly "1" in env or --guest-onboarding on argv turns the free tier on', () => {
assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '1' }), true)
assert.equal(guestOnboardingEnabled(['electron', '.', '--guest-onboarding'], {}), true)

assert.equal(guestOnboardingEnabled([], {}), false)
assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: 'true' }), false)
assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '0' }), false)
assert.equal(guestOnboardingEnabled(['electron', '.', '--local'], { HERMES_GUEST_ONBOARDING: '' }), false)
})

test('desktopBackendSpawnEnv stamps the launch decision last and never lets an inherited value leak', () => {
const base = {
HERMES_HOME: '/tmp/home',
HERMES_DESKTOP: '1',
HERMES_GUEST_ONBOARDING: '1',
PATH: '/usr/bin'
}

const on = desktopBackendSpawnEnv({ ...base, HERMES_GUEST_ONBOARDING: '0' }, true)
assert.equal(on.HERMES_GUEST_ONBOARDING, '1')

const off = desktopBackendSpawnEnv(base, false)
assert.equal(off.HERMES_GUEST_ONBOARDING, '0', 'a stray inherited "1" must not turn the free tier on')

for (const env of [on, off]) {
assert.equal(env.HERMES_HOME, base.HERMES_HOME)
assert.equal(env.HERMES_DESKTOP, base.HERMES_DESKTOP)
assert.equal(env.PATH, base.PATH)
}
})

test('remote SSH spawn command carries HERMES_GUEST_ONBOARDING=1 only when the launch decided on', () => {
const on = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: true })
assert.match(on, /exec env HERMES_DESKTOP=1 HERMES_GUEST_ONBOARDING=1 /)

const off = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: false })
assert.match(off, /exec env HERMES_DESKTOP=1 /)
assert.doesNotMatch(off, /HERMES_GUEST_ONBOARDING/)

const unset = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log' })
assert.doesNotMatch(unset, /HERMES_GUEST_ONBOARDING/)
})
23 changes: 23 additions & 0 deletions apps/desktop/electron/guest-onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// The Nous free tier is gated by ONE launch-time decision. The Python backend
// reads HERMES_GUEST_ONBOARDING and treats exactly "1" as on; the desktop
// decides once at launch (env or `--guest-onboarding` argv) and stamps that
// answer onto every backend it spawns, so the app and its backends can never
// disagree about whether the free tier is live.

export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING'
export const GUEST_ONBOARDING_FLAG = '--guest-onboarding'

export function guestOnboardingEnabled(
argv: readonly string[] = process.argv,
env: NodeJS.ProcessEnv = process.env
): boolean {
return env[GUEST_ONBOARDING_ENV] === '1' || argv.includes(GUEST_ONBOARDING_FLAG)
}

// Outermost wrapper for a backend spawn env: the flag is written LAST so no
// earlier spread (process.env, backend.env) can resurrect a stray value, and
// "off" is an explicit '0' rather than an absent key so a '1' inherited from
// the parent's environment cannot leak into a backend the launch decided off.
export function desktopBackendSpawnEnv(base: NodeJS.ProcessEnv, guestOnboarding: boolean): NodeJS.ProcessEnv {
return { ...base, [GUEST_ONBOARDING_ENV]: guestOnboarding ? '1' : '0' }
}
Loading
Loading