From d30ea65c9bc65b8845f19c05e85e66ad10d3d7ec Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:36:56 -0700 Subject: [PATCH] fix: URL-based auth for third-party Anthropic endpoints + CI test fixes (#4148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- agent/anthropic_adapter.py | 29 ++++++++++++++++++++++----- tests/agent/test_auxiliary_client.py | 4 ++-- tests/test_cli_provider_resolution.py | 1 + tests/test_trajectory_compressor.py | 5 +++-- tests/tools/test_browser_camofox.py | 7 ++++++- 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 879d1b34b..76bc8ff2e 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -152,20 +152,31 @@ def _is_oauth_token(key: str) -> bool: Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens 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: return False # Regular Console API keys use x-api-key header if key.startswith("sk-ant-api"): return False - # Azure AI Foundry keys don't start with sk-ant- at all — treat as regular API key - if not key.startswith("sk-ant-"): - return False - # Everything else (setup-tokens sk-ant-oat, managed keys, JWTs) uses Bearer auth + # Everything else (setup-tokens, managed keys, JWTs) uses Bearer auth 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: """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 if _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): # OAuth access token / setup-token → Bearer auth + Claude Code identity. # Anthropic routes OAuth requests based on user-agent and headers; diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 28ef57289..35dcee7ad 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -310,7 +310,7 @@ class TestExpiredCodexFallback: def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch): """OAuth-style tokens should get is_oauth=True (token is not sk-ant-api-*).""" # 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: mock_build.return_value = MagicMock() from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient @@ -364,7 +364,7 @@ class TestExpiredCodexFallback: def test_claude_code_oauth_env_sets_flag(self, monkeypatch): """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) with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 667cd33a6..b9960f08c 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -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_main, "_prompt_provider_choice", lambda choices: len(choices) - 1) + monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})()) hermes_main.cmd_model(SimpleNamespace()) output = capsys.readouterr().out diff --git a/tests/test_trajectory_compressor.py b/tests/test_trajectory_compressor.py index c95a3af94..72708b8d9 100644 --- a/tests/test_trajectory_compressor.py +++ b/tests/test_trajectory_compressor.py @@ -405,12 +405,13 @@ class TestGenerateSummary: @pytest.mark.asyncio async def test_generate_summary_async_handles_none_content(self): tc = _make_compressor() - tc.async_client = MagicMock() - tc.async_client.chat.completions.create = AsyncMock( + mock_client = MagicMock() + mock_client.chat.completions.create = AsyncMock( return_value=SimpleNamespace( choices=[SimpleNamespace(message=SimpleNamespace(content=None))] ) ) + tc._get_async_client = MagicMock(return_value=mock_client) metrics = TrajectoryMetrics() summary = await tc._generate_summary_async("Turn content", metrics) diff --git a/tests/tools/test_browser_camofox.py b/tests/tools/test_browser_camofox.py index a59862b9b..f9ff0e7c7 100644 --- a/tests/tools/test_browser_camofox.py +++ b/tests/tools/test_browser_camofox.py @@ -235,8 +235,13 @@ class TestCamofoxGetImages: mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"}) 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={ - "images": [{"src": "https://x.com/img.png", "alt": "Logo"}], + "snapshot": snapshot_text, }) result = json.loads(camofox_get_images(task_id="t10")) assert result["success"] is True