Skip to content
Merged
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
29 changes: 24 additions & 5 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ def test_expired_codex_custom_endpoint_wins(self, tmp_path, 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-*)."""
# 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
Expand Down Expand Up @@ -364,7 +364,7 @@ def test_jwt_invalid_json_payload_passes_through(self, tmp_path, monkeypatch):

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()
Expand Down
1 change: 1 addition & 0 deletions tests/test_cli_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ def _resolve_provider(requested, **kwargs):

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
Expand Down
5 changes: 3 additions & 2 deletions tests/test_trajectory_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,12 +405,13 @@ def test_generate_summary_handles_none_content(self):
@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)
Expand Down
7 changes: 6 additions & 1 deletion tests/tools/test_browser_camofox.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,13 @@ def test_get_images(self, mock_get, mock_post, monkeypatch):
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
Expand Down
Loading