diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 2f08810ae1b..cab284986f5 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2889,15 +2889,26 @@ def run_conversation( agent._vprint(f"{agent.log_prefix} 🌐 Endpoint: {_base}", force=True) # Actionable guidance for common auth errors if classified.is_auth or classified.reason == FailoverReason.billing: - if _provider in {"openai-codex", "xai-oauth"} and status_code == 401: + if _provider in {"openai-codex", "xai-oauth", "nous"} and status_code == 401: if _provider == "openai-codex": agent._vprint(f"{agent.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True) agent._vprint(f"{agent.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True) agent._vprint(f"{agent.log_prefix} 1. Run `codex` in your terminal to generate fresh tokens.", force=True) agent._vprint(f"{agent.log_prefix} 2. Then run `hermes auth` to re-authenticate.", force=True) - else: + elif _provider == "xai-oauth": agent._vprint(f"{agent.log_prefix} 💡 xAI OAuth token was rejected (HTTP 401). To fix:", force=True) agent._vprint(f"{agent.log_prefix} re-authenticate with xAI Grok OAuth (SuperGrok / Premium+) from `hermes model`.", force=True) + else: # nous + agent._vprint(f"{agent.log_prefix} 💡 Nous Portal OAuth token was rejected (HTTP 401). Your token may be", force=True) + agent._vprint(f"{agent.log_prefix} expired, revoked, or your account may be out of credits. To fix:", force=True) + agent._vprint(f"{agent.log_prefix} 1. Re-authenticate: hermes auth add nous --type oauth", force=True) + agent._vprint(f"{agent.log_prefix} 2. Check your portal account: https://portal.nousresearch.com", force=True) + # ``:free`` is OpenRouter slug syntax; Nous Portal will reject + # the model name even after a successful re-auth. + if isinstance(_model, str) and _model.endswith(":free"): + agent._vprint(f"{agent.log_prefix} ⚠️ Note: `{_model}` looks like an OpenRouter slug (`:free` suffix).", force=True) + agent._vprint(f"{agent.log_prefix} Nous Portal won't recognize that model name. Either switch to a", force=True) + agent._vprint(f"{agent.log_prefix} Nous catalog model, or run `/model openrouter:{_model}` to use OpenRouter.", force=True) else: agent._vprint(f"{agent.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True) agent._vprint(f"{agent.log_prefix} • Is the key valid? Run: hermes setup", force=True) diff --git a/tests/agent/test_nous_oauth_401_guidance.py b/tests/agent/test_nous_oauth_401_guidance.py new file mode 100644 index 00000000000..d5d6e107eac --- /dev/null +++ b/tests/agent/test_nous_oauth_401_guidance.py @@ -0,0 +1,71 @@ +"""Tests for the Nous OAuth 401 actionable-guidance branch in +``agent.conversation_loop.run_conversation``. + +Source-inspection style (matches ``test_gemini_fast_fallback.py``): we assert +that the guidance strings exist in the function body so that the user-facing +hint cannot be silently removed by a future refactor. + +Regression context: ashh hit a Nous 401 (OAuth token expired / portal said +account out of credits) plus a model slug ``deepseek/deepseek-v4-flash:free`` +that's OpenRouter syntax, not a Nous catalog name. The previous guidance +branch only covered ``openai-codex`` and ``xai-oauth``; ``nous`` fell through +to a generic "Your API key was rejected... run hermes setup" message, which is +the wrong advice for a pure-OAuth provider. +""" +from __future__ import annotations + +import inspect + +from agent import conversation_loop + + +def test_nous_provider_is_in_oauth_401_set(): + """The provider-set gate that selects OAuth-specific guidance must + include ``nous`` alongside ``openai-codex`` and ``xai-oauth``. + """ + source = inspect.getsource(conversation_loop.run_conversation) + + # Be flexible about set element ordering — assert all three are listed + # near each other in the gating expression. + assert "\"openai-codex\"" in source + assert "\"xai-oauth\"" in source + assert "\"nous\"" in source + + # And the gate string itself must mention all three so future refactors + # that split nous off into its own gate still get caught. + needle = "_provider in {\"openai-codex\", \"xai-oauth\", \"nous\"}" + assert needle in source, ( + "Expected nous to be co-gated with the other OAuth providers in the " + "actionable-401-guidance branch of run_conversation." + ) + + +def test_nous_401_guidance_strings_present(): + """User-facing remediation strings for Nous OAuth 401s must exist.""" + source = inspect.getsource(conversation_loop.run_conversation) + + # Must tell the user it's an OAuth token problem, NOT an API key problem + # (Nous Portal has no API key path — auth_type=oauth_device_code only). + assert "Nous Portal OAuth token was rejected" in source + + # Must give the exact re-auth command, not a generic "hermes setup". + assert "hermes auth add nous --type oauth" in source + + # Must point at the portal so users can check account/credit status. + assert "portal.nousresearch.com" in source + + +def test_free_slug_hint_for_nous_provider(): + """When the failing model slug ends with ``:free`` and the provider is + ``nous``, the guidance must flag that ``:free`` is OpenRouter syntax and + suggest switching providers via ``/model openrouter:``. + + Without this hint, users re-OAuth successfully and then hit the same 401 + on the next message because Nous Portal doesn't carry the OpenRouter + free-tier slug. + """ + source = inspect.getsource(conversation_loop.run_conversation) + + assert "endswith(\":free\")" in source + assert "OpenRouter slug" in source + assert "/model openrouter:" in source