From abc38935fa8dc0f1e9c0b33d2c4256e22f76e33d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:11:17 -0700 Subject: [PATCH] fix(anthropic): override custom_llm_provider in provider config subclasses so capability probes use the right namespace --- litellm/llms/anthropic/common_utils.py | 30 +++++++++- .../anthropic/messages_transformation.py | 4 ++ .../llms/databricks/chat/transformation.py | 4 ++ .../github_copilot/messages/transformation.py | 4 ++ .../openai_like/messages/transformation.py | 4 ++ .../transformation.py | 4 ++ ...azure_anthropic_messages_transformation.py | 56 +++++++++++++++++++ .../test_databricks_chat_transformation.py | 7 +++ ..._github_copilot_messages_transformation.py | 8 +++ ..._like_anthropic_messages_transformation.py | 18 ++++++ ...artner_models_anthropic_messages_config.py | 56 +++++++++++++++++++ 11 files changed, 192 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index cc5db407e3f..0bcf34a45d6 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -359,16 +359,40 @@ class AnthropicModelInfo(BaseLLMModelInfo): value = litellm.model_cost.get(model, {}).get(key) return value if isinstance(value, bool) else None + @staticmethod + def _get_provider_resolved_capability(model: str, key: str, custom_llm_provider: str) -> Optional[bool]: + """Resolve boolean capability ``key`` for ``model`` under the caller's provider. + + Returns the flag when the provider-aware lookup resolves ``model`` to an + entry (or fallback rule) that sets it explicitly, and ``None`` when the + model does not resolve under that provider or the resolved entry has no + opinion on ``key``. + """ + from litellm.utils import _get_model_info_helper + + try: + resolved_model, resolved_provider, _, _ = litellm.get_llm_provider( + model=model, custom_llm_provider=custom_llm_provider + ) + value = _get_model_info_helper(model=resolved_model, custom_llm_provider=resolved_provider).get(key) + except Exception: # noqa: BLE001 # _get_model_info_helper raises bare Exception for unmapped models + return None + return value if isinstance(value, bool) else None + @staticmethod def _supports_model_capability(model: str, key: str, custom_llm_provider: str) -> bool: """Check a boolean capability ``key`` in the model map under the caller's provider. - The provider-aware lookup makes exact provider-namespaced entries (e.g. the - Bedrock ``global.anthropic.*`` ids) authoritative; the raw model-map walk - remains as a provider-less backstop for alias forms the lookup misses. + The provider-aware lookup is authoritative when it resolves an explicit flag, + so ``key: false`` on the provider-namespaced entry wins over every fallback. + Otherwise ``_supports_factory``'s provider-level fallbacks and the raw + model-map walk remain as backstops for alias forms the lookup misses. """ from litellm.utils import _supports_factory + resolved = AnthropicModelInfo._get_provider_resolved_capability(model, key, custom_llm_provider) + if resolved is not None: + return resolved try: if _supports_factory( model=model, diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index 1de18701a2f..8cee35989af 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -21,6 +21,10 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig): and Azure endpoint format. """ + @property + def custom_llm_provider(self) -> Optional[str]: + return "azure_ai" + def should_strip_billing_metadata(self) -> bool: return True diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 9b53dee3453..9c05899c719 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -181,6 +181,10 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): if key != "self" and value is not None: setattr(self.__class__, key, value) + @property + def custom_llm_provider(self) -> Optional[str]: + return "databricks" + @classmethod def get_config(cls): return super().get_config() diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index fb3f0a4e159..4d7b003c48f 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -25,6 +25,10 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): super().__init__() self.authenticator = Authenticator() + @property + def custom_llm_provider(self) -> Optional[str]: + return "github_copilot" + def handles_web_search_natively(self) -> bool: """ Copilot's /v1/messages endpoint does not execute ``web_search`` tools, so diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index 4963bcca9ac..0d593d8d0f4 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -85,6 +85,10 @@ class JSONProviderAnthropicMessagesConfig(OpenAILikeAnthropicMessagesConfig): super().__init__() self._provider = provider + @property + def custom_llm_provider(self) -> Optional[str]: + return self._provider.slug + def should_strip_billing_metadata(self) -> bool: return True diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py index 8566496bf9c..de72795cabc 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py @@ -17,6 +17,10 @@ from ..output_params_utils import sanitize_vertex_anthropic_output_params class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, VertexBase): + @property + def custom_llm_provider(self) -> Optional[str]: + return "vertex_ai" + def should_strip_billing_metadata(self) -> bool: return True diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py index 5983597196a..5e9af6bd34d 100644 --- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py @@ -331,3 +331,59 @@ class TestProviderConfigManagerAzureAnthropicMessages: ) assert config is None + + +@pytest.fixture +def local_model_cost_map(monkeypatch): + """Force the bundled backup cost map so capability flags match this branch.""" + import litellm + + original = litellm.model_cost + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + litellm.get_model_info.cache_clear() + try: + yield + finally: + litellm.model_cost = original + litellm.get_model_info.cache_clear() + + +def test_messages_thinking_shape_follows_exact_azure_entry_flag(local_model_cost_map, monkeypatch): + """The Azure messages config must probe capabilities under ``azure_ai`` so an + operator setting ``supports_adaptive_thinking: false`` on the exact + ``azure_ai/claude-opus-4-8`` entry beats the unmodified ``anthropic`` entry. + With the inherited ``"anthropic"`` provider default the flip was ignored and + the transform kept emitting ``thinking.type='adaptive'``.""" + import litellm + + config = AzureAnthropicMessagesConfig() + + def transform(): + return config.transform_anthropic_messages_request( + model="claude-opus-4-8", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params={ + "max_tokens": 4096, + "reasoning_effort": "medium", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + result = transform() + assert result.get("thinking") == {"type": "adaptive"} + assert result.get("output_config") == {"effort": "medium"} + + monkeypatch.setitem( + litellm.model_cost["azure_ai/claude-opus-4-8"], "supports_adaptive_thinking", False + ) + litellm.get_model_info.cache_clear() + assert litellm.model_cost["claude-opus-4-8"]["supports_adaptive_thinking"] is True + + flipped = transform() + thinking = flipped.get("thinking") + assert isinstance(thinking, dict) + assert thinking.get("type") == "enabled" + assert isinstance(thinking.get("budget_tokens"), int) + assert "output_config" not in flipped diff --git a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py index cfdb76a97f4..00f3e7a6faf 100644 --- a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py +++ b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py @@ -416,3 +416,10 @@ def test_transform_request_keeps_parallel_tool_calls_for_claude(): )["messages"] assert len([m for m in result if m.get("role") == "assistant"]) == 1 + + +def test_databricks_config_probes_capabilities_under_databricks_namespace(): + """Inherited AnthropicConfig capability probes read ``self.custom_llm_provider``; + without this override they probed the ``anthropic`` cost-map namespace and + ignored the exact ``databricks/databricks-claude-*`` entries.""" + assert DatabricksConfig().custom_llm_provider == "databricks" diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 01787c07d27..8ed84b3ed8d 100644 --- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -326,3 +326,11 @@ def test_github_copilot_config_does_not_handle_web_search_natively(): assert GithubCopilotAnthropicMessagesConfig().handles_web_search_natively() is False assert AnthropicMessagesConfig().handles_web_search_natively() is True + + +def test_github_copilot_messages_config_probes_capabilities_under_copilot_namespace(): + """Capability probes in the shared pass-through helpers read + ``self.custom_llm_provider``; without this override they probed the + ``anthropic`` namespace and ignored the exact ``github_copilot/claude-*`` + cost-map entries.""" + assert GithubCopilotAnthropicMessagesConfig().custom_llm_provider == "github_copilot" diff --git a/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py b/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py index 534d7aefda4..33e677b000e 100644 --- a/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py @@ -299,3 +299,21 @@ def test_anthropic_beta_survives_provider_filter_on_passthrough_path(config): stripped = update_headers_with_filtered_beta(headers=dict(headers), provider="openai") assert "anthropic-beta" not in stripped + + +def test_json_provider_messages_config_probes_capabilities_under_provider_slug(): + """Capability probes in the shared pass-through helpers read + ``self.custom_llm_provider``. The JSON-provider config knows its slug, so it + must expose it; the generic OpenAI-like config has no class-level namespace + and keeps the inherited ``anthropic`` default.""" + from litellm.llms.openai_like.json_loader import SimpleProviderConfig + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + provider = SimpleProviderConfig( + slug="exampleprovider", + data={"base_url": "https://api.example.com/v1", "api_key_env": "EXAMPLE_API_KEY"}, + ) + assert JSONProviderAnthropicMessagesConfig(provider).custom_llm_provider == "exampleprovider" + assert OpenAILikeAnthropicMessagesConfig().custom_llm_provider == "anthropic" diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py index bfd37f73b2d..ce770221ceb 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py @@ -509,3 +509,59 @@ def test_vertex_claude_completion_does_not_mutate_shared_extra_headers(): assert ( shared_extra_headers == {} ), "extra_headers must not be mutated by completion()" + + +@pytest.fixture +def local_model_cost_map(monkeypatch): + """Force the bundled backup cost map so capability flags match this branch.""" + import litellm + + original = litellm.model_cost + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + litellm.get_model_info.cache_clear() + try: + yield + finally: + litellm.model_cost = original + litellm.get_model_info.cache_clear() + + +def test_messages_thinking_shape_follows_exact_vertex_entry_flag(local_model_cost_map, monkeypatch): + """The Vertex messages config must probe capabilities under ``vertex_ai`` so an + operator setting ``supports_adaptive_thinking: false`` on the exact + ``vertex_ai/claude-opus-4-8`` entry beats the unmodified ``anthropic`` entry. + With the inherited ``"anthropic"`` provider default the flip was ignored and + the transform kept emitting ``thinking.type='adaptive'``.""" + import litellm + + config = VertexAIPartnerModelsAnthropicMessagesConfig() + + def transform(): + return config.transform_anthropic_messages_request( + model="claude-opus-4-8", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params={ + "max_tokens": 4096, + "reasoning_effort": "medium", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + result = transform() + assert result.get("thinking") == {"type": "adaptive"} + assert result.get("output_config") == {"effort": "medium"} + + monkeypatch.setitem( + litellm.model_cost["vertex_ai/claude-opus-4-8"], "supports_adaptive_thinking", False + ) + litellm.get_model_info.cache_clear() + assert litellm.model_cost["claude-opus-4-8"]["supports_adaptive_thinking"] is True + + flipped = transform() + thinking = flipped.get("thinking") + assert isinstance(thinking, dict) + assert thinking.get("type") == "enabled" + assert isinstance(thinking.get("budget_tokens"), int) + assert "output_config" not in flipped