From 16def7b8cc1b91bdf716641135de0c3c7ac8cb2d Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Tue, 15 Sep 2026 22:40:57 +1000 Subject: [PATCH] fix(free-tier): the desktop renders a free-tier refusal as its own card, not an OAuth re-login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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 --- agent/error_surface.py | 12 ++++ agent/turn_api_call.py | 2 + agent/turn_recovery.py | 31 +++++++++- .../assistant-ui/thread/assistant-message.tsx | 14 +++++ apps/desktop/src/i18n/en.ts | 31 ++++++++++ apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/lib/error-surface-copy.ts | 9 ++- apps/desktop/src/lib/error-surface.test.ts | 40 +++++++++++++ apps/desktop/src/lib/error-surface.ts | 27 ++++++++- tests/agent/test_error_surface.py | 29 ++++++++++ tests/agent/test_welcome_tier_recovery.py | 56 +++++++++++++++++++ 11 files changed, 247 insertions(+), 6 deletions(-) diff --git a/agent/error_surface.py b/agent/error_surface.py index 48b613a791..21453bd756 100644 --- a/agent/error_surface.py +++ b/agent/error_surface.py @@ -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) diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index bf3ecec973..56987a44c7 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -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 diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index d14a24e8dd..ce6d408073 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -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_``): 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 diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 7494401bdc..0fc5bedc4e 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -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)} )} + {plan.signInFreeTier && ( + + )} {plan.updateApiKey && inRouter && ( } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 70c0fe92e5..226cc0f754 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index baf1bf3263..29ccdeecdf 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/lib/error-surface-copy.ts b/apps/desktop/src/lib/error-surface-copy.ts index 122ed35914..72320527e2 100644 --- a/apps/desktop/src/lib/error-surface-copy.ts +++ b/apps/desktop/src/lib/error-surface-copy.ts @@ -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] } diff --git a/apps/desktop/src/lib/error-surface.test.ts b/apps/desktop/src/lib/error-surface.test.ts index b4cf61a9a6..9bab5937b7 100644 --- a/apps/desktop/src/lib/error-surface.test.ts +++ b/apps/desktop/src/lib/error-surface.test.ts @@ -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/) + } + }) +}) diff --git a/apps/desktop/src/lib/error-surface.ts b/apps/desktop/src/lib/error-surface.ts index db1798ce6f..189f29e642 100644 --- a/apps/desktop/src/lib/error-surface.ts +++ b/apps/desktop/src/lib/error-surface.ts @@ -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_`). 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 diff --git a/tests/agent/test_error_surface.py b/tests/agent/test_error_surface.py index 8b037940e0..9a4c12bde6 100644 --- a/tests/agent/test_error_surface.py +++ b/tests/agent/test_error_surface.py @@ -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_`` 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 diff --git a/tests/agent/test_welcome_tier_recovery.py b/tests/agent/test_welcome_tier_recovery.py index ed8facc336..d13904ba57 100644 --- a/tests/agent/test_welcome_tier_recovery.py +++ b/tests/agent/test_welcome_tier_recovery.py @@ -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