diff --git a/litellm/llms/anthropic/count_tokens/transformation.py b/litellm/llms/anthropic/count_tokens/transformation.py index 7db2ea67830..91f3896f774 100644 --- a/litellm/llms/anthropic/count_tokens/transformation.py +++ b/litellm/llms/anthropic/count_tokens/transformation.py @@ -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, diff --git a/litellm/llms/anthropic/wif.py b/litellm/llms/anthropic/wif.py index fda41e0bf7d..75b2be3ba0f 100644 --- a/litellm/llms/anthropic/wif.py +++ b/litellm/llms/anthropic/wif.py @@ -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]: diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_count_tokens_transformation.py b/tests/test_litellm/llms/anthropic/test_anthropic_count_tokens_transformation.py index 8953b351acd..5b6b4251e4d 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_count_tokens_transformation.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_count_tokens_transformation.py @@ -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) diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py index 485c353d5da..c72e2e81eb0 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_wif.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_wif.py @@ -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"