diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 0c346cad56f7..52ec2e02c256 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -534,8 +534,9 @@ def _requires_bearer_auth(base_url: str | None) -> bool: Some third-party /anthropic endpoints implement Anthropic's Messages API but require Authorization: Bearer instead of Anthropic's native x-api-key header. - MiniMax's global and China Anthropic-compatible endpoints, and Azure AI - Foundry's Anthropic-style endpoint follow this pattern. + MiniMax's global and China Anthropic-compatible endpoints, Azure AI + Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy + follow this pattern. """ normalized = _normalize_base_url_text(base_url) if not normalized: @@ -544,7 +545,11 @@ def _requires_bearer_auth(base_url: str | None) -> bool: return ( normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")) or "azure.com" in normalized - or "palantirfoundry" in normalized + # Palantir Foundry LLM proxy (.palantirfoundry.com/api/v2/llm/proxy/anthropic) + # rejects x-api-key with 401 and requires Authorization: Bearer. + # Hostname match (not substring) so e.g. evil.com/palantirfoundry + # paths don't trigger Bearer auth. + or base_url_host_matches(normalized, "palantirfoundry.com") ) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 3b1af7ea6f2f..8cb68f2a0ad2 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4392,17 +4392,32 @@ def _resolve_auto( explicit_api_key = runtime_api_key or None elif main_provider.startswith("custom:"): # Named custom provider (custom_providers / providers dict entry). - # KEEP the full ``custom:`` so resolve_provider_client lands in - # the named-custom-provider arm — that arm honours the entry's - # api_mode (e.g. anthropic_messages → AnthropicAuxiliaryClient, - # avoiding the /anthropic→/v1 rewrite that 404s against proxies - # like Palantir Foundry's Anthropic surface). Do NOT collapse to - # plain "custom"; that path strips /anthropic and routes through - # OpenAI chat.completions. - resolved_provider = main_provider - # base_url / api_key come from the named entry itself — leave the - # explicit_* overrides unset so the named arm reads them from config. - if runtime_api_key: + _has_named_entry = False + try: + from hermes_cli.runtime_provider import _get_named_custom_provider + _has_named_entry = _get_named_custom_provider(main_provider) is not None + except ImportError: + pass + if _has_named_entry: + # KEEP the full ``custom:`` so resolve_provider_client + # lands in the named-custom-provider arm — that arm honours the + # entry's api_mode (e.g. anthropic_messages → + # AnthropicAuxiliaryClient, avoiding the /anthropic→/v1 rewrite + # that 404s against proxies like Palantir Foundry's Anthropic + # surface). Do NOT collapse to plain "custom"; that path + # strips /anthropic and routes through OpenAI chat.completions. + # base_url and api_key come from the named entry itself, so + # leave the explicit_* overrides unset. + resolved_provider = main_provider + explicit_base_url = None + elif runtime_base_url: + # Config-less named custom provider (#34777): the entry only + # exists in the live runtime, so collapse to the anonymous + # custom arm with the runtime endpoint + key. + resolved_provider = "custom" + explicit_base_url = runtime_base_url + explicit_api_key = runtime_api_key or None + elif runtime_api_key: explicit_api_key = runtime_api_key elif runtime_api_key: # Pin auxiliary to the same api_key as the active main chat session diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index febdf53a3384..1519c41c81ae 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -201,6 +201,44 @@ class TestBuildAnthropicClient: betas = kwargs["default_headers"]["anthropic-beta"] assert "context-1m-2025-08-07" in betas + def test_palantir_foundry_anthropic_endpoint_uses_bearer_auth(self): + """Palantir Foundry's LLM proxy requires Authorization: Bearer. + + Regression test for PR #36043: Palantir's + ``.palantirfoundry.com/api/v2/llm/proxy/anthropic`` endpoint + rejects x-api-key with 401 — the SDK must be built with auth_token. + """ + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "foundry-secret-123", + base_url="https://acme.palantirfoundry.com/api/v2/llm/proxy/anthropic", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["auth_token"] == "foundry-secret-123" + assert "api_key" not in kwargs + + def test_palantir_bearer_auth_matches_hostname_not_substring(self): + """The palantirfoundry check must be a hostname match, not a loose + substring match — a URL merely *containing* the string (path segment, + lookalike domain) must not trigger Bearer auth.""" + from agent.anthropic_adapter import _requires_bearer_auth + + # Real Foundry hosts (org subdomains) → Bearer. + assert _requires_bearer_auth( + "https://acme.palantirfoundry.com/api/v2/llm/proxy/anthropic" + ) is True + assert _requires_bearer_auth("https://palantirfoundry.com/anthropic") is True + # Substring false-positives → x-api-key (default). + assert _requires_bearer_auth( + "https://evil.example.com/palantirfoundry/anthropic" + ) is False + assert _requires_bearer_auth( + "https://palantirfoundry.com.evil.example/anthropic" + ) is False + assert _requires_bearer_auth( + "https://notpalantirfoundry.com/anthropic" + ) is False + def test_disables_sdk_retries_for_api_key(self): """#26293: the SDK's default max_retries=2 ignores Retry-After and double-retries inside hermes's outer loop. We delegate retry entirely diff --git a/tests/agent/test_set_runtime_main_custom_provider.py b/tests/agent/test_set_runtime_main_custom_provider.py index bb6a04a4beb3..e89ae5552ea4 100644 --- a/tests/agent/test_set_runtime_main_custom_provider.py +++ b/tests/agent/test_set_runtime_main_custom_provider.py @@ -224,3 +224,72 @@ class TestResolveAutoCustomEndToEnd: assert base and base.rstrip("/") == "https://withcfg.example/v1" finally: mod.clear_runtime_main() + + def test_named_custom_anthropic_messages_keeps_full_name_and_url( + self, tmp_path, monkeypatch): + """PR #36043: a ``custom:`` main provider whose config entry + declares ``api_mode: anthropic_messages`` must reach the + named-custom-provider arm of resolve_provider_client — NOT the + anonymous-custom arm, whose ``_to_openai_base_url`` rewrite strips a + trailing ``/anthropic`` into ``/v1`` and 404s against proxies like + Palantir Foundry's Anthropic surface. The resulting client must be an + AnthropicAuxiliaryClient pointed at the ORIGINAL /anthropic URL.""" + import agent.auxiliary_client as mod + + for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY", + "OPENAI_BASE_URL"): + monkeypatch.delenv(var, raising=False) + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + proxy_base = "https://acme.palantirfoundry.com/api/v2/llm/proxy/anthropic" + (hermes_home / "config.yaml").write_text( + "model:\n" + " default: claude-4-6-opus\n" + " provider: 'custom:palantir'\n" + " base_url: ''\n" + "custom_providers:\n" + " - name: palantir\n" + f" base_url: '{proxy_base}'\n" + " model: claude-4-6-opus\n" + " api_key: foundry-token\n" + " api_mode: anthropic_messages\n" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mod.clear_runtime_main() + try: + # The live runtime carries the same base_url the main agent uses — + # the regression collapsed the provider to bare "custom" whenever a + # runtime base_url was present, which routed here through the + # OpenAI-wire /anthropic→/v1 rewrite. + mod.set_runtime_main( + "custom:palantir", + "claude-4-6-opus", + base_url=proxy_base, + api_key="foundry-token", + api_mode="anthropic_messages", + ) + client, resolved = mod.resolve_provider_client("auto", None) + assert client is not None, ( + "custom: with anthropic_messages entry resolved to None" + ) + assert resolved == "claude-4-6-opus" + assert client.__class__.__name__ == "AnthropicAuxiliaryClient", ( + f"expected AnthropicAuxiliaryClient, got {client.__class__.__name__}" + " — the custom: main provider was collapsed to the" + " anonymous-custom OpenAI-wire arm (PR #36043 regression)" + ) + # The original /anthropic URL must survive — no /v1 rewrite. + assert getattr(client, "base_url", "").rstrip("/") == proxy_base + + # Wiring check: _resolve_auto must hand the FULL custom: + # string to resolve_provider_client, with no explicit_base_url + # override (the named arm reads base_url/api_key from config). + with patch.object(mod, "resolve_provider_client") as mock_resolve: + mock_resolve.return_value = (MagicMock(), "claude-4-6-opus") + mod._resolve_auto(main_runtime=None) + mock_resolve.assert_called_once() + assert mock_resolve.call_args.args[0] == "custom:palantir" + assert mock_resolve.call_args.kwargs["explicit_base_url"] is None + finally: + mod.clear_runtime_main()