fix(auxiliary): refresh auto-routed provider credentials on 401

Infer the concrete auxiliary auth provider from the selected client base
URL so provider:auto routes can refresh Copilot/Codex/Anthropic/Nous
credentials after auth errors, instead of skipping refresh because
resolved_provider stayed 'auto'. Adds the copilot branch to
_refresh_provider_credentials and evicts the stale auto-route cache
before retrying.

Fixes #20832. Salvaged from PR #20837, reapplied surgically onto current
main (branch predated the _retry_same_provider_sync/async extraction).
This commit is contained in:
Fan Yang 2026-07-06 13:26:32 -07:00 committed by Teknium
parent 830165473e
commit f69e3aadf1
2 changed files with 125 additions and 10 deletions

View file

@ -3388,6 +3388,21 @@ def _refresh_provider_credentials(provider: str) -> bool:
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
normalized = _normalize_aux_provider(provider)
try:
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 == "openai-codex":
from hermes_cli.auth import resolve_codex_runtime_credentials
@ -3442,6 +3457,31 @@ def _refresh_provider_credentials(provider: str) -> bool:
return False
def _auth_refresh_provider_for_route(
resolved_provider: Optional[str],
client_base_url: str,
) -> str:
"""Return the provider whose short-lived credentials should be refreshed.
Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even
after _get_cached_client() selects a concrete backend. Infer the backend
from the selected client's base URL so auth refresh works for auto →
Copilot/Codex/Anthropic/Nous routes too. (#20832)
"""
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 _try_payment_fallback(
failed_provider: str,
task: str = None,
@ -6464,18 +6504,24 @@ def call_llm(
refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry ───────────────────────────────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider, _base_info)
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):
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
# The stale client is cached under the route label
# (e.g. "auto"), not the concrete backend we refreshed.
_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,
)
return _retry_same_provider_sync(
task=task,
resolved_provider=resolved_provider,
resolved_model=resolved_model,
resolved_provider=auth_refresh_provider,
resolved_model=resolved_model or final_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,
@ -6992,18 +7038,24 @@ async def async_call_llm(
await refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider, _client_base)
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):
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
# The stale client is cached under the route label
# (e.g. "auto"), not the concrete backend we refreshed.
_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,
)
return await _retry_same_provider_async(
task=task,
resolved_provider=resolved_provider,
resolved_model=resolved_model,
resolved_provider=auth_refresh_provider,
resolved_model=resolved_model or final_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,

View file

@ -3376,6 +3376,69 @@ class TestAuxiliaryAuthRefreshRetry:
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1
def test_call_llm_refreshes_copilot_when_auto_routes_to_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: unauthorized: token expired"
)
fresh_client = MagicMock()
fresh_client.base_url = "https://api.githubcopilot.com"
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-auto-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-5.5"), (fresh_client, "gpt-5.5")]) as mock_get_client,
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="title_generation",
messages=[{"role": "user", "content": "hi"}],
main_runtime={"provider": "copilot", "model": "gpt-5.5"},
)
assert resp.choices[0].message.content == "fresh-auto-copilot"
mock_refresh.assert_called_once_with("copilot")
mock_evict.assert_called_once_with("auto")
assert mock_get_client.call_args_list[0].args[0] == "auto"
assert mock_get_client.call_args_list[1].args[0] == "copilot"
assert mock_get_client.call_args_list[1].args[1] == "gpt-5.5"
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1
def test_call_llm_refreshes_codex_when_auto_routes_to_codex_on_401(self):
# Preflight compression's exact failure (#23670): provider auto →
# Codex OAuth backend 401s → before the fix, no refresh was attempted
# because resolved_provider stayed "auto".
stale_client = MagicMock()
stale_client.base_url = "https://chatgpt.com/backend-api/codex"
stale_client.chat.completions.create.side_effect = _AuxAuth401("User not found.")
fresh_client = MagicMock()
fresh_client.base_url = "https://chatgpt.com/backend-api/codex"
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-auto-codex")
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-5.5"), (fresh_client, "gpt-5.5")]) as mock_get_client,
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": "summarize"}],
main_runtime={"provider": "openai-codex", "model": "gpt-5.5"},
)
assert resp.choices[0].message.content == "fresh-auto-codex"
mock_refresh.assert_called_once_with("openai-codex")
mock_evict.assert_called_once_with("auto")
assert mock_get_client.call_args_list[1].args[0] == "openai-codex"
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1
def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
stale_client = MagicMock()
stale_client.base_url = "https://api.anthropic.com"