fix(anthropic): resolve the count-tokens base like chat when the deployment has none

This commit is contained in:
mateo-berri 2026-09-02 14:43:37 -07:00
parent 412890ff52
commit 272ccfdd22
4 changed files with 44 additions and 13 deletions

View file

@ -7,7 +7,7 @@ This module handles the transformation of requests to Anthropic's CountTokens AP
from typing import Any, Final
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
from litellm.llms.anthropic.wif import anthropic_base_without_chat_suffix
from litellm.llms.anthropic.wif import resolve_anthropic_base
class AnthropicCountTokensConfig:
@ -27,14 +27,14 @@ class AnthropicCountTokensConfig:
Args:
api_base: The deployment's api_base, which names the chat surface (a host, or a
base already carrying ``/v1`` or ``/v1/messages``); the count-tokens path is
appended to it, so it is never the full count-tokens URL
appended to it, so it is never the full count-tokens URL. Unset or empty falls
back to ``ANTHROPIC_API_BASE`` / ``ANTHROPIC_BASE_URL`` and then Anthropic's
host, the same resolution chat and the federated exchange use
Returns:
The endpoint URL for the CountTokens API
"""
if api_base is None:
return "https://api.anthropic.com/v1/messages/count_tokens"
return anthropic_base_without_chat_suffix(api_base) + "/v1/messages/count_tokens"
return resolve_anthropic_base(api_base) + "/v1/messages/count_tokens"
def transform_request_to_count_tokens(
self,

View file

@ -254,7 +254,7 @@ def get_anthropic_wif_token(
params: Final = resolve_anthropic_wif_params(litellm_params)
if params is None:
return None
exchange_base: Final = _token_exchange_base(api_base)
exchange_base: Final = resolve_anthropic_base(api_base)
_raise_if_exchange_host_untrusted(exchange_base, model)
result: Final = engine.get_token(build_anthropic_wif_spec(params, exchange_base))
return _token_from_result(result, model, params)
@ -269,7 +269,7 @@ async def aget_anthropic_wif_token(
params: Final = resolve_anthropic_wif_params(litellm_params)
if params is None:
return None
exchange_base: Final = _token_exchange_base(api_base)
exchange_base: Final = resolve_anthropic_base(api_base)
_raise_if_exchange_host_untrusted(exchange_base, model)
result: Final = await engine.aget_token(build_anthropic_wif_spec(params, exchange_base))
return _token_from_result(result, model, params)
@ -283,11 +283,12 @@ def _token_from_result(result: ExchangeResult, model: str, params: AnthropicWifP
_raise_anthropic_wif_error(result, model=model, workspace_id_set=params.workspace_id is not None)
def _token_exchange_base(api_base: str | None) -> str:
"""Exchange base for any caller-supplied form of the deployment base: trailing
slashes and chat-appended ``/v1/messages`` suffixes stripped, so every tier
derives the same token URL (and cache key) for the same deployment."""
return anthropic_base_without_chat_suffix(api_base if api_base is not None else _resolve_default_api_base())
def resolve_anthropic_base(api_base: str | None) -> str:
"""The base every Anthropic tier derives its URLs from: the deployment api_base when set,
else ``ANTHROPIC_API_BASE`` / ``ANTHROPIC_BASE_URL``, else Anthropic's host, with trailing
slashes and chat-appended ``/v1/messages`` suffixes stripped, so the token URL, the cache key
and the count-tokens URL all agree for the same deployment."""
return anthropic_base_without_chat_suffix(api_base or _resolve_default_api_base())
def _trusted_exchange_hosts() -> frozenset[str]:

View file

@ -99,16 +99,41 @@ def test_transform_no_system_no_tools():
("api_base", "expected"),
[
(None, "https://api.anthropic.com/v1/messages/count_tokens"),
("", "https://api.anthropic.com/v1/messages/count_tokens"),
("https://gateway.example", "https://gateway.example/v1/messages/count_tokens"),
("https://gateway.example/", "https://gateway.example/v1/messages/count_tokens"),
("https://gateway.example/v1", "https://gateway.example/v1/messages/count_tokens"),
("https://gateway.example/anthropic/v1/messages", "https://gateway.example/anthropic/v1/messages/count_tokens"),
],
)
def test_endpoint_appends_count_tokens_path_to_deployment_api_base(api_base, expected):
def test_endpoint_appends_count_tokens_path_to_deployment_api_base(api_base, expected, monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_BASE", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
assert AnthropicCountTokensConfig().get_anthropic_count_tokens_endpoint(api_base) == expected
@pytest.mark.parametrize("env_name", ["ANTHROPIC_API_BASE", "ANTHROPIC_BASE_URL"])
@pytest.mark.parametrize("api_base", [None, ""])
def test_endpoint_without_deployment_api_base_follows_env_base(env_name, api_base, monkeypatch):
"""Chat and the federated exchange resolve an unset deployment base through the environment,
so an env-only gateway must receive the count too, never Anthropic's public host."""
monkeypatch.delenv("ANTHROPIC_API_BASE", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
monkeypatch.setenv(env_name, "https://env-gateway.example/v1/messages/")
assert (
AnthropicCountTokensConfig().get_anthropic_count_tokens_endpoint(api_base)
== "https://env-gateway.example/v1/messages/count_tokens"
)
def test_endpoint_prefers_deployment_api_base_over_env_base(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_BASE", "https://env-gateway.example")
assert (
AnthropicCountTokensConfig().get_anthropic_count_tokens_endpoint("https://gateway.example/v1")
== "https://gateway.example/v1/messages/count_tokens"
)
@pytest.fixture
def httpx_transport_clients(monkeypatch):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)

View file

@ -303,6 +303,11 @@ class TestBaseUrlDerivation:
monkeypatch.setenv("ANTHROPIC_API_BASE", "https://env.example.com")
assert self._mint(None, monkeypatch) == "https://env.example.com/v1/oauth/token"
def test_empty_api_base_falls_back_like_unset(self, monkeypatch: pytest.MonkeyPatch):
"""Chat treats an empty deployment api_base as unset; the exchange must not refuse host ''."""
monkeypatch.setenv("ANTHROPIC_API_BASE", "https://env.example.com")
assert self._mint("", monkeypatch) == "https://env.example.com/v1/oauth/token"
def test_env_base_url(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://base.example.com")
assert self._mint(None, monkeypatch) == "https://base.example.com/v1/oauth/token"