diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 5532d5eab1f22..aa4b59292fd67 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -6281,8 +6281,18 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", if provider == "custom": custom_base = "" custom_key = "" + # Base passed to _wrap_if_needed for the Anthropic-wrap decision. It + # normally equals custom_base, but anthropic_messages talks to the + # /anthropic surface directly, so it must keep the raw /anthropic base + # while the plain OpenAI client (created from custom_base below, and the + # OpenAI-wire fallback taken when the anthropic SDK is unavailable) still + # uses the /v1-rewritten base so it never lands on + # /anthropic/chat/completions. Empty means "use custom_base". See #16254. + wrap_base = "" if explicit_base_url: custom_base = _to_openai_base_url(explicit_base_url).strip() + if api_mode == "anthropic_messages": + wrap_base = (explicit_base_url or "").strip().rstrip("/") custom_key = ( (explicit_api_key or "").strip() or _scoped_key_env("OPENAI_API_KEY") @@ -6339,7 +6349,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", if _merged_custom: extra["default_headers"] = _merged_custom client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **extra) - client = _wrap_if_needed(client, final_model, custom_base, custom_key) + client = _wrap_if_needed(client, final_model, wrap_base or custom_base, custom_key) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) # Try custom first, then API-key providers (Codex excluded here: diff --git a/tests/agent/test_auxiliary_explicit_base_anthropic.py b/tests/agent/test_auxiliary_explicit_base_anthropic.py new file mode 100644 index 0000000000000..f46fdec25f91d --- /dev/null +++ b/tests/agent/test_auxiliary_explicit_base_anthropic.py @@ -0,0 +1,118 @@ +"""Tests for resolve_provider_client's ``custom`` + ``explicit_base_url`` branch +when the endpoint speaks Anthropic Messages. + +When the main provider is ``custom`` and its ``base_url`` ends in ``/anthropic`` +(a proxied Anthropic gateway — MiniMax, Zhipu GLM, LiteLLM, or a self-hosted +LLM proxy), auxiliary tasks reach ``resolve_provider_client("custom", +explicit_base_url=..., api_mode="anthropic_messages")`` — directly for a +per-task ``auxiliary.`` override, or via ``_resolve_auto`` Step 1 which +forwards the main runtime's ``api_mode``. + +The bug (issue #16254): this branch called ``_to_openai_base_url()`` +unconditionally, stripping the ``/anthropic`` tail to ``/v1`` even for +``api_mode=anthropic_messages``. The Anthropic wrapper then never saw the real +``/anthropic`` path, so every side task (title generation, compression, vision, +web_extract, session_search) hit ``.../v1/chat/completions`` on a Messages-only +endpoint and failed. The sibling named-custom-provider branch already guarded +the rewrite on ``api_mode``; this makes the explicit-base branch consistent. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for key in ( + "OPENAI_API_KEY", "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", + ): + monkeypatch.delenv(key, raising=False) + + +_ANTHROPIC_BASE = "https://gateway.example.com/proxy/anthropic" + + +def _client_base_url(client) -> str: + for chain in (("base_url",), ("_real_client", "base_url"), ("_client", "base_url")): + obj = client + try: + for attr in chain: + obj = getattr(obj, attr) + return str(obj) + except AttributeError: + continue + return "" + + +def test_explicit_base_anthropic_messages_keeps_anthropic_path(): + """api_mode=anthropic_messages must build the Anthropic wrapper on the raw + ``/anthropic`` base — not the ``/v1``-rewritten one.""" + from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient + + fake_anthropic = MagicMock(name="anthropic_sdk_client") + with patch( + "agent.anthropic_adapter.build_anthropic_client", + return_value=fake_anthropic, + ) as mock_build: + client, model = resolve_provider_client( + "custom", + model="claude-opus-4-8", + explicit_base_url=_ANTHROPIC_BASE, + explicit_api_key="k", + api_mode="anthropic_messages", + ) + + assert isinstance(client, AnthropicAuxiliaryClient), ( + "custom endpoint with api_mode=anthropic_messages must return the native " + f"Anthropic wrapper, got {type(client).__name__}" + ) + # The wrapper — and the Anthropic SDK client it was built from — must keep + # the /anthropic path, NOT the /v1-rewritten one. + mock_build.assert_called_once_with("k", _ANTHROPIC_BASE) + assert client.base_url == _ANTHROPIC_BASE + assert model == "claude-opus-4-8" + + +def test_explicit_base_anthropic_messages_openai_fallback_uses_v1(): + """When the anthropic SDK is unavailable, _maybe_wrap_anthropic returns the + plain OpenAI client — which must be on the /v1 base, never /anthropic.""" + from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient + + with patch( + "agent.anthropic_adapter.build_anthropic_client", + side_effect=ImportError("anthropic package not installed"), + ): + client, model = resolve_provider_client( + "custom", + model="claude-opus-4-8", + explicit_base_url=_ANTHROPIC_BASE, + explicit_api_key="k", + api_mode="anthropic_messages", + ) + + assert client is not None + assert not isinstance(client, AnthropicAuxiliaryClient) + # /anthropic → /v1 so the OpenAI SDK never hits /anthropic/chat/completions. + assert _client_base_url(client).rstrip("/").endswith("/proxy/v1") + + +def test_explicit_base_without_anthropic_mode_preserves_v1_rewrite(): + """Regression: with no anthropic_messages api_mode, the /anthropic → /v1 + OpenAI-wire rewrite is preserved (fix is scoped, no behavior change).""" + from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient + + client, model = resolve_provider_client( + "custom", + model="my-model", + explicit_base_url=_ANTHROPIC_BASE, + explicit_api_key="k", + api_mode="chat_completions", + ) + + assert client is not None + assert not isinstance(client, AnthropicAuxiliaryClient) + assert _client_base_url(client).rstrip("/").endswith("/proxy/v1")