fix(free-tier): the desktop renders a free-tier refusal as its own card, not an OAuth re-login

A welcome-tier 403 classifies as auth_permanent, so the desktop's error
surface mapped it to "Your Nous Portal sign-in expired" with a Nous Portal
re-login button — the chat sentence never reached the user. Terminal results
on the free route now carry a structured free_tier block (kind + the chat
sentence); agent/error_surface.py turns it into a free_tier_<kind> code on
the provider layer with the sentence as `message`. The desktop gives those
codes their own titles, shows the backend sentence as the body, and offers
"Sign in with a Nous account" (the free-tier dialog) instead of the OAuth
re-login, with Retry only where a later send can succeed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Robin Fernandes
2026-09-15 22:40:57 +10:00
committed by kshitij
parent d89cacc25f
commit 16def7b8cc
11 changed files with 247 additions and 6 deletions
+12
View File
@@ -44,6 +44,9 @@ _REASON_TO_LAYER = {
# Failures between us and the base_url (not a provider verdict); on a
# custom/local endpoint they point at the user's endpoint config.
_TRANSPORT_REASONS = {"timeout", "ssl_cert_verification"}
# Free-tier kinds where a later send can succeed on its own (a wait, an outage clearing); the
# rest need a sign-in or another provider.
_FREE_TIER_RETRYABLE_KINDS = {"rate_limited", "at_capacity", "outage"}
# Deterministic for the request — a bare "Retry" repeats the failure. Fallback
# only: current backends stamp the classifier's verdict in ``failure_retryable``.
@@ -153,6 +156,15 @@ def build_error_surface_from_result(result: Any, provider: str = "", model: str
return _surface(LAYER_DISK, "disk_full", False, provider, model)
if result.get("billing_block") or reason in ("billing", "billing_unverified"):
return _surface(LAYER_BILLING, reason or "billing", False, provider, model)
# The Nous free tier refused or could not serve the turn (``agent/turn_recovery.py``
# stamps ``free_tier``): its own code, so a client offers the free sign-in rather than an
# OAuth re-login, and the chat sentence rides along as the card body.
if isinstance(free_tier := result.get("free_tier"), dict) and free_tier.get("kind"):
kind = str(free_tier["kind"])
surface = _surface(LAYER_PROVIDER, f"free_tier_{kind}", kind in _FREE_TIER_RETRYABLE_KINDS, provider, model)
if message := str(free_tier.get("message") or ""):
surface["message"] = message
return surface
if not reason: # failed result without a classified reason (legacy paths)
drop = _looks_like_stream_drop(error_text)
return _surface(LAYER_STREAMING if drop else LAYER_PROVIDER, "stream_drop" if drop else "unknown", True, provider, model)
+2
View File
@@ -263,6 +263,8 @@ def nous_rate_limit_guard(
"completed": False,
"failed": True,
"error": _nous_msg,
# The free tier's card body and its sign-in door (agent/error_surface.py).
**({"free_tier": {"kind": "rate_limited", "message": _nous_msg}} if _welcome else {}),
}, FailoverReason.rate_limit.value, True))
except Exception:
pass # Never let rate guard break the agent loop
+29 -2
View File
@@ -719,6 +719,27 @@ def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool) -> str
return welcome_route_refusal_copy(str(route), in_chat=in_chat)
def _welcome_surface_kind(classified: Any) -> str:
"""The free-tier failure kind a client renders its card from (``error_surface`` code
``free_tier_<kind>``): the welcome refusal's reason, or the route refusal; "" otherwise."""
ctx = getattr(classified, "error_context", None) or {}
refusal = ctx.get("welcome_refusal") if isinstance(ctx, dict) else None
if isinstance(refusal, dict):
reason = str(refusal.get("reason") or "")
return {"admission_closed": "at_capacity", "feature_not_free": "model_not_free"}.get(reason, reason) or "refused"
route = ctx.get("welcome_route") if isinstance(ctx, dict) else None
if route == "tier_disabled":
return "disabled"
return "route" if route else ""
def _stamp_free_tier(result: Dict[str, Any], kind: str, message: str) -> Dict[str, Any]:
"""Structured free-tier failure block: ``error_surface`` keys its code on ``kind`` and a client
shows ``message`` (the chat sentence) as the card body instead of its own generic copy."""
result["free_tier"] = {"kind": kind or "refused", "message": message}
return result
def _welcome_outage_copy(base_url: Any, classified: Any) -> str:
"""On the Nous free tier, a transport / server failure that outlived every retry reads as one
plain sentence (the free model is having trouble) rather than the technical summary. Empty
@@ -859,6 +880,8 @@ def nonretryable_client_error_result(
"failure_reason": classified.reason.value,
"failure_retryable": bool(classified.retryable),
})
if _welcome_hint:
_stamp_free_tier(result, _welcome_surface_kind(classified), _final_response)
return result
@@ -943,6 +966,7 @@ def max_retries_exhausted_result(
agent._persist_session(messages, conversation_history)
_billing_block = None
_billing_unverified = False
_free_tier_kind = ""
if _is_billing:
_billing_unverified = classified.billing_unverified
_final_response = _billing_terminal_label(_final_summary, _billing_unverified)
@@ -961,8 +985,9 @@ def max_retries_exhausted_result(
)
if _welcome_hint:
_final_response = _welcome_tier_guidance(classified, model=model, in_chat=True)
else:
_final_response = _welcome_outage_copy(base_url, classified) or _final_response
_free_tier_kind = _welcome_surface_kind(classified)
elif _outage := _welcome_outage_copy(base_url, classified):
_final_response, _free_tier_kind = _outage, "outage"
if _is_thinking_timeout:
# Thinking-timeout guidance overrides stream-drop guidance, which would wrongly
# suggest splitting large file writes.
@@ -985,6 +1010,8 @@ def max_retries_exhausted_result(
# Present only for billing walls: (provider, billing_url, is_nous, message).
"billing_block": _billing_block,
})
if _free_tier_kind:
_stamp_free_tier(result, _free_tier_kind, _final_response)
return result
@@ -50,6 +50,7 @@ import { markAssistantIdSpoken } from '@/lib/spoken-reply'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
import { notifyError } from '@/store/notifications'
import { startManualProviderOAuth } from '@/store/onboarding'
import { $activeGatewayProfile, normalizeProfileKey, requestFreshSession } from '@/store/profile'
@@ -639,6 +640,13 @@ const ErrorRecoveryActions: FC = () => {
startManualProviderOAuth(surface.provider, key === 'default' ? undefined : key)
}, [gatewayProfile, surface])
// The free tier's door: the same dialog the status-bar chip and the first-launch
// intro open. Signing in is free and lifts every free-tier refusal.
const signInFreeTier = useCallback(() => {
triggerHaptic('submit')
openFreeTierSignIn()
}, [])
// Reveal a local folder through Electron; `logsRoot` is the profile's
// HERMES_HOME/logs, and its parent is the Hermes data folder itself (what
// the user needs to see to free space after a disk-full failure).
@@ -714,6 +722,12 @@ const ErrorRecoveryActions: FC = () => {
{copy.errorSignInAgain(surface.providerLabel || surface.provider)}
</button>
)}
{plan.signInFreeTier && (
<button className="aui-error-action" onClick={signInFreeTier} type="button">
<KeyRound className="size-3" />
{copy.errorSignInFreeTier}
</button>
)}
{plan.updateApiKey && inRouter && (
<SettingsLinkAction
icon={<KeyRound className="size-3" />}
+31
View File
@@ -4052,6 +4052,36 @@ export const en: Translations = {
disk_full: {
title: 'Disk full',
body: 'Your disk is full, so Hermes could not save this conversation. Free some space, then retry.'
},
// Nous free tier. The body is normally the backend's own sentence (it names the wait
// and the way forward); these bodies stand in for an older backend that sent none.
free_tier_disabled: {
title: 'Using Hermes without signing in is switched off right now',
body: "Sign in with a Nous account to keep chatting, it's free and keeps the free model."
},
free_tier_rate_limited: {
title: "You've used up the allowance for chatting without signing in",
body: "It refreshes shortly. Sign in with a Nous account for a bigger allowance, it's free."
},
free_tier_at_capacity: {
title: 'Chatting without signing in is really busy right now',
body: "Sign in to skip the queue, it's free, or try again in a little while."
},
free_tier_model_not_free: {
title: "That model isn't available without signing in",
body: "Hermes uses the free model for now. Sign in with a Nous account for more models, it's free."
},
free_tier_route: {
title: "Hermes couldn't reach the free model on this route",
body: "Sign in with a Nous account, it's free, or check the NOUS_INFERENCE_BASE_URL setting."
},
free_tier_outage: {
title: 'The free model is having trouble responding right now',
body: 'Try sending your message again in a minute.'
},
free_tier_refused: {
title: "Hermes couldn't send that without signing in",
body: 'Signing in with a Nous account is free.'
}
},
errorAuthKinds: {
@@ -4076,6 +4106,7 @@ export const en: Translations = {
errorOpenHermesFolderFailed: 'Could not open the Hermes folder',
errorUpdateApiKey: 'Update API key',
errorSignInAgain: provider => `Sign in to ${provider} again`,
errorSignInFreeTier: 'Sign in with a Nous account',
errorOauthExpired: provider =>
`Your ${provider} sign-in has expired or was revoked. Sign in again to keep chatting.`,
errorOpenLogs: 'Open logs',
+2
View File
@@ -3511,6 +3511,8 @@ export interface Translations {
/** One-click recovery for an expired/revoked OAuth grant: re-runs that
* provider's sign-in flow (auth layer, authKind 'oauth'). */
errorSignInAgain: (provider: string) => string
/** Free-tier refusals: opens the free sign-in dialog (signing in is free and lifts the refusal). */
errorSignInFreeTier: string
/** Explains WHY the turn failed for an OAuth 401 — the raw body
* ("HTTP 401: User not found.") doesn't say "sign in again". */
errorOauthExpired: (provider: string) => string
+7 -2
View File
@@ -6,7 +6,7 @@
import type { ErrorCardCopy, Translations } from '@/i18n/types'
import { errorCardKey, type ErrorSurface } from './error-surface'
import { errorCardKey, type ErrorSurface, isFreeTierSurface } from './error-surface'
export interface ErrorCardText {
title: string
@@ -45,7 +45,12 @@ export function errorCardText(
if ('code' in key) {
const copy = thread.errorCodes[key.code]
return { body: render(copy.body, provider), title: render(copy.title, provider) }
// A free-tier refusal arrives with the backend's own sentence (the wait, the
// model, the way forward); the table body is only the fallback for an older backend.
return {
body: (isFreeTierSurface(surface) && surface?.message) || render(copy.body, provider),
title: render(copy.title, provider)
}
}
return { body: thread.errorLayerBodies[key.layer], title: thread.errorLayers[key.layer] }
@@ -138,3 +138,43 @@ describe('error copy never names a hidden Retry', () => {
expect(errorRecoveryPlan(surface).retry).toBe(true)
})
})
describe('free-tier refusals', () => {
const surface = parseErrorSurface({
code: 'free_tier_disabled',
layer: 'provider',
message: ' Using Hermes without signing in is switched off right now. To sign in: /login. ',
provider: 'nous',
retryable: false
})!
it('carries the backend sentence and offers the free sign-in, never an OAuth re-login', () => {
expect(surface.message).toBe('Using Hermes without signing in is switched off right now. To sign in: /login.')
const plan = errorRecoveryPlan(surface)
expect(plan.signInFreeTier).toBe(true)
expect(plan.signInAgain).toBe(false)
expect(plan.retry).toBe(false)
expect(plan.switchProvider).toBe(true)
})
it('renders the backend sentence as the body under its own title', () => {
const { body, title } = errorCardText(en.assistant.thread, surface)
expect(title).toBe(en.assistant.thread.errorCodes.free_tier_disabled.title)
expect(body).toBe(surface.message)
})
it('falls back to the table body when an older backend sent no sentence', () => {
const bare = parseErrorSurface({ code: 'free_tier_rate_limited', layer: 'provider', retryable: true })!
expect(errorCardText(en.assistant.thread, bare).body).toBe(en.assistant.thread.errorCodes.free_tier_rate_limited.body)
expect(errorRecoveryPlan(bare).retry).toBe(true)
})
it('every free-tier code has copy and the copy never blames the free model', () => {
for (const code of ERROR_CODE_KEYS.filter(key => key.startsWith('free_tier_'))) {
const copy = en.assistant.thread.errorCodes[code]
const text = `${typeof copy.title === 'string' ? copy.title : ''} ${typeof copy.body === 'string' ? copy.body : ''}`.toLowerCase()
expect(text).not.toMatch(/free (service|model|tier) is (off|switched off|unavailable|down)/)
expect(text).not.toMatch(/anonymous|guest|credential|token|rate limit/)
}
})
})
+25 -2
View File
@@ -46,7 +46,16 @@ export const ERROR_CODE_KEYS = [
'empty_response',
'loop_error',
'SESSION_NOT_OWNED',
'disk_full'
'disk_full',
// The Nous free tier refused or could not serve the turn (agent/error_surface.py
// `free_tier_<kind>`). The backend's sentence rides in `message` and is the card body.
'free_tier_disabled',
'free_tier_rate_limited',
'free_tier_at_capacity',
'free_tier_model_not_free',
'free_tier_route',
'free_tier_outage',
'free_tier_refused'
] as const
export type ErrorCodeKey = (typeof ERROR_CODE_KEYS)[number]
@@ -72,6 +81,9 @@ export interface ErrorSurface {
* (OPENAI_API_KEY). Deep-links Settings → Keys to that row. Absent from
* older backends. */
apiKeyEnv?: string
/** Free-tier codes: the backend's own plain sentence for this failure (it
* names the wait, the model, the way forward). Shown as the card body. */
message?: string
}
/** Validate a wire payload into an ErrorSurface, or null when absent/garbled. */
@@ -85,6 +97,7 @@ export function parseErrorSurface(value: unknown): ErrorSurface | null {
auth_kind?: unknown
code?: unknown
layer?: unknown
message?: unknown
model?: unknown
provider?: unknown
provider_label?: unknown
@@ -105,10 +118,17 @@ export function parseErrorSurface(value: unknown): ErrorSurface | null {
...(typeof raw.model === 'string' && raw.model ? { model: raw.model } : {}),
...(raw.auth_kind === 'oauth' || raw.auth_kind === 'api_key' ? { authKind: raw.auth_kind } : {}),
...(typeof raw.provider_label === 'string' && raw.provider_label ? { providerLabel: raw.provider_label } : {}),
...(typeof raw.api_key_env === 'string' && raw.api_key_env ? { apiKeyEnv: raw.api_key_env } : {})
...(typeof raw.api_key_env === 'string' && raw.api_key_env ? { apiKeyEnv: raw.api_key_env } : {}),
...(typeof raw.message === 'string' && raw.message.trim() ? { message: raw.message.trim() } : {})
}
}
/** True when the Nous free tier refused or could not serve the turn: the way
* forward is the free sign-in (or another provider), never an OAuth re-login. */
export function isFreeTierSurface(surface: ErrorSurface | null | undefined): boolean {
return typeof surface?.code === 'string' && surface.code.startsWith('free_tier_')
}
/** True when the failed turn's provider rejected an OAuth grant — the
* one-click recovery is re-running that provider's sign-in, not editing keys. */
export function isOAuthReauthSurface(surface: ErrorSurface | null | undefined): surface is ErrorSurface & {
@@ -161,6 +181,8 @@ export interface ErrorRecoveryPlan {
updateApiKey: boolean
/** Re-run the provider's OAuth sign-in (auth, oauth). */
signInAgain: boolean
/** Open the free-tier sign-in dialog (free_tier_* codes): signing in is free and lifts the refusal. */
signInFreeTier: boolean
/** Settings → Models deep link. */
switchProvider: boolean
}
@@ -197,6 +219,7 @@ export function errorRecoveryPlan(surface: ErrorSurface | null | undefined): Err
// natural second click.
retry: !surface || surface.retryable || oauthReauth || apiKeyRejected,
signInAgain: oauthReauth,
signInFreeTier: isFreeTierSurface(surface),
startNewSession: false,
switchProvider: surface != null && SWITCH_PROVIDER_LAYERS.includes(surface.layer),
updateApiKey: apiKeyRejected
+29
View File
@@ -229,3 +229,32 @@ def test_exception_never_raises_on_weird_input():
# Must not raise, whatever it returns.
build_error_surface_from_exception(Hostile("x"))
# ── Nous free tier ────────────────────────────────────────────────────────
def test_free_tier_block_gets_its_own_code_and_carries_the_sentence():
"""A free-tier refusal is never an OAuth re-login: its own ``free_tier_<kind>`` code on the
provider layer, with the chat sentence riding along as the card body."""
result = _failed_result("auth_permanent", error="HTTP 403: no permissions",
free_tier={"kind": "disabled", "message": "Using Hermes without signing in is switched off."})
surface = build_error_surface_from_result(result, provider="nous", model="nous/welcome")
assert surface["layer"] == LAYER_PROVIDER and surface["code"] == "free_tier_disabled"
assert surface["retryable"] is False and "auth_kind" not in surface
assert surface["message"] == "Using Hermes without signing in is switched off."
@pytest.mark.parametrize("kind,retryable", [
("rate_limited", True), ("at_capacity", True), ("outage", True),
("disabled", False), ("model_not_free", False), ("route", False), ("refused", False),
])
def test_free_tier_kinds_say_whether_a_later_send_can_succeed(kind, retryable):
surface = build_error_surface_from_result(_failed_result("rate_limit", free_tier={"kind": kind}), provider="nous")
assert surface["code"] == f"free_tier_{kind}" and surface["retryable"] is retryable
assert "message" not in surface
def test_a_free_tier_block_without_a_kind_is_ignored():
surface = build_error_surface_from_result(_failed_result("auth_permanent", free_tier={}), provider="nous")
assert surface["code"] == "auth_permanent" and surface["layer"] == LAYER_AUTH
+56
View File
@@ -159,3 +159,59 @@ class TestOutageCopy:
from agent.turn_recovery import _welcome_outage_copy
assert _welcome_outage_copy(PAID, SimpleNamespace(reason=FailoverReason.timeout)) == ""
assert _welcome_outage_copy(WELCOME, SimpleNamespace(reason=FailoverReason.rate_limit)) == ""
class TestTerminalResultsCarryTheFreeTierBlock:
"""The terminal results stamp ``free_tier`` so a client renders the free tier's own card
(agent/error_surface.py) instead of an OAuth re-login."""
@staticmethod
def _terminal_agent():
agent = _agent(_dump_api_request_debug=lambda *a, **k: None, _flush_status_buffer=lambda: None,
_summarize_api_error=lambda e: "HTTP 403: no permissions", _emit_status=lambda *a: None,
_persist_session=lambda *a: None, _plines=lambda *a: None, _buffer_status=lambda *a: None,
_rate_limit_state=None, _has_pending_fallback=lambda: False)
return agent
def test_a_dark_tier_403_is_stamped_disabled_with_the_chat_sentence(self):
from agent.turn_recovery import nonretryable_client_error_result
err = _generic_403()
classified = _classify(err)
result = nonretryable_client_error_result(
self._terminal_agent(), err, classified, status_code=403, api_kwargs=None, api_messages=[],
messages=[], conversation_history=[], api_call_count=1, approx_tokens=10,
provider="nous", base_url=WELCOME, model="nous/welcome")
assert result["free_tier"] == {"kind": "disabled", "message": result["final_response"]}
assert "switched off" in result["final_response"] and "/login" in result["final_response"]
assert result["error"] == "HTTP 403: no permissions" # the technical detail stays in the log line
def test_an_exhausted_capacity_refusal_is_stamped_at_capacity(self):
from agent.turn_recovery import max_retries_exhausted_result
err = _refusal("at_capacity", retry_after=30)
classified = _classify(err)
result = max_retries_exhausted_result(
self._terminal_agent(), err, classified, max_retries=3, is_rate_limited=True, error_msg="429",
api_kwargs=None, api_messages=[], messages=[], conversation_history=[], api_call_count=3,
approx_tokens=10, provider="nous", base_url=WELCOME, model="nous/welcome")
assert result["free_tier"]["kind"] == "at_capacity"
assert result["free_tier"]["message"] == result["final_response"]
assert "really busy" in result["final_response"]
def test_a_spent_outage_on_the_welcome_host_is_stamped_outage(self):
from agent.turn_recovery import max_retries_exhausted_result
err = _gateway_error(503, {"status": 503, "message": "The requested model is currently unavailable."})
classified = _classify(err)
result = max_retries_exhausted_result(
self._terminal_agent(), err, classified, max_retries=3, is_rate_limited=False, error_msg="503",
api_kwargs=None, api_messages=[], messages=[], conversation_history=[], api_call_count=3,
approx_tokens=10, provider="nous", base_url=WELCOME, model="nous/welcome")
assert result["free_tier"]["kind"] == "outage"
def test_the_same_outage_on_the_paid_host_is_not_stamped(self):
from agent.turn_recovery import max_retries_exhausted_result
err = _gateway_error(503, {"status": 503, "message": "The requested model is currently unavailable."})
result = max_retries_exhausted_result(
self._terminal_agent(), err, _classify(err, base_url=PAID), max_retries=3, is_rate_limited=False,
error_msg="503", api_kwargs=None, api_messages=[], messages=[], conversation_history=[],
api_call_count=3, approx_tokens=10, provider="nous", base_url=PAID, model="hermes-4")
assert "free_tier" not in result