Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 82 additions & 26 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,13 @@ def _is_auth_error(exc: Exception) -> bool:
if status == 401:
return True
err_lower = str(exc).lower()
return "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower()
return (
"error code: 401" in err_lower
or "http 401" in err_lower
or "status code 401" in err_lower
or "unauthorized" in err_lower
or "authenticationerror" in type(exc).__name__.lower()
)


def _is_unsupported_parameter_error(exc: Exception, param: str) -> bool:
Expand Down Expand Up @@ -1844,6 +1850,25 @@ def _evict_cached_clients(provider: str) -> None:
_client_cache.pop(key, None)


def _auth_refresh_provider_for_route(
resolved_provider: Optional[str],
client_base_url: str,
) -> str:
"""Infer which concrete provider owns credentials for an auth failure."""
normalized = _normalize_aux_provider(resolved_provider)
if normalized and normalized != "auto":
return normalized
if base_url_host_matches(client_base_url, "api.githubcopilot.com"):
return "copilot"
if base_url_host_matches(client_base_url, "chatgpt.com"):
return "openai-codex"
if base_url_host_matches(client_base_url, "api.anthropic.com"):
return "anthropic"
if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"):
return "nous"
return normalized


def _refresh_provider_credentials(provider: str) -> bool:
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
normalized = _normalize_aux_provider(provider)
Expand All @@ -1868,6 +1893,21 @@ def _refresh_provider_credentials(provider: str) -> bool:
return False
_evict_cached_clients(normalized)
return True
if normalized == "copilot":
from hermes_cli.copilot_auth import (
_jwt_cache,
_token_fingerprint,
exchange_copilot_token,
resolve_copilot_token,
)

raw_token, _source = resolve_copilot_token()
if not str(raw_token or "").strip():
return False
_jwt_cache.pop(_token_fingerprint(raw_token), None)
exchange_copilot_token(raw_token)
_evict_cached_clients(normalized)
return True
if normalized == "anthropic":
from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token

