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
12 changes: 11 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
118 changes: 118 additions & 0 deletions tests/agent/test_auxiliary_explicit_base_anthropic.py
Original file line number Diff line number Diff line change
@@ -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.<task>`` 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")
Loading