From bc091eb7ef1f00be7d58f9ec4732ca7954ff1387 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Sat, 7 Mar 2026 13:34:23 +1100 Subject: [PATCH] fix: implement Nous credential refresh on 401 error for retry logic --- run_agent.py | 54 +++++++++++++++++++++++++++ tests/test_run_agent.py | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/run_agent.py b/run_agent.py index 74eb8807..90c0fce9 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2018,6 +2018,49 @@ class AIAgent: return True + def _try_refresh_nous_client_credentials(self, *, force: bool = True) -> bool: + if self.api_mode != "chat_completions" or self.provider != "nous": + return False + + try: + from hermes_cli.auth import resolve_nous_runtime_credentials + + creds = resolve_nous_runtime_credentials( + min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), + timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), + force_mint=force, + ) + except Exception as exc: + logger.debug("Nous credential refresh failed: %s", exc) + return False + + api_key = creds.get("api_key") + base_url = creds.get("base_url") + if not isinstance(api_key, str) or not api_key.strip(): + return False + if not isinstance(base_url, str) or not base_url.strip(): + return False + + self.api_key = api_key.strip() + self.base_url = base_url.strip().rstrip("/") + self._client_kwargs["api_key"] = self.api_key + self._client_kwargs["base_url"] = self.base_url + # Nous requests should not inherit OpenRouter-only attribution headers. + self._client_kwargs.pop("default_headers", None) + + try: + self.client.close() + except Exception: + pass + + try: + self.client = OpenAI(**self._client_kwargs) + except Exception as exc: + logger.warning("Failed to rebuild OpenAI client after Nous refresh: %s", exc) + return False + + return True + def _interruptible_api_call(self, api_kwargs: dict): """ Run the API call in a background thread so the main conversation loop @@ -3044,6 +3087,7 @@ class AIAgent: retry_count = 0 max_retries = 6 # Increased to allow longer backoff periods codex_auth_retry_attempted = False + nous_auth_retry_attempted = False finish_reason = "stop" @@ -3293,6 +3337,16 @@ class AIAgent: if self._try_refresh_codex_client_credentials(force=True): print(f"{self.log_prefix}🔐 Codex auth refreshed after 401. Retrying request...") continue + if ( + self.api_mode == "chat_completions" + and self.provider == "nous" + and status_code == 401 + and not nous_auth_retry_attempted + ): + nous_auth_retry_attempted = True + if self._try_refresh_nous_client_credentials(force=True): + print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...") + continue retry_count += 1 elapsed_time = time.time() - api_start_time diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 0cf6a53f..ae7924d4 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -765,6 +765,43 @@ class TestRunConversation: assert result["completed"] is False assert result.get("partial") is True + def test_nous_401_refreshes_after_remint_and_retries(self, agent): + self._setup_agent(agent) + agent.provider = "nous" + agent.api_mode = "chat_completions" + + calls = {"api": 0, "refresh": 0} + + class _UnauthorizedError(RuntimeError): + def __init__(self): + super().__init__("Error code: 401 - unauthorized") + self.status_code = 401 + + def _fake_api_call(api_kwargs): + calls["api"] += 1 + if calls["api"] == 1: + raise _UnauthorizedError() + return _mock_response(content="Recovered after remint", finish_reason="stop") + + def _fake_refresh(*, force=True): + calls["refresh"] += 1 + assert force is True + return True + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + patch.object(agent, "_try_refresh_nous_client_credentials", side_effect=_fake_refresh), + ): + result = agent.run_conversation("hello") + + assert calls["api"] == 2 + assert calls["refresh"] == 1 + assert result["completed"] is True + assert result["final_response"] == "Recovered after remint" + def test_context_compression_triggered(self, agent): """When compressor says should_compress, compression runs.""" self._setup_agent(agent) @@ -938,6 +975,50 @@ class TestConversationHistoryNotMutated: # _max_tokens_param consistency # --------------------------------------------------------------------------- +class TestNousCredentialRefresh: + """Verify Nous credential refresh rebuilds the runtime client.""" + + def test_try_refresh_nous_client_credentials_rebuilds_client(self, agent, monkeypatch): + agent.provider = "nous" + agent.api_mode = "chat_completions" + + closed = {"value": False} + rebuilt = {"kwargs": None} + captured = {} + + class _ExistingClient: + def close(self): + closed["value"] = True + + class _RebuiltClient: + pass + + def _fake_resolve(**kwargs): + captured.update(kwargs) + return { + "api_key": "new-nous-key", + "base_url": "https://inference-api.nousresearch.com/v1", + } + + def _fake_openai(**kwargs): + rebuilt["kwargs"] = kwargs + return _RebuiltClient() + + monkeypatch.setattr("hermes_cli.auth.resolve_nous_runtime_credentials", _fake_resolve) + + agent.client = _ExistingClient() + with patch("run_agent.OpenAI", side_effect=_fake_openai): + ok = agent._try_refresh_nous_client_credentials(force=True) + + assert ok is True + assert closed["value"] is True + assert captured["force_mint"] is True + assert rebuilt["kwargs"]["api_key"] == "new-nous-key" + assert rebuilt["kwargs"]["base_url"] == "https://inference-api.nousresearch.com/v1" + assert "default_headers" not in rebuilt["kwargs"] + assert isinstance(agent.client, _RebuiltClient) + + class TestMaxTokensParam: """Verify _max_tokens_param returns the correct key for each provider."""