Expand Down Expand Up @@ -3642,10 +3682,11 @@ def call_llm(
first_err = retry_err

# ── Nous auth refresh parity with main agent ──────────────────
client_is_nous = (
resolved_provider == "nous"
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider,
_base_info,
)
client_is_nous = auth_refresh_provider == "nous"
if _is_auth_error(first_err) and client_is_nous:
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
cache_provider=resolved_provider or "nous",
Expand All @@ -3667,22 +3708,25 @@ def call_llm(

# ── Auth refresh retry ───────────────────────────────────────
if (_is_auth_error(first_err)
and resolved_provider not in ("auto", "", None)
and auth_refresh_provider not in ("auto", "", None)
and not client_is_nous):
if _refresh_provider_credentials(resolved_provider):
original_provider = _normalize_aux_provider(resolved_provider)
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != original_provider:
_evict_cached_clients(resolved_provider)
logger.info(
"Auxiliary %s: refreshed %s credentials after auth error, retrying",
task or "call", resolved_provider,
task or "call", auth_refresh_provider,
)
retry_client, retry_model = (
resolve_vision_provider_client(
provider=resolved_provider,
provider=auth_refresh_provider,
model=final_model,
async_mode=False,
)[1:]
if task == "vision"
else _get_cached_client(
resolved_provider,
auth_refresh_provider,
resolved_model,
base_url=resolved_base_url,
api_key=resolved_api_key,
Expand All @@ -3691,19 +3735,19 @@ def call_llm(
)
)
if retry_client is not None:
_retry_base = str(getattr(retry_client, "base_url", "") or "")
retry_kwargs = _build_call_kwargs(
resolved_provider,
auth_refresh_provider,
retry_model or final_model,
messages,
temperature=temperature,
max_tokens=max_tokens,
tools=tools,
timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=resolved_base_url,
base_url=_retry_base or resolved_base_url,
)
_retry_base = str(getattr(retry_client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _retry_base):
if _is_anthropic_compat_endpoint(auth_refresh_provider, _retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task)
Expand Down Expand Up @@ -3820,6 +3864,7 @@ async def async_call_llm(
model: str = None,
base_url: str = None,
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: float = None,
max_tokens: int = None,
Expand Down Expand Up @@ -3868,6 +3913,7 @@ async def async_call_llm(
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
main_runtime=main_runtime,
)
if client is None:
_explicit = (resolved_provider or "").strip().lower()
Expand All @@ -3880,7 +3926,11 @@ async def async_call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True)
client, final_model = _get_cached_client(
"auto",
async_mode=True,
main_runtime=main_runtime,
)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
Expand Down Expand Up @@ -3948,10 +3998,11 @@ async def async_call_llm(
first_err = retry_err

# ── Nous auth refresh parity with main agent ──────────────────
client_is_nous = (
resolved_provider == "nous"
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider,
_client_base,
)
client_is_nous = auth_refresh_provider == "nous"
if _is_auth_error(first_err) and client_is_nous:
refreshed_client, refreshed_model = _refresh_nous_auxiliary_client(
cache_provider=resolved_provider or "nous",
Expand All @@ -3960,6 +4011,7 @@ async def async_call_llm(
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
main_runtime=main_runtime,
is_vision=(task == "vision"),
)
if refreshed_client is not None:
Expand All @@ -3972,42 +4024,46 @@ async def async_call_llm(

# ── Auth refresh retry (mirrors sync call_llm) ───────────────
if (_is_auth_error(first_err)
and resolved_provider not in ("auto", "", None)
and auth_refresh_provider not in ("auto", "", None)
and not client_is_nous):
if _refresh_provider_credentials(resolved_provider):
original_provider = _normalize_aux_provider(resolved_provider)
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != original_provider:
_evict_cached_clients(resolved_provider)
logger.info(
"Auxiliary %s (async): refreshed %s credentials after auth error, retrying",
task or "call", resolved_provider,
task or "call", auth_refresh_provider,
)
if task == "vision":
_, retry_client, retry_model = resolve_vision_provider_client(
provider=resolved_provider,
provider=auth_refresh_provider,
model=final_model,
async_mode=True,
)
else:
retry_client, retry_model = _get_cached_client(
resolved_provider,
auth_refresh_provider,
resolved_model,
async_mode=True,
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
main_runtime=main_runtime,
)
if retry_client is not None:
_retry_base = str(getattr(retry_client, "base_url", "") or "")
retry_kwargs = _build_call_kwargs(
resolved_provider,
auth_refresh_provider,
retry_model or final_model,
messages,
temperature=temperature,
max_tokens=max_tokens,
tools=tools,
timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=resolved_base_url,
base_url=_retry_base or resolved_base_url,
)
_retry_base = str(getattr(retry_client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _retry_base):
if _is_anthropic_compat_endpoint(auth_refresh_provider, _retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
Expand Down
84 changes: 84 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,13 @@ async def create(self, **kwargs):


class TestAuxiliaryAuthRefreshRetry:
def test_is_auth_error_detects_copilot_http_401_text(self):
from agent.auxiliary_client import _is_auth_error

assert _is_auth_error(
RuntimeError("HTTP 401: IDE token expired: unauthorized: token expired")
)

def test_call_llm_refreshes_codex_on_401_for_vision(self):
failing_client = MagicMock()
failing_client.base_url = "https://chatgpt.com/backend-api/codex"
Expand Down Expand Up @@ -1532,6 +1539,34 @@ def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1

def test_call_llm_refreshes_auto_routed_copilot_on_401(self):
stale_client = MagicMock()
stale_client.base_url = "https://api.githubcopilot.com"
stale_client.chat.completions.create.side_effect = _AuxAuth401("IDE token expired")

fresh_client = MagicMock()
fresh_client.base_url = "https://api.githubcopilot.com"
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-copilot")

with (
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("auto", None, None, None, None)),
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-4o"), (fresh_client, "gpt-4o")]) as mock_get,
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
patch("agent.auxiliary_client._evict_cached_clients") as mock_evict,
):
resp = call_llm(
task="compression",
messages=[{"role": "user", "content": "hi"}],
)

assert resp.choices[0].message.content == "fresh-copilot"
mock_refresh.assert_called_once_with("copilot")
mock_evict.assert_called_once_with("auto")
assert mock_get.call_args_list[0].args[0] == "auto"
assert mock_get.call_args_list[1].args[0] == "copilot"
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1

@pytest.mark.asyncio
async def test_async_call_llm_refreshes_codex_on_401_for_vision(self):
failing_client = MagicMock()
Expand Down Expand Up @@ -1589,6 +1624,21 @@ def test_refresh_provider_credentials_force_refreshes_anthropic_oauth_and_evicts
mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999)
stale_client.close.assert_called_once()

def test_refresh_provider_credentials_refreshes_copilot_jwt_and_evicts_cache(self):
with (
patch("hermes_cli.copilot_auth.resolve_copilot_token", return_value=("raw-gho-token", "test")),
patch("hermes_cli.copilot_auth._token_fingerprint", return_value="fp"),
patch("hermes_cli.copilot_auth._jwt_cache", {"fp": ("expired-api-token", 0)}),
patch("hermes_cli.copilot_auth.exchange_copilot_token", return_value=("fresh-api-token", 9999999999)) as mock_exchange,
patch("agent.auxiliary_client._evict_cached_clients") as mock_evict,
):
from agent.auxiliary_client import _refresh_provider_credentials

assert _refresh_provider_credentials("copilot") is True

mock_exchange.assert_called_once_with("raw-gho-token")
mock_evict.assert_called_once_with("copilot")

@pytest.mark.asyncio
async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
stale_client = MagicMock()
Expand Down Expand Up @@ -1616,6 +1666,40 @@ async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
assert stale_client.chat.completions.create.await_count == 1
assert fresh_client.chat.completions.create.await_count == 1

@pytest.mark.asyncio
async def test_async_call_llm_refreshes_auto_routed_copilot_on_401(self):
stale_client = MagicMock()
stale_client.base_url = "https://api.githubcopilot.com"
stale_client.chat.completions.create = AsyncMock(side_effect=_AuxAuth401("IDE token expired"))

fresh_client = MagicMock()
fresh_client.base_url = "https://api.githubcopilot.com"
fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async-copilot"))

with (
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("auto", None, None, None, None)),
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-4o"), (fresh_client, "gpt-4o")]) as mock_get,
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
patch("agent.auxiliary_client._evict_cached_clients") as mock_evict,
):
resp = await async_call_llm(
task="session_search",
main_runtime={"provider": "copilot", "model": "gpt-4o"},
messages=[{"role": "user", "content": "hi"}],
)

assert resp.choices[0].message.content == "fresh-async-copilot"
mock_refresh.assert_called_once_with("copilot")
mock_evict.assert_called_once_with("auto")
assert mock_get.call_args_list[0].args[0] == "auto"
assert mock_get.call_args_list[0].kwargs["main_runtime"] == {
"provider": "copilot",
"model": "gpt-4o",
}
assert mock_get.call_args_list[1].args[0] == "copilot"
assert stale_client.chat.completions.create.await_count == 1
assert fresh_client.chat.completions.create.await_count == 1


class TestCodexAdapterReasoningTranslation:
"""Verify _CodexCompletionsAdapter translates extra_body.reasoning
Expand Down
Loading