fix(anthropic): keep OAuth rotation on accepted native proxies
The anthropic.com-only refresh guard stopped token rotation on hosts the resolver itself accepts as native Anthropic (*.claude.com, /anthropic proxies), which already receive the Anthropic token at startup. Long sessions there hit 401 and the recovery refresh was refused too. Official hosts (anthropic.com, claude.com) rotate as before; any other host rotates only when the current key is already an Anthropic credential (sk-ant- or OAuth), so the #17829 case (custom key swapped for ANTHROPIC_API_KEY / the OAuth token) stays blocked. Azure keeps its static-key exclusion. Also corrects the _resolve_openrouter_runtime docstring, which still claimed OPENAI_BASE_URL is never consulted.
This commit is contained in:
@@ -872,16 +872,21 @@ class ClientLifecycleMixin:
|
||||
def _try_refresh_anthropic_client_credentials(self) -> bool:
|
||||
# Only native Anthropic rotates OAuth tokens; other anthropic_messages providers (MiniMax, Alibaba, ...)
|
||||
# and Azure use static keys — a refresh would pick up the ~/.claude OAuth token and break auth.
|
||||
# Any other host under provider 'anthropic' (a URL-bearing alias, #28660) holds no Anthropic
|
||||
# credential either: refreshing would ship ANTHROPIC_API_KEY / the OAuth token to it. Match
|
||||
# the hostname, not a substring, so ``proxy.example/anthropic.com`` stays foreign.
|
||||
anthropic_base_url = getattr(self, "_anthropic_base_url", "") or ""
|
||||
if (
|
||||
self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key")
|
||||
or self.provider != "anthropic"
|
||||
or (anthropic_base_url and not base_url_host_matches(anthropic_base_url, "anthropic.com"))
|
||||
or self.provider != "anthropic" or base_url_host_matches(anthropic_base_url, "azure.com")
|
||||
):
|
||||
return False
|
||||
# Off the official hosts (a /anthropic proxy the resolver accepts, or a URL-bearing alias,
|
||||
# #28660) rotate only a credential the endpoint already holds: swapping a custom key for
|
||||
# ANTHROPIC_API_KEY / the OAuth token would leak it (#17829). Hostname match, not substring,
|
||||
# so ``proxy.example/anthropic.com`` stays foreign.
|
||||
official_host = not anthropic_base_url or any(
|
||||
base_url_host_matches(anthropic_base_url, host) for host in ("anthropic.com", "claude.com"))
|
||||
current_key = str(self._anthropic_api_key or "")
|
||||
if not official_host and not (current_key.startswith("sk-ant-") or getattr(self, "_is_anthropic_oauth", False)):
|
||||
return False
|
||||
try:
|
||||
from agent.anthropic_credentials import resolve_anthropic_token
|
||||
new_token = resolve_anthropic_token(model=self.model)
|
||||
|
||||
@@ -118,8 +118,9 @@ def _resolve_openrouter_runtime(
|
||||
) -> Dict[str, Any]:
|
||||
"""Terminal resolver: OpenRouter, or a bare/aliased ``custom`` endpoint. base_url precedence:
|
||||
explicit > CUSTOM_BASE_URL > trusted ``model.base_url`` > OPENROUTER_BASE_URL > default.
|
||||
OPENAI_BASE_URL is deliberately NOT consulted — config.yaml is the single source of truth for
|
||||
endpoint URLs. OpenRouter contexts prefer OPENROUTER_API_KEY; custom endpoints never receive the
|
||||
OPENAI_BASE_URL never picks the endpoint (config.yaml is the single source of truth for endpoint
|
||||
URLs); it is read only to keep an OPENAI_API_KEY bound to another host out of the OpenRouter
|
||||
fallback. OpenRouter contexts prefer OPENROUTER_API_KEY; custom endpoints never receive the
|
||||
OpenRouter key and only get env keys gated on their authoritative hosts."""
|
||||
rp = _rp()
|
||||
model_cfg = rp._get_model_config()
|
||||
|
||||
@@ -78,6 +78,7 @@ class TestOAuthFlagOnRefresh:
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"https://llmbox.bytedance.net",
|
||||
"http://127.0.0.1:8080/anthropic.com", # substring spoof: the host is still foreign
|
||||
"https://llmbox.bytedance.net/anthropic", # accepted proxy shape, but holds a custom key
|
||||
])
|
||||
def test_third_party_endpoint_skips_refresh(self, agent, base_url):
|
||||
"""provider == 'anthropic' on a third-party endpoint must not refresh: the refresh
|
||||
@@ -101,6 +102,30 @@ class TestOAuthFlagOnRefresh:
|
||||
assert agent._anthropic_api_key == "custom-api-key"
|
||||
assert agent._is_anthropic_oauth is False
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"https://api.claude.com",
|
||||
"https://llm.corp.example/anthropic",
|
||||
])
|
||||
def test_accepted_native_proxy_keeps_rotating_anthropic_token(self, agent, base_url):
|
||||
"""Hosts the resolver accepts as native Anthropic already hold the Anthropic token, so
|
||||
blocking the refresh would strand an expiring OAuth token (401 with no recovery)."""
|
||||
old, new = "sk-ant-oat01-old-token-aaaaaaaa", "sk-ant-oat01-new-token-bbbbbbbb"
|
||||
agent.api_mode = "anthropic_messages"
|
||||
agent.provider = "anthropic"
|
||||
agent._anthropic_api_key = old
|
||||
agent._anthropic_base_url = base_url
|
||||
agent._anthropic_client = MagicMock()
|
||||
agent._is_anthropic_oauth = True
|
||||
|
||||
with (
|
||||
patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=new),
|
||||
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
||||
):
|
||||
result = agent._try_refresh_anthropic_client_credentials()
|
||||
|
||||
assert result is True
|
||||
assert agent._anthropic_api_key == new
|
||||
|
||||
|
||||
|
||||
class TestOAuthFlagOnCredentialSwap:
|
||||
|
||||
Reference in New Issue
Block a user