diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index 56987a44c7..024815d03b 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -264,7 +264,8 @@ def nous_rate_limit_guard( "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 {}), + **({"free_tier": {"kind": "rate_limited", "message": anon_auth.FREE_TIER_RATE_LIMIT_CARD.format( + reset=anon_auth.friendly_wait(_nous_remaining))}} 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 ce6d408073..e38fedd857 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -706,7 +706,7 @@ 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: +def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool, door: bool = True) -> 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 {} @@ -715,8 +715,8 @@ def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool) -> str 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) + return welcome_refusal_copy(refusal, model=str(model or ""), in_chat=in_chat, door=door) + return welcome_route_refusal_copy(str(route), in_chat=in_chat, door=door) def _welcome_surface_kind(classified: Any) -> str: @@ -881,7 +881,9 @@ def nonretryable_client_error_result( "failure_retryable": bool(classified.retryable), }) if _welcome_hint: - _stamp_free_tier(result, _welcome_surface_kind(classified), _final_response) + # The card form: the desktop renders the sign-in as a button, so no "To sign in" tail. + _stamp_free_tier(result, _welcome_surface_kind(classified), + _welcome_tier_guidance(classified, model=model, in_chat=True, door=False)) return result @@ -1011,7 +1013,9 @@ def max_retries_exhausted_result( "billing_block": _billing_block, }) if _free_tier_kind: - _stamp_free_tier(result, _free_tier_kind, _final_response) + _stamp_free_tier(result, _free_tier_kind, ( + _welcome_tier_guidance(classified, model=model, in_chat=True, door=False) + if _welcome_hint else _final_response)) return result diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index b360577f3b..aed9bd9eb8 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -675,11 +675,13 @@ def parse_welcome_refusal(body: Any) -> Optional[Dict[str, Any]]: "upgrade_url": upgrade_url if isinstance(upgrade_url, str) else ""} -def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: bool = True) -> str: +def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: bool = True, door: bool = True) -> str: """User copy for a structured welcome-tier refusal: what happened and the one way forward. - Never guest / anonymous / claim; ``in_chat`` picks ``/login`` over the terminal verb.""" - signin = _SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL + Never guest / anonymous / claim; ``in_chat`` picks ``/login`` over the terminal verb. + ``door=False`` leaves the "To sign in: …" tail off, for a surface that renders the sign-in as + a button beside the sentence (the desktop's error card).""" + signin = (_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL) if door else "" reason = str(refusal.get("reason") or "") alternates = refusal.get("alternates") or [] serves = alternates[0] if alternates else GUEST_MODEL @@ -690,17 +692,17 @@ def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: b return (f"{what} available without signing in, so Hermes uses {serves} for now. " f"Sign in for more models. {signin}") if reason == "feature_not_free": - return f"That isn't available without signing in. Sign in to use it, it's free. {signin}" + return f"That isn't available without signing in. Sign in to use it, it's free. {signin}".rstrip() if reason == "at_capacity": return ("Chatting without signing in is really busy right now. Sign in to skip the queue, " - f"it's free, or try again in {wait}. {signin}") + f"it's free, or try again in {wait}. {signin}").rstrip() if reason == "admission_closed": return ("Chatting without signing in is full right now. Sign in to keep going, " - f"it's free, or try again in {wait}. {signin}") + f"it's free, or try again in {wait}. {signin}").rstrip() if reason == "rate_limited": return (f"You've used up the allowance for chatting without signing in. It refreshes in {wait}. " - f"Sign in for a bigger allowance, it's free. {signin}") - return f"Hermes couldn't send that without signing in. Signing in is free. {signin}" + f"Sign in for a bigger allowance, it's free. {signin}").rstrip() + return f"Hermes couldn't send that without signing in. Signing in is free. {signin}".rstrip() def welcome_route_refusal(status: Any, message: Any, base_url: Any = None) -> Optional[str]: @@ -723,11 +725,11 @@ def welcome_route_refusal(status: Any, message: Any, base_url: Any = None) -> Op return kind -def welcome_route_refusal_copy(kind: str, *, in_chat: bool = True) -> str: +def welcome_route_refusal_copy(kind: str, *, in_chat: bool = True, door: bool = True) -> str: template = _WELCOME_ROUTE_COPY.get(kind) or "Hermes couldn't reach the free model on this route." return template.format( - host=DEFAULT_NOUS_WELCOME_URL, signin=_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL, - model_hint=_MODEL_HINT_CHAT if in_chat else _MODEL_HINT_TERMINAL) + host=DEFAULT_NOUS_WELCOME_URL, signin=(_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL) if door else "", + model_hint=_MODEL_HINT_CHAT if in_chat else _MODEL_HINT_TERMINAL).rstrip() def note_model_switch(agent: Any, headers: Any) -> Optional[str]: @@ -1034,6 +1036,7 @@ from hermes_cli.anon_sign_in import ( # noqa: E402 Code as Code, Completed as Completed, Declined as Declined, + FREE_TIER_RATE_LIMIT_CARD as FREE_TIER_RATE_LIMIT_CARD, FREE_TIER_RATE_LIMIT_CHAT as FREE_TIER_RATE_LIMIT_CHAT, Failed as Failed, LOGIN_BUSY_ELSEWHERE as LOGIN_BUSY_ELSEWHERE, diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index 4bf9ef3431..62ae8f7273 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -47,9 +47,11 @@ LOGIN_STARTING = "Starting sign-in..." LOGIN_DM_ONLY = "Sign in from a direct message with Hermes." LOGIN_BUSY_ELSEWHERE = "Another sign-in is already running on this Hermes. Try again in a few minutes." LOGIN_NOT_ALLOWED = "Only an operator of this Hermes can sign it in." -FREE_TIER_RATE_LIMIT_CHAT = ( +# The card form (a surface with its own sign-in button) and the chat form (names /login). +FREE_TIER_RATE_LIMIT_CARD = ( "You've used up the allowance for chatting without signing in. It refreshes in {reset}. " - "Sign in for a bigger allowance, it's free: /login.") + "Sign in for a bigger allowance, it's free.") +FREE_TIER_RATE_LIMIT_CHAT = FREE_TIER_RATE_LIMIT_CARD[:-1] + ": /login." def format_wait_line(expires_in: int) -> str: diff --git a/tests/agent/test_welcome_tier_recovery.py b/tests/agent/test_welcome_tier_recovery.py index d13904ba57..6d6f1699b3 100644 --- a/tests/agent/test_welcome_tier_recovery.py +++ b/tests/agent/test_welcome_tier_recovery.py @@ -181,8 +181,11 @@ class TestTerminalResultsCarryTheFreeTierBlock: 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"]} + # The chat text names /login; the card text (a button beside it) leaves that tail off. assert "switched off" in result["final_response"] and "/login" in result["final_response"] + assert result["free_tier"]["kind"] == "disabled" + assert result["free_tier"]["message"] == result["final_response"].replace(" To sign in: /login.", "") + assert "/login" not in result["free_tier"]["message"] 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): @@ -194,8 +197,8 @@ class TestTerminalResultsCarryTheFreeTierBlock: 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"] + assert "really busy" in result["free_tier"]["message"] and "/login" not in result["free_tier"]["message"] + assert "/login" 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