fix: URL-based auth for third-party Anthropic endpoints + CI test fixes (#4148)
* fix(tests): mock sys.stdin.isatty for cmd_model TTY guard * fix(tests): update camofox snapshot format + trajectory compressor mock path - test_browser_camofox: mock response now uses snapshot format (accessibility tree) - test_trajectory_compressor: mock _get_async_client instead of setting async_client directly * fix: URL-based auth detection for third-party Anthropic endpoints + test fixes Reverts the key-prefix approach from #4093 which broke JWT and managed key OAuth detection. Instead, detects third-party endpoints by URL: if base_url is set and isn't anthropic.com, it's a proxy (Azure AI Foundry, AWS Bedrock, etc.) that uses x-api-key regardless of key format. Auth decision chain is now: 1. _requires_bearer_auth(url) → MiniMax → Bearer 2. _is_third_party_anthropic_endpoint(url) → Azure/Bedrock → x-api-key 3. _is_oauth_token(key) → OAuth on direct Anthropic → Bearer 4. else → x-api-key Also includes test fixes from PR #4051 by @erosika: - Mock sys.stdin.isatty for cmd_model TTY guard - Update camofox snapshot format mock - Fix trajectory compressor async client mock path --------- Co-authored-by: Erosika <eri@plasticlabs.ai>
This commit is contained in:
@@ -152,20 +152,31 @@ def _is_oauth_token(key: str) -> bool:
|
|||||||
|
|
||||||
Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens
|
Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens
|
||||||
starting with 'sk-ant-oat', managed keys, JWTs, etc.) needs Bearer auth.
|
starting with 'sk-ant-oat', managed keys, JWTs, etc.) needs Bearer auth.
|
||||||
Azure AI Foundry keys (non sk-ant- prefixed) should use x-api-key, not Bearer.
|
|
||||||
"""
|
"""
|
||||||
if not key:
|
if not key:
|
||||||
return False
|
return False
|
||||||
# Regular Console API keys use x-api-key header
|
# Regular Console API keys use x-api-key header
|
||||||
if key.startswith("sk-ant-api"):
|
if key.startswith("sk-ant-api"):
|
||||||
return False
|
return False
|
||||||
# Azure AI Foundry keys don't start with sk-ant- at all — treat as regular API key
|
# Everything else (setup-tokens, managed keys, JWTs) uses Bearer auth
|
||||||
if not key.startswith("sk-ant-"):
|
|
||||||
return False
|
|
||||||
# Everything else (setup-tokens sk-ant-oat, managed keys, JWTs) uses Bearer auth
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||||||
|
"""Return True for non-Anthropic endpoints using the Anthropic Messages API.
|
||||||
|
|
||||||
|
Third-party proxies (Azure AI Foundry, AWS Bedrock, self-hosted) authenticate
|
||||||
|
with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth
|
||||||
|
detection should be skipped for these endpoints.
|
||||||
|
"""
|
||||||
|
if not base_url:
|
||||||
|
return False # No base_url = direct Anthropic API
|
||||||
|
normalized = base_url.rstrip("/").lower()
|
||||||
|
if "anthropic.com" in normalized:
|
||||||
|
return False # Direct Anthropic API — OAuth applies
|
||||||
|
return True # Any other endpoint is a third-party proxy
|
||||||
|
|
||||||
|
|
||||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
||||||
|
|
||||||
@@ -209,6 +220,14 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
|||||||
kwargs["auth_token"] = api_key
|
kwargs["auth_token"] = api_key
|
||||||
if _COMMON_BETAS:
|
if _COMMON_BETAS:
|
||||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||||
|
elif _is_third_party_anthropic_endpoint(base_url):
|
||||||
|
# Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their
|
||||||
|
# own API keys with x-api-key auth. Skip OAuth detection — their keys
|
||||||
|
# don't follow Anthropic's sk-ant-* prefix convention and would be
|
||||||
|
# misclassified as OAuth tokens.
|
||||||
|
kwargs["api_key"] = api_key
|
||||||
|
if _COMMON_BETAS:
|
||||||
|
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||||
elif _is_oauth_token(api_key):
|
elif _is_oauth_token(api_key):
|
||||||
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
||||||
# Anthropic routes OAuth requests based on user-agent and headers;
|
# Anthropic routes OAuth requests based on user-agent and headers;
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ class TestExpiredCodexFallback:
|
|||||||
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
|
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
|
||||||
"""OAuth-style tokens should get is_oauth=True (token is not sk-ant-api-*)."""
|
"""OAuth-style tokens should get is_oauth=True (token is not sk-ant-api-*)."""
|
||||||
# Mock resolve_anthropic_token to return an OAuth-style token
|
# Mock resolve_anthropic_token to return an OAuth-style token
|
||||||
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-hermes-oauth-test"), \
|
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="hermes-oauth-jwt-token"), \
|
||||||
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
|
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
|
||||||
mock_build.return_value = MagicMock()
|
mock_build.return_value = MagicMock()
|
||||||
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
|
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
|
||||||
@@ -364,7 +364,7 @@ class TestExpiredCodexFallback:
|
|||||||
|
|
||||||
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
|
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
|
||||||
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
|
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
|
||||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-cc-oauth-test")
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "cc-oauth-token-test")
|
||||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
|
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
|
||||||
mock_build.return_value = MagicMock()
|
mock_build.return_value = MagicMock()
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys):
|
|||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider)
|
monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider)
|
||||||
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: len(choices) - 1)
|
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: len(choices) - 1)
|
||||||
|
monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})())
|
||||||
|
|
||||||
hermes_main.cmd_model(SimpleNamespace())
|
hermes_main.cmd_model(SimpleNamespace())
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
|
|||||||
@@ -405,12 +405,13 @@ class TestGenerateSummary:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_generate_summary_async_handles_none_content(self):
|
async def test_generate_summary_async_handles_none_content(self):
|
||||||
tc = _make_compressor()
|
tc = _make_compressor()
|
||||||
tc.async_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
tc.async_client.chat.completions.create = AsyncMock(
|
mock_client.chat.completions.create = AsyncMock(
|
||||||
return_value=SimpleNamespace(
|
return_value=SimpleNamespace(
|
||||||
choices=[SimpleNamespace(message=SimpleNamespace(content=None))]
|
choices=[SimpleNamespace(message=SimpleNamespace(content=None))]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
tc._get_async_client = MagicMock(return_value=mock_client)
|
||||||
metrics = TrajectoryMetrics()
|
metrics = TrajectoryMetrics()
|
||||||
|
|
||||||
summary = await tc._generate_summary_async("Turn content", metrics)
|
summary = await tc._generate_summary_async("Turn content", metrics)
|
||||||
|
|||||||
@@ -235,8 +235,13 @@ class TestCamofoxGetImages:
|
|||||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"})
|
mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"})
|
||||||
camofox_navigate("https://x.com", task_id="t10")
|
camofox_navigate("https://x.com", task_id="t10")
|
||||||
|
|
||||||
|
# camofox_get_images parses images from the accessibility tree snapshot
|
||||||
|
snapshot_text = (
|
||||||
|
'- img "Logo"\n'
|
||||||
|
' /url: https://x.com/img.png\n'
|
||||||
|
)
|
||||||
mock_get.return_value = _mock_response(json_data={
|
mock_get.return_value = _mock_response(json_data={
|
||||||
"images": [{"src": "https://x.com/img.png", "alt": "Logo"}],
|
"snapshot": snapshot_text,
|
||||||
})
|
})
|
||||||
result = json.loads(camofox_get_images(task_id="t10"))
|
result = json.loads(camofox_get_images(task_id="t10"))
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|||||||
Reference in New Issue
Block a user