fix: implement Nous credential refresh on 401 error for retry logic
This commit is contained in:
parent
f75b1d21b4
commit
bc091eb7ef
2 changed files with 135 additions and 0 deletions
54
run_agent.py
54
run_agent.py
|
|
@ -2018,6 +2018,49 @@ class AIAgent:
|
||||||
|
|
||||||
return True
|
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):
|
def _interruptible_api_call(self, api_kwargs: dict):
|
||||||
"""
|
"""
|
||||||
Run the API call in a background thread so the main conversation loop
|
Run the API call in a background thread so the main conversation loop
|
||||||
|
|
@ -3044,6 +3087,7 @@ class AIAgent:
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
max_retries = 6 # Increased to allow longer backoff periods
|
max_retries = 6 # Increased to allow longer backoff periods
|
||||||
codex_auth_retry_attempted = False
|
codex_auth_retry_attempted = False
|
||||||
|
nous_auth_retry_attempted = False
|
||||||
|
|
||||||
finish_reason = "stop"
|
finish_reason = "stop"
|
||||||
|
|
||||||
|
|
@ -3293,6 +3337,16 @@ class AIAgent:
|
||||||
if self._try_refresh_codex_client_credentials(force=True):
|
if self._try_refresh_codex_client_credentials(force=True):
|
||||||
print(f"{self.log_prefix}🔐 Codex auth refreshed after 401. Retrying request...")
|
print(f"{self.log_prefix}🔐 Codex auth refreshed after 401. Retrying request...")
|
||||||
continue
|
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
|
retry_count += 1
|
||||||
elapsed_time = time.time() - api_start_time
|
elapsed_time = time.time() - api_start_time
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,43 @@ class TestRunConversation:
|
||||||
assert result["completed"] is False
|
assert result["completed"] is False
|
||||||
assert result.get("partial") is True
|
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):
|
def test_context_compression_triggered(self, agent):
|
||||||
"""When compressor says should_compress, compression runs."""
|
"""When compressor says should_compress, compression runs."""
|
||||||
self._setup_agent(agent)
|
self._setup_agent(agent)
|
||||||
|
|
@ -938,6 +975,50 @@ class TestConversationHistoryNotMutated:
|
||||||
# _max_tokens_param consistency
|
# _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:
|
class TestMaxTokensParam:
|
||||||
"""Verify _max_tokens_param returns the correct key for each provider."""
|
"""Verify _max_tokens_param returns the correct key for each provider."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue