From 7a7abe0d7f259baab3d1a4fc414612a7e73ea0ff Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 27 Oct 2026 19:59:09 +0000 Subject: [PATCH 1/6] feat(anthropic): pick beta allowlist row per deployment, add mid-2026 beta rows, surface input_transformations Foundry-hosted anthropic deployments now filter anthropic-beta by the azure_ai row on /v1/messages and chat completions, the bundled config gains the mid-conversation-system-clear-at-2026-08-21 row plus the tool-changes and output-config betas on azure_ai and vertex_ai from a live probe, and Anthropic's input_transformations array reaches chat completions in provider_specific_fields (non-streaming and the first streaming chunk). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/anthropic_beta_headers_config.json | 11 ++ litellm/anthropic_beta_headers_manager.py | 35 ++++- litellm/llms/anthropic/chat/handler.py | 4 + litellm/llms/anthropic/chat/transformation.py | 4 + litellm/llms/custom_httpx/llm_http_handler.py | 23 ++- litellm/types/llms/anthropic.py | 1 + .../anthropic_messages/anthropic_response.py | 3 +- .../chat/test_anthropic_chat_handler.py | 46 +++++- .../test_anthropic_chat_transformation.py | 51 +++++++ .../test_anthropic_messages_transformation.py | 30 ++++ .../custom_httpx/test_llm_http_handler.py | 138 +++++++++++++----- ..._anthropic_beta_headers_deployment_rows.py | 108 ++++++++++++++ 12 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 tests/unit/test_anthropic_beta_headers_deployment_rows.py diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index d68de6c9d4d..0fbc2b67e4b 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -31,6 +31,7 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21", "token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19", "web-fetch-2025-09-10": "web-fetch-2025-09-10", "web-search-2025-03-05": "web-search-2025-03-05", @@ -68,6 +69,9 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "mid-conversation-output-config-2026-07-01": "mid-conversation-output-config-2026-07-01", + "mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01", + "mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21", "thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18", "token-efficient-tools-2025-02-19": null, "web-fetch-2025-09-10": "web-fetch-2025-09-10", @@ -102,6 +106,7 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21": null, "thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18", "token-efficient-tools-2025-02-19": null, "tool-search-tool-2025-10-19": null, @@ -137,6 +142,7 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21": null, "token-efficient-tools-2025-02-19": null, "tool-search-tool-2025-10-19": "tool-search-tool-2025-10-19", "web-fetch-2025-09-10": null, @@ -175,6 +181,7 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21": null, "thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18", "token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19", "tool-examples-2025-10-29": "tool-examples-2025-10-29", @@ -211,6 +218,9 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "mid-conversation-output-config-2026-07-01": "mid-conversation-output-config-2026-07-01", + "mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01", + "mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21", "thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18", "token-efficient-tools-2025-02-19": null, "tool-search-tool-2025-10-19": "tool-search-tool-2025-10-19", @@ -248,6 +258,7 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21": null, "token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19", "web-fetch-2025-09-10": null, "web-search-2025-03-05": "web-search-2025-03-05" diff --git a/litellm/anthropic_beta_headers_manager.py b/litellm/anthropic_beta_headers_manager.py index fec1d8e4465..3c9f7c2bc70 100644 --- a/litellm/anthropic_beta_headers_manager.py +++ b/litellm/anthropic_beta_headers_manager.py @@ -31,6 +31,7 @@ from concurrent.futures import ThreadPoolExecutor from functools import cache from importlib.resources import files from typing import Final, TypeAlias +from urllib.parse import urlparse import httpx from pydantic import TypeAdapter @@ -355,9 +356,33 @@ def get_provider_beta_header( return provider_mapping[anthropic_beta_header] +FOUNDRY_API_BASE_HOST_SUFFIXES: Final[tuple[str, ...]] = ("services.ai.azure.com", "openai.azure.com") + + +def is_foundry_api_base(api_base: str | None) -> bool: + """Whether ``api_base`` points at an Azure AI Foundry host, where Claude is served by Microsoft.""" + if not api_base: + return False + host: Final = urlparse(api_base if "://" in api_base else f"https://{api_base}").hostname or "" + return any(host == suffix or host.endswith(f".{suffix}") for suffix in FOUNDRY_API_BASE_HOST_SUFFIXES) + + +def resolve_beta_provider(provider: str, api_base: str | None = None) -> str: + """ + Pick the beta allowlist row for one deployment, not one provider string. + + An ``anthropic`` deployment whose ``api_base`` is a Foundry host is served by Microsoft and + gets the ``azure_ai`` row, since Foundry accepts a different set of betas than api.anthropic.com. + """ + if get_provider_name(provider) == "anthropic" and is_foundry_api_base(api_base): + return "azure_ai" + return provider + + def update_headers_with_filtered_beta( headers: dict, provider: str, + api_base: str | None = None, ) -> dict: """ Update headers dict by filtering and transforming anthropic-beta header values. @@ -366,6 +391,7 @@ def update_headers_with_filtered_beta( Args: headers: Request headers dict (will be modified in place) provider: Provider name + api_base: Deployment api_base, used to pick the allowlist row (Foundry hosts use ``azure_ai``) Returns: Updated headers dict @@ -380,7 +406,7 @@ def update_headers_with_filtered_beta( # Filter and transform based on provider filtered_beta_values: Final = filter_and_transform_beta_headers( beta_headers=beta_values, - provider=provider, + provider=resolve_beta_provider(provider, api_base), ) # Update or remove the header @@ -397,6 +423,7 @@ def update_request_with_filtered_beta( headers: dict, request_data: dict, provider: str, + api_base: str | None = None, ) -> tuple[dict, dict]: """ Update both headers and request body beta fields based on provider support. @@ -406,11 +433,13 @@ def update_request_with_filtered_beta( headers: Request headers dict (will be modified in place) request_data: Request body dict (will be modified in place) provider: Provider name + api_base: Deployment api_base, used to pick the allowlist row (Foundry hosts use ``azure_ai``) Returns: Tuple of (updated headers, updated request_data) """ - headers = update_headers_with_filtered_beta(headers=headers, provider=provider) + row_provider: Final = resolve_beta_provider(provider, api_base) + headers = update_headers_with_filtered_beta(headers=headers, provider=row_provider) existing_body_betas: Final = request_data.get("anthropic_beta") if not existing_body_betas: @@ -418,7 +447,7 @@ def update_request_with_filtered_beta( filtered_body_betas: Final = filter_and_transform_beta_headers( beta_headers=existing_body_betas, - provider=provider, + provider=row_provider, ) if filtered_body_betas: diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 13ceea3e31b..d9931f80fb1 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -509,6 +509,7 @@ class AnthropicChatCompletion(BaseLLM): headers=headers, request_data=request_data, provider=custom_llm_provider, + api_base=api_base, ) logging_obj.pre_call( input=messages, @@ -1165,6 +1166,9 @@ class ModelResponseIterator: start_message: Final = message_start_block["message"] if "model" in start_message: self.served_model = start_message["model"] + input_transformations: Final = start_message.get("input_transformations") + if input_transformations is not None: + provider_specific_fields["input_transformations"] = input_transformations if "usage" in message_start_block["message"]: usage = self._handle_usage(anthropic_usage_chunk=message_start_block["message"]["usage"]) elif type_chunk == "error": diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 92e7c84031c..1410758a4ad 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2937,6 +2937,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if context_management is not None: provider_specific_fields["context_management"] = context_management + input_transformations: Final = completion_response.get("input_transformations") + if input_transformations is not None: + provider_specific_fields["input_transformations"] = input_transformations + if web_search_results is not None: provider_specific_fields["web_search_results"] = web_search_results provider_specific_fields["web_search_calls"] = self._build_web_search_calls( diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index ef009e70137..6b7a50b1bd5 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -36,7 +36,11 @@ import litellm.litellm_core_utils import litellm.types import litellm.types.utils from litellm._logging import redact_string, verbose_logger -from litellm.anthropic_beta_headers_manager import aload_beta_headers_config, update_headers_with_filtered_beta +from litellm.anthropic_beta_headers_manager import ( + aload_beta_headers_config, + is_foundry_api_base, + update_headers_with_filtered_beta, +) from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES from litellm.files.types import FileContentStreamingResult from litellm.litellm_core_utils.agentic_followup_kwargs import build_agentic_followup_kwargs @@ -2494,14 +2498,15 @@ class BaseLLMHTTPHandler: litellm_params.get("additional_drop_params") or (), anthropic_messages_optional_request_params, ) - if ( - request_params.get("safeguards") is not None - and anthropic_messages_provider_config.should_filter_anthropic_beta_headers() - ): - await aload_beta_headers_config() affinity_api_base: Final = ( AnthropicModelInfo.get_api_base(api_base) if custom_llm_provider == "anthropic" else api_base ) + filters_beta_headers: Final = ( + anthropic_messages_provider_config.should_filter_anthropic_beta_headers() + or is_foundry_api_base(affinity_api_base) + ) + if request_params.get("safeguards") is not None and filters_beta_headers: + await aload_beta_headers_config() default_affinity_header: Final = get_provider_affinity_header(affinity_api_base) ( headers, @@ -2520,8 +2525,10 @@ class BaseLLMHTTPHandler: api_base=api_base, ) - if anthropic_messages_provider_config.should_filter_anthropic_beta_headers(): - headers = update_headers_with_filtered_beta(headers=headers, provider=custom_llm_provider) + if filters_beta_headers: + headers = update_headers_with_filtered_beta( + headers=headers, provider=custom_llm_provider, api_base=affinity_api_base + ) from litellm.llms.vertex_ai.vertex_llm_base import VertexBase diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index ba422a0a901..33174ff22c1 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -651,6 +651,7 @@ class MessageChunk(TypedDict, total=False): stop_sequence: str | None usage: UsageDelta safeguard_results: ReadOnly[Sequence[SafeguardResult]] + input_transformations: ReadOnly[Sequence[Mapping[str, object]]] class MessageStartBlock(TypedDict): diff --git a/litellm/types/llms/anthropic_messages/anthropic_response.py b/litellm/types/llms/anthropic_messages/anthropic_response.py index 9ad13243451..56c012436f4 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -1,4 +1,4 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import Literal, TypeAlias from typing_extensions import NotRequired, ReadOnly, TypedDict @@ -106,3 +106,4 @@ class AnthropicMessagesResponse(TypedDict, total=False): usage: AnthropicUsage | None context_management: NotRequired[ContextManagementResponse] safeguard_results: NotRequired[ReadOnly[Sequence[SafeguardResult]]] + input_transformations: NotRequired[ReadOnly[Sequence[Mapping[str, object]]]] diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py index 1f4a5aaa256..efea2ad7dd0 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -494,7 +494,11 @@ def test_streamed_thinking_blocks_accumulate_in_the_openai_sdk_and_replay_withou {"type": "content_block_delta", "index": 2, "delta": {"type": "thinking_delta", "thinking": "Step 3."}}, {"type": "content_block_delta", "index": 2, "delta": {"type": "signature_delta", "signature": "sig-b"}}, {"type": "content_block_stop", "index": 2}, - {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 9}}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 9}, + }, {"type": "message_stop"}, ) @@ -515,7 +519,11 @@ def test_streamed_thinking_blocks_accumulate_in_the_openai_sdk_and_replay_withou "thinking_blocks": message["thinking_blocks"], } replayed: Final = anthropic_messages_pt( - messages=[{"role": "user", "content": "Capital of France?"}, next_turn, {"role": "user", "content": "And Spain?"}], + messages=[ + {"role": "user", "content": "Capital of France?"}, + next_turn, + {"role": "user", "content": "And Spain?"}, + ], model="claude-haiku-4-5", llm_provider="anthropic", ) @@ -3607,3 +3615,37 @@ def test_streamed_end_turn_carries_no_stop_details(): provider_fields: Final = assembled.choices[0].message.provider_specific_fields or {} assert "stop_details" not in provider_fields assert "stop_reason" not in provider_fields + + +def test_message_start_surfaces_input_transformations_on_the_first_chunk(): + input_transformations: Final = [{"type": "thinking_block_binding", "count": 2}] + chunk: Final = { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 1}, + "input_transformations": input_transformations, + }, + } + iterator: Final = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False) + + first_chunk: Final = iterator.chunk_parser(chunk=chunk) + + assert first_chunk.choices[0].delta.provider_specific_fields == {"input_transformations": input_transformations} + assert first_chunk.model == "claude-sonnet-4-6" + + +def test_message_start_without_input_transformations_has_no_provider_specific_fields(): + chunk: Final = { + "type": "message_start", + "message": {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "content": []}, + } + iterator: Final = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False) + + assert iterator.chunk_parser(chunk=chunk).choices[0].delta.provider_specific_fields is None diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index ef002a73e52..a95b2a94bbf 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -9804,3 +9804,54 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key(): assert "cache_control_injection_points" not in data assert "tools" not in data assert litellm_params["metadata"] == {} + + +_INPUT_TRANSFORMATIONS: Final = [{"type": "thinking_block_binding", "count": 2}] + + +def _input_transformations_response() -> dict: + return { + "id": "msg_input_transformations", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 3}, + "input_transformations": _INPUT_TRANSFORMATIONS, + } + + +def test_chat_response_surfaces_input_transformations_in_provider_specific_fields(): + result = AnthropicConfig().transform_response( + model="claude-sonnet-4-6", + raw_response=httpx.Response(200, json=_input_transformations_response()), + model_response=ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].message.provider_specific_fields["input_transformations"] == _INPUT_TRANSFORMATIONS + + +def test_chat_response_without_input_transformations_adds_no_key(): + completion_response = _input_transformations_response() + del completion_response["input_transformations"] + result = AnthropicConfig().transform_response( + model="claude-sonnet-4-6", + raw_response=httpx.Response(200, json=completion_response), + model_response=ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert "input_transformations" not in result.choices[0].message.provider_specific_fields diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py index aa6625561ba..23422e45000 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py @@ -416,3 +416,33 @@ def test_drop_params_forwards_a_non_object_message_item_as_sent_on_the_native_re assert request["messages"][1] == 7 assert request["messages"][2]["content"][0]["content"] == [{"type": "text", "text": "sunny"}] + + +def test_native_response_keeps_input_transformations() -> None: + input_transformations: Final[list[JsonValue]] = [{"type": "thinking_block_binding", "count": 2}] + upstream: Final[dict[str, JsonValue]] = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 19, "output_tokens": 42}, + "input_transformations": input_transformations, + } + logging_obj: Final = Logging( + model="claude-sonnet-4-6", + messages=[], + stream=False, + call_type="anthropic_messages", + start_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + litellm_call_id="test-call", + function_id="test-function", + ) + + response: Final = AnthropicMessagesConfig().transform_anthropic_messages_response( + model="claude-sonnet-4-6", raw_response=httpx.Response(200, json=upstream), logging_obj=logging_obj + ) + + assert response["input_transformations"] == input_transformations diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index 297e64629a8..cb3f1edc929 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -413,9 +413,7 @@ def test_response_api_handler_sends_no_stream_options_when_web_search_forces_a_n ): sent: Final = _SentRequestRecorder() recorder: Final = _AgenticLoopKwargsRecorder() - monkeypatch.setattr( - litellm, "callbacks", [WebSearchInterceptionLogger(enabled_providers=["azure"]), recorder] - ) + monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(enabled_providers=["azure"]), recorder]) logging_obj: Final = Mock() logging_obj.dynamic_success_callbacks = [] @@ -2486,7 +2484,9 @@ async def test_async_anthropic_messages_handler_passes_deployment_api_base_to_ag super().__init__() self.hook_kwargs: dict | None = None - async def async_should_run_agentic_loop(self, response, model, messages, tools, stream, custom_llm_provider, kwargs): + async def async_should_run_agentic_loop( + self, response, model, messages, tools, stream, custom_llm_provider, kwargs + ): self.hook_kwargs = dict(kwargs) return False, {} @@ -2846,9 +2846,7 @@ def _transform_subtitle_response(payload): def test_subtitle_synthesis_fallback_without_timings_drops_words(): - response = _transform_subtitle_response( - {"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]} - ) + response = _transform_subtitle_response({"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]}) assert response.text == "hello world" assert "words" not in response @@ -3840,7 +3838,13 @@ def _capture_video_create_request(captured): captured["body"] = request.content return httpx.Response( 200, - json={"id": "video_123", "object": "video", "status": "queued", "created_at": 1712697600, "model": "sora-2"}, + json={ + "id": "video_123", + "object": "video", + "status": "queued", + "created_at": 1712697600, + "model": "sora-2", + }, ) return respond @@ -3863,7 +3867,9 @@ def test_video_generation_without_file_sends_multipart_form_data(): captured = {} client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured)))) - result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(OpenAIVideoConfig())) + result = BaseLLMHTTPHandler().video_generation_handler( + client=client, **_video_create_call_kwargs(OpenAIVideoConfig()) + ) assert captured["content_type"].startswith("multipart/form-data") assert _multipart_text_fields(captured["content_type"], captured["body"]) == { @@ -3903,7 +3909,9 @@ def test_azure_video_generation_without_file_sends_multipart_form_data(): captured = {} client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured)))) - result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(AzureVideoConfig())) + result = BaseLLMHTTPHandler().video_generation_handler( + client=client, **_video_create_call_kwargs(AzureVideoConfig()) + ) assert captured["content_type"].startswith("multipart/form-data") assert _multipart_text_fields(captured["content_type"], captured["body"]) == { @@ -3918,7 +3926,9 @@ def test_video_generation_json_provider_keeps_json_body(): captured = {} client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured)))) - result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig())) + result = BaseLLMHTTPHandler().video_generation_handler( + client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig()) + ) assert captured["content_type"] == "application/json" assert json.loads(captured["body"]) == {"model": "sora-2", "prompt": "a cat surfing", "seconds": "4"} @@ -3948,6 +3958,7 @@ def test_video_generation_with_input_reference_keeps_file_multipart(): AZURE_AI_BASE = "https://myfoundry.services.ai.azure.com" AZURE_AI_CHAT_COMPLETIONS_URL = f"{AZURE_AI_BASE}/models/chat/completions" + def _a_tool_with_an_unsupported_field() -> dict: return { "type": "function", @@ -3955,14 +3966,13 @@ def _a_tool_with_an_unsupported_field() -> dict: "strict": True, } + A_COMPLETION = { "id": "chatcmpl-1", "object": "chat.completion", "created": 1, "model": "grok-3", - "choices": [ - {"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"} - ], + "choices": [{"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, } @@ -4006,9 +4016,7 @@ def _call_azure_ai(recorder: _RecordedAzureAI, **overrides): def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried(): - recorder = _RecordedAzureAI( - [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]) response = _call_azure_ai(recorder) @@ -4019,9 +4027,7 @@ def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried(): def test_the_retry_changes_only_the_field_the_provider_named(): - recorder = _RecordedAzureAI( - [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]) _call_azure_ai(recorder) @@ -4060,9 +4066,7 @@ def test_an_extra_input_outside_a_tool_is_not_retried_unless_dropping_params_was def test_an_extra_input_outside_a_tool_is_retried_when_dropping_params_was_asked_for(): - recorder = _RecordedAzureAI( - [_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)]) response = _call_azure_ai(recorder, drop_params=True) @@ -4076,9 +4080,7 @@ async def test_a_tool_field_the_provider_rejects_is_dropped_and_retried_on_the_a ): import respx - recorder = _RecordedAzureAI( - [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]) with respx.mock(assert_all_called=True) as router: router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder) @@ -4219,7 +4221,11 @@ async def test_create_batch_says_which_setting_is_missing_when_the_provider_reso with pytest.raises(ValueError, match="api_base is required for create_batch"): await BaseLLMHTTPHandler().create_batch( - create_batch_data={"input_file_id": "file_1", "endpoint": "/v1/chat/completions", "completion_window": "24h"}, + create_batch_data={ + "input_file_id": "file_1", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, litellm_params={}, provider_config=provider_config, headers={}, @@ -4400,7 +4406,9 @@ def _start_async_completion(config, logging_obj=None): custom_llm_provider="openai", model_response=ModelResponse(), encoding=None, - logging_obj=logging_obj if logging_obj is not None else Mock(dynamic_success_callbacks=None, model_call_details={}), + logging_obj=logging_obj + if logging_obj is not None + else Mock(dynamic_success_callbacks=None, model_call_details={}), optional_params={}, timeout=10.0, litellm_params={}, @@ -4788,7 +4796,9 @@ async def test_async_realtime_bridges_a_transcription_session_through_the_provid logging_obj.dispatch_failure_handlers = AsyncMock() handler = BaseLLMHTTPHandler() - with patch.object(handler, "_open_realtime_backend_ws", AsyncMock(side_effect=AssertionError("dialed a websocket"))) as dial: + with patch.object( + handler, "_open_realtime_backend_ws", AsyncMock(side_effect=AssertionError("dialed a websocket")) + ) as dial: await handler.async_realtime( model="chirp_3", websocket=client_ws, @@ -5599,7 +5609,9 @@ async def test_messages_opencode_session_header( model="anthropic/claude-haiku-5-5", messages=[{"role": "user", "content": "hello"}], max_tokens=128000, - api_base=None if environment_base and api_base == "https://opencode.ai/inference/go/anthropic" else api_base, + api_base=None + if environment_base and api_base == "https://opencode.ai/inference/go/anthropic" + else api_base, api_key="test-key", stream=stream, litellm_session_id="conversation-123", @@ -5660,17 +5672,27 @@ def test_vector_store_retrieve_handler_sends_id_before_azure_api_version_query() @pytest.mark.parametrize("is_async", (False, True)) @pytest.mark.parametrize("provider", ("openai", "azure")) async def test_non_chatgpt_responses_preserves_caller_headers_after_validation( - is_async: bool, provider: str, respx_mock: respx.MockRouter, + is_async: bool, + provider: str, + respx_mock: respx.MockRouter, ) -> None: route: Final = respx_mock.post(url__regex=r"https://responses\.example\.com/.*").respond( 200, json={ - "id": "resp_headers", "object": "response", "created_at": 1, - "model": "gpt-6-luna", "status": "completed", "output": [], - "parallel_tool_calls": False, "tool_choice": "auto", "tools": [], + "id": "resp_headers", + "object": "response", + "created_at": 1, + "model": "gpt-6-luna", + "status": "completed", + "output": [], + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], }, ) - client: Final = AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport()) if is_async else HTTPHandler(client=httpx.Client()) + client: Final = ( + AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport()) if is_async else HTTPHandler(client=httpx.Client()) + ) kwargs: Final = { "model": f"{provider}/gpt-6-luna", "input": "hello", @@ -5695,3 +5717,49 @@ async def test_non_chatgpt_responses_preserves_caller_headers_after_validation( await client.close() else: client.client.close() + + +@pytest.mark.asyncio +async def test_async_anthropic_messages_handler_filters_betas_by_the_azure_ai_row_on_a_foundry_api_base(monkeypatch): + from litellm import anthropic_beta_headers_manager + from litellm.llms.anthropic.messages.native.transformation import AnthropicMessagesConfig + + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") + anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None + foundry_api_base = "https://my-resource.services.ai.azure.com/anthropic" + upstream_response = httpx.Response( + 200, + json={ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello!"}], + "model": "claude-sonnet-4-6", + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + request=httpx.Request("POST", f"{foundry_api_base}/v1/messages"), + ) + mock_client = AsyncMock(spec=AsyncHTTPHandler) + mock_client.post = AsyncMock(return_value=upstream_response) + mock_logging_obj = Mock() + mock_logging_obj.model_call_details = {} + mock_logging_obj.stream = False + mock_logging_obj.dynamic_success_callbacks = None + + await BaseLLMHTTPHandler().async_anthropic_messages_handler( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_provider_config=AnthropicMessagesConfig(), + anthropic_messages_optional_request_params={"max_tokens": 8}, + custom_llm_provider="anthropic", + litellm_params=GenericLiteLLMParams(api_base=foundry_api_base), + logging_obj=mock_logging_obj, + api_key="sk-test", + api_base=foundry_api_base, + client=mock_client, + kwargs={"extra_headers": {"anthropic-beta": "advisor-tool-2026-03-01,thinking-binding-controls-2026-08-01"}}, + ) + + sent_headers = mock_client.post.call_args.kwargs["headers"] + assert sent_headers["anthropic-beta"] == "thinking-binding-controls-2026-08-01" diff --git a/tests/unit/test_anthropic_beta_headers_deployment_rows.py b/tests/unit/test_anthropic_beta_headers_deployment_rows.py new file mode 100644 index 00000000000..777b01e4e53 --- /dev/null +++ b/tests/unit/test_anthropic_beta_headers_deployment_rows.py @@ -0,0 +1,108 @@ +"""The beta allowlist row is picked per deployment: an ``anthropic`` deployment on a Foundry host gets ``azure_ai``.""" + +import json +import os +from typing import Final + +import pytest + +import litellm +from litellm import anthropic_beta_headers_manager +from litellm.anthropic_beta_headers_manager import ( + is_foundry_api_base, + resolve_beta_provider, + update_headers_with_filtered_beta, + update_request_with_filtered_beta, +) + +FOUNDRY_API_BASE: Final = "https://my-resource.services.ai.azure.com/anthropic" +DIRECT_API_BASE: Final = "https://api.anthropic.com" +DIRECT_ONLY_BETA: Final = "advisor-tool-2026-03-01" +PRESERVED_THINKING_BETAS: Final = ( + "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21", + "mid-conversation-tool-changes-2026-07-01", + "mid-conversation-output-config-2026-07-01", + "thinking-display-updates-2026-08-18", +) + + +@pytest.fixture(autouse=True) +def _bundled_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") + anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None + + +def _bundled_rows() -> dict: + with open(os.path.join(os.path.dirname(litellm.__file__), "anthropic_beta_headers_config.json")) as f: + return json.load(f) + + +@pytest.mark.parametrize( + ("api_base", "expected"), + ( + (FOUNDRY_API_BASE, True), + ("https://my-resource.openai.azure.com", True), + ("my-resource.services.ai.azure.com/anthropic", True), + (DIRECT_API_BASE, False), + ("https://services.ai.azure.com.example.com", False), + ("", False), + (None, False), + ), +) +def test_is_foundry_api_base_matches_only_foundry_hosts(api_base: str | None, expected: bool) -> None: + assert is_foundry_api_base(api_base) is expected + + +@pytest.mark.parametrize( + ("provider", "api_base", "expected"), + ( + ("anthropic", FOUNDRY_API_BASE, "azure_ai"), + ("anthropic", DIRECT_API_BASE, "anthropic"), + ("anthropic", None, "anthropic"), + ("vertex_ai", FOUNDRY_API_BASE, "vertex_ai"), + ("bedrock_converse", None, "bedrock_converse"), + ), +) +def test_resolve_beta_provider_picks_the_row_per_deployment(provider: str, api_base: str | None, expected: str) -> None: + assert resolve_beta_provider(provider, api_base) == expected + + +def test_foundry_deployment_headers_follow_the_azure_ai_row() -> None: + headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} + result: Final = update_headers_with_filtered_beta(headers=headers, provider="anthropic", api_base=FOUNDRY_API_BASE) + assert result == {"anthropic-beta": "thinking-binding-controls-2026-08-01"} + + +def test_direct_deployment_headers_keep_every_anthropic_row_beta() -> None: + headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} + result: Final = update_headers_with_filtered_beta(headers=headers, provider="anthropic", api_base=DIRECT_API_BASE) + assert result == {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} + + +def test_foundry_deployment_request_filters_header_and_body_betas_together() -> None: + headers, data = update_request_with_filtered_beta( + headers={"anthropic-beta": DIRECT_ONLY_BETA}, + request_data={"anthropic_beta": [DIRECT_ONLY_BETA, "thinking-binding-controls-2026-08-01"], "max_tokens": 8}, + provider="anthropic", + api_base=FOUNDRY_API_BASE, + ) + assert headers == {} + assert data == {"anthropic_beta": ["thinking-binding-controls-2026-08-01"], "max_tokens": 8} + + +@pytest.mark.parametrize("provider", ("anthropic", "azure_ai", "vertex_ai")) +@pytest.mark.parametrize("beta", PRESERVED_THINKING_BETAS) +def test_live_verified_platforms_forward_every_preserved_thinking_beta(provider: str, beta: str) -> None: + assert _bundled_rows()[provider][beta] == beta + + +@pytest.mark.parametrize("provider", ("bedrock", "bedrock_converse", "bedrock_mantle", "databricks")) +def test_unverified_platforms_hold_back_the_system_clear_beta(provider: str) -> None: + assert _bundled_rows()[provider]["mid-conversation-system-clear-at-2026-08-21"] is None + assert ( + update_headers_with_filtered_beta( + headers={"anthropic-beta": "mid-conversation-system-clear-at-2026-08-21"}, provider=provider + ) + == {} + ) From f7e3a21fbc0120c84596ba7b6a686d7b775c29b7 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 27 Oct 2026 20:25:06 +0000 Subject: [PATCH 2/6] fix(types): type the provider_specific_fields source dict and beta row test helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/transformation.py | 12 +++++------- ...test_anthropic_beta_headers_deployment_rows.py | 15 +++++++++++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1410758a4ad..162b7190b00 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2920,7 +2920,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): def _build_provider_specific_fields( self, - completion_response: dict, + completion_response: dict[str, object], citations: Sequence[object] | None, thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None, web_search_results: Sequence[object] | None, @@ -2951,17 +2951,15 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if tool_results is not None: provider_specific_fields["tool_results"] = tool_results + container: Final = completion_response.get("container") + container_fields: Final[dict[str, object] | None] = container if isinstance(container, dict) else None if tool_results is not None and any(result.get("type") != "tool_search_tool_result" for result in tool_results): - container_id: Final = ( - completion_response.get("container", {}).get("id") - if isinstance(completion_response.get("container"), dict) - else None - ) + raw_container_id: Final = container_fields.get("id") if container_fields is not None else None + container_id: Final = raw_container_id if isinstance(raw_container_id, str) else None code_by_id: Final = self._build_code_by_id_map(tool_calls) code_interpreter_results = self._build_code_interpreter_results(tool_results, code_by_id, container_id) provider_specific_fields["code_interpreter_results"] = code_interpreter_results - container: Final = completion_response.get("container") if container is not None: provider_specific_fields["container"] = container diff --git a/tests/unit/test_anthropic_beta_headers_deployment_rows.py b/tests/unit/test_anthropic_beta_headers_deployment_rows.py index 777b01e4e53..dd123b686e1 100644 --- a/tests/unit/test_anthropic_beta_headers_deployment_rows.py +++ b/tests/unit/test_anthropic_beta_headers_deployment_rows.py @@ -33,9 +33,10 @@ def _bundled_config(monkeypatch: pytest.MonkeyPatch) -> None: anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None -def _bundled_rows() -> dict: +def _bundled_rows() -> dict[str, dict[str, str | None]]: with open(os.path.join(os.path.dirname(litellm.__file__), "anthropic_beta_headers_config.json")) as f: - return json.load(f) + rows: dict[str, dict[str, str | None]] = json.load(f) + return rows @pytest.mark.parametrize( @@ -70,17 +71,23 @@ def test_resolve_beta_provider_picks_the_row_per_deployment(provider: str, api_b def test_foundry_deployment_headers_follow_the_azure_ai_row() -> None: headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} - result: Final = update_headers_with_filtered_beta(headers=headers, provider="anthropic", api_base=FOUNDRY_API_BASE) + result: dict[str, str] = update_headers_with_filtered_beta( + headers=headers, provider="anthropic", api_base=FOUNDRY_API_BASE + ) assert result == {"anthropic-beta": "thinking-binding-controls-2026-08-01"} def test_direct_deployment_headers_keep_every_anthropic_row_beta() -> None: headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} - result: Final = update_headers_with_filtered_beta(headers=headers, provider="anthropic", api_base=DIRECT_API_BASE) + result: dict[str, str] = update_headers_with_filtered_beta( + headers=headers, provider="anthropic", api_base=DIRECT_API_BASE + ) assert result == {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} def test_foundry_deployment_request_filters_header_and_body_betas_together() -> None: + headers: dict[str, str] + data: dict[str, object] headers, data = update_request_with_filtered_beta( headers={"anthropic-beta": DIRECT_ONLY_BETA}, request_data={"anthropic_beta": [DIRECT_ONLY_BETA, "thinking-binding-controls-2026-08-01"], "max_tokens": 8}, From dcebb4303411e5d12016ad82ce94f312646f0fbe Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 27 Oct 2026 20:25:06 +0000 Subject: [PATCH 3/6] test(integration): input_transformations reaches chat completions and /v1/messages Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...st_anthropic_input_transformations_wire.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/integration/messages_endpoint/providers/anthropic/test_anthropic_input_transformations_wire.py diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_input_transformations_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_input_transformations_wire.py new file mode 100644 index 00000000000..6b9739def8c --- /dev/null +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_input_transformations_wire.py @@ -0,0 +1,91 @@ +"""Anthropic's ``input_transformations`` report reaches the caller on chat completions and ``/v1/messages``.""" + +import json +from typing import Final + +import pytest +from integration._support import claude_code as cc +from integration._support.client import Gateway +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue, TypeAdapter + +_BACKEND: Final = "claude-sonnet-4-6" +_API_KEY: Final = "scripted-anthropic-key" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_CHOICES: Final = TypeAdapter(list[dict[str, JsonValue]]) +_INPUT_TRANSFORMATIONS: Final[list[JsonValue]] = [{"type": "prefix_binding_mismatch", "index": 1}] +_CONTENT: Final[tuple[dict[str, JsonValue], ...]] = ({"type": "text", "text": "Ok"},) +_USAGE: Final[dict[str, JsonValue]] = {"input_tokens": 8, "output_tokens": 2} +_IDENTITY: Final = "msg_input_transformations" + + +def _reply_body() -> bytes: + body: Final = _JSON_OBJECT.validate_json(cc.message_reply(_IDENTITY, _BACKEND, _CONTENT, _USAGE)) + return json.dumps({**body, "input_transformations": _INPUT_TRANSFORMATIONS}).encode() + + +def _stream_reply() -> tuple[bytes, ...]: + start, *rest = cc.message_stream(_IDENTITY, _BACKEND, _CONTENT, _USAGE) + event_name, data = cc.sse_events(start.decode())[0] + message: Final = _JSON_OBJECT.validate_python(data["message"]) + started: Final = cc.sse_frame( + event_name, {**data, "message": {**message, "input_transformations": _INPUT_TRANSFORMATIONS}} + ) + return (started, *rest) + + +def _anthropic_upstream(request: Request) -> Reply: + assert request.method == "POST" and request.target == "/v1/messages", request.target + assert request.headers["x-api-key"] == _API_KEY, request.headers + body: Final = _JSON_OBJECT.validate_json(request.body) + if body.get("stream") is True: + return Reply(content_type="text/event-stream", chunks=_stream_reply()) + return Reply(body=_reply_body()) + + +def test_chat_completion_message_carries_input_transformations(gateway: Gateway) -> None: + with wire_server(_anthropic_upstream) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_BACKEND}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": "Say ok"}]}, + ) + assert response.status_code == 200, response.text + message: Final = response.json()["choices"][0]["message"] + assert message["provider_specific_fields"]["input_transformations"] == _INPUT_TRANSFORMATIONS, response.text + + +def test_first_chat_completion_chunk_carries_input_transformations(gateway: Gateway) -> None: + with wire_server(_anthropic_upstream) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_BACKEND}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "max_tokens": 16, "stream": True, "messages": [{"role": "user", "content": "Say ok"}]}, + ) + assert response.status_code == 200, response.text + chunks: Final = [ + _JSON_OBJECT.validate_json(line[len("data: ") :]) + for line in response.text.splitlines() + if line.startswith("data: {") + ] + first_delta: Final = _JSON_OBJECT.validate_python(_CHOICES.validate_python(chunks[0]["choices"])[0]["delta"]) + assert first_delta["provider_specific_fields"] == {"input_transformations": _INPUT_TRANSFORMATIONS}, chunks[0] + + +@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"]) +def test_messages_endpoint_keeps_input_transformations(gateway: Gateway, stream: bool) -> None: + with wire_server(_anthropic_upstream) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_BACKEND}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request( + "POST", + "/v1/messages", + {"model": model, "max_tokens": 16, "stream": stream, "messages": [{"role": "user", "content": "Say ok"}]}, + ) + assert response.status_code == 200, response.text + if stream: + started: Final = next(data for name, data in cc.sse_events(response.text) if name == "message_start") + assert _JSON_OBJECT.validate_python(started["message"])["input_transformations"] == _INPUT_TRANSFORMATIONS + else: + assert response.json()["input_transformations"] == _INPUT_TRANSFORMATIONS, response.text From a524926ebaf1dbed9d948538d1c671826291ebe7 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 27 Oct 2026 20:29:35 +0000 Subject: [PATCH 4/6] fix(types): read the Anthropic response as a Mapping when building provider_specific_fields Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/transformation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 162b7190b00..462e91ab781 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2920,7 +2920,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): def _build_provider_specific_fields( self, - completion_response: dict[str, object], + completion_response: Mapping[str, object], citations: Sequence[object] | None, thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None, web_search_results: Sequence[object] | None, @@ -2952,7 +2952,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): provider_specific_fields["tool_results"] = tool_results container: Final = completion_response.get("container") - container_fields: Final[dict[str, object] | None] = container if isinstance(container, dict) else None + container_fields: Final[Mapping[str, object] | None] = container if isinstance(container, dict) else None if tool_results is not None and any(result.get("type") != "tool_search_tool_result" for result in tool_results): raw_container_id: Final = container_fields.get("id") if container_fields is not None else None container_id: Final = raw_container_id if isinstance(raw_container_id, str) else None From 07c4d4d7f12ef5d56e255acad98b6392ec0f6476 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 27 Oct 2026 20:40:46 +0000 Subject: [PATCH 5/6] test(anthropic): fold deployment-row tests into the filtering file and mirror the new beta in the Rust table Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/providers/anthropic/beta.rs | 14 +- .../custom_httpx/test_llm_http_handler.py | 10 +- ..._anthropic_beta_headers_deployment_rows.py | 115 --------- .../test_anthropic_beta_headers_filtering.py | 227 ++++++++++-------- 4 files changed, 145 insertions(+), 221 deletions(-) delete mode 100644 tests/unit/test_anthropic_beta_headers_deployment_rows.py diff --git a/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs b/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs index 8429c98a88a..55d04be4974 100644 --- a/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs +++ b/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs @@ -98,6 +98,8 @@ pub enum AnthropicBeta { McpServers20251204, #[strum(serialize = "mid-conversation-output-config-2026-07-01")] MidConversationOutputConfig20260701, + #[strum(serialize = "mid-conversation-system-clear-at-2026-08-21")] + MidConversationSystemClearAt20260821, #[strum(serialize = "mid-conversation-tool-changes-2026-07-01")] MidConversationToolChanges20260701, #[strum(serialize = "output-128k-2025-02-19")] @@ -129,7 +131,7 @@ pub enum AnthropicBeta { } impl AnthropicBeta { - pub const KNOWN: [Self; 40] = [ + pub const KNOWN: [Self; 41] = [ Self::Oauth20250420, Self::WebFetch20250910, Self::WebSearch20250305, @@ -157,6 +159,7 @@ impl AnthropicBeta { Self::McpClient20251120, Self::McpServers20251204, Self::MidConversationOutputConfig20260701, + Self::MidConversationSystemClearAt20260821, Self::MidConversationToolChanges20260701, Self::Output128k20250219, Self::PromptCachingScope20260105, @@ -257,10 +260,14 @@ impl AnthropicBeta { } Self::MidConversationOutputConfig20260701 | Self::MidConversationToolChanges20260701 => &[ - BetaProvider::AzureAi, BetaProvider::BedrockConverse, BetaProvider::BedrockMantle, - BetaProvider::VertexAi, + BetaProvider::Databricks, + ], + Self::MidConversationSystemClearAt20260821 => &[ + BetaProvider::BedrockConverse, + BetaProvider::Bedrock, + BetaProvider::BedrockMantle, BetaProvider::Databricks, ], Self::Output128k20250219 | Self::TokenEfficientTools20250219 => &[ @@ -429,6 +436,7 @@ mod tests { AnthropicBeta::McpClient20251120, AnthropicBeta::McpServers20251204, AnthropicBeta::MidConversationOutputConfig20260701, + AnthropicBeta::MidConversationSystemClearAt20260821, AnthropicBeta::MidConversationToolChanges20260701, AnthropicBeta::Output128k20250219, AnthropicBeta::PromptCachingScope20260105, diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index cb3f1edc929..b03be3f9f98 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -5726,8 +5726,8 @@ async def test_async_anthropic_messages_handler_filters_betas_by_the_azure_ai_ro monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None - foundry_api_base = "https://my-resource.services.ai.azure.com/anthropic" - upstream_response = httpx.Response( + foundry_api_base: Final = "https://my-resource.services.ai.azure.com/anthropic" + upstream_response: Final = httpx.Response( 200, json={ "id": "msg_123", @@ -5740,9 +5740,9 @@ async def test_async_anthropic_messages_handler_filters_betas_by_the_azure_ai_ro }, request=httpx.Request("POST", f"{foundry_api_base}/v1/messages"), ) - mock_client = AsyncMock(spec=AsyncHTTPHandler) + mock_client: Final = AsyncMock(spec=AsyncHTTPHandler) mock_client.post = AsyncMock(return_value=upstream_response) - mock_logging_obj = Mock() + mock_logging_obj: Final = Mock() mock_logging_obj.model_call_details = {} mock_logging_obj.stream = False mock_logging_obj.dynamic_success_callbacks = None @@ -5761,5 +5761,5 @@ async def test_async_anthropic_messages_handler_filters_betas_by_the_azure_ai_ro kwargs={"extra_headers": {"anthropic-beta": "advisor-tool-2026-03-01,thinking-binding-controls-2026-08-01"}}, ) - sent_headers = mock_client.post.call_args.kwargs["headers"] + sent_headers: Final = mock_client.post.call_args.kwargs["headers"] assert sent_headers["anthropic-beta"] == "thinking-binding-controls-2026-08-01" diff --git a/tests/unit/test_anthropic_beta_headers_deployment_rows.py b/tests/unit/test_anthropic_beta_headers_deployment_rows.py deleted file mode 100644 index dd123b686e1..00000000000 --- a/tests/unit/test_anthropic_beta_headers_deployment_rows.py +++ /dev/null @@ -1,115 +0,0 @@ -"""The beta allowlist row is picked per deployment: an ``anthropic`` deployment on a Foundry host gets ``azure_ai``.""" - -import json -import os -from typing import Final - -import pytest - -import litellm -from litellm import anthropic_beta_headers_manager -from litellm.anthropic_beta_headers_manager import ( - is_foundry_api_base, - resolve_beta_provider, - update_headers_with_filtered_beta, - update_request_with_filtered_beta, -) - -FOUNDRY_API_BASE: Final = "https://my-resource.services.ai.azure.com/anthropic" -DIRECT_API_BASE: Final = "https://api.anthropic.com" -DIRECT_ONLY_BETA: Final = "advisor-tool-2026-03-01" -PRESERVED_THINKING_BETAS: Final = ( - "thinking-binding-controls-2026-08-01", - "mid-conversation-system-clear-at-2026-08-21", - "mid-conversation-tool-changes-2026-07-01", - "mid-conversation-output-config-2026-07-01", - "thinking-display-updates-2026-08-18", -) - - -@pytest.fixture(autouse=True) -def _bundled_config(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") - anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None - - -def _bundled_rows() -> dict[str, dict[str, str | None]]: - with open(os.path.join(os.path.dirname(litellm.__file__), "anthropic_beta_headers_config.json")) as f: - rows: dict[str, dict[str, str | None]] = json.load(f) - return rows - - -@pytest.mark.parametrize( - ("api_base", "expected"), - ( - (FOUNDRY_API_BASE, True), - ("https://my-resource.openai.azure.com", True), - ("my-resource.services.ai.azure.com/anthropic", True), - (DIRECT_API_BASE, False), - ("https://services.ai.azure.com.example.com", False), - ("", False), - (None, False), - ), -) -def test_is_foundry_api_base_matches_only_foundry_hosts(api_base: str | None, expected: bool) -> None: - assert is_foundry_api_base(api_base) is expected - - -@pytest.mark.parametrize( - ("provider", "api_base", "expected"), - ( - ("anthropic", FOUNDRY_API_BASE, "azure_ai"), - ("anthropic", DIRECT_API_BASE, "anthropic"), - ("anthropic", None, "anthropic"), - ("vertex_ai", FOUNDRY_API_BASE, "vertex_ai"), - ("bedrock_converse", None, "bedrock_converse"), - ), -) -def test_resolve_beta_provider_picks_the_row_per_deployment(provider: str, api_base: str | None, expected: str) -> None: - assert resolve_beta_provider(provider, api_base) == expected - - -def test_foundry_deployment_headers_follow_the_azure_ai_row() -> None: - headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} - result: dict[str, str] = update_headers_with_filtered_beta( - headers=headers, provider="anthropic", api_base=FOUNDRY_API_BASE - ) - assert result == {"anthropic-beta": "thinking-binding-controls-2026-08-01"} - - -def test_direct_deployment_headers_keep_every_anthropic_row_beta() -> None: - headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} - result: dict[str, str] = update_headers_with_filtered_beta( - headers=headers, provider="anthropic", api_base=DIRECT_API_BASE - ) - assert result == {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} - - -def test_foundry_deployment_request_filters_header_and_body_betas_together() -> None: - headers: dict[str, str] - data: dict[str, object] - headers, data = update_request_with_filtered_beta( - headers={"anthropic-beta": DIRECT_ONLY_BETA}, - request_data={"anthropic_beta": [DIRECT_ONLY_BETA, "thinking-binding-controls-2026-08-01"], "max_tokens": 8}, - provider="anthropic", - api_base=FOUNDRY_API_BASE, - ) - assert headers == {} - assert data == {"anthropic_beta": ["thinking-binding-controls-2026-08-01"], "max_tokens": 8} - - -@pytest.mark.parametrize("provider", ("anthropic", "azure_ai", "vertex_ai")) -@pytest.mark.parametrize("beta", PRESERVED_THINKING_BETAS) -def test_live_verified_platforms_forward_every_preserved_thinking_beta(provider: str, beta: str) -> None: - assert _bundled_rows()[provider][beta] == beta - - -@pytest.mark.parametrize("provider", ("bedrock", "bedrock_converse", "bedrock_mantle", "databricks")) -def test_unverified_platforms_hold_back_the_system_clear_beta(provider: str) -> None: - assert _bundled_rows()[provider]["mid-conversation-system-clear-at-2026-08-21"] is None - assert ( - update_headers_with_filtered_beta( - headers={"anthropic-beta": "mid-conversation-system-clear-at-2026-08-21"}, provider=provider - ) - == {} - ) diff --git a/tests/unit/test_anthropic_beta_headers_filtering.py b/tests/unit/test_anthropic_beta_headers_filtering.py index deba7fe9587..ec15f0a8dd5 100644 --- a/tests/unit/test_anthropic_beta_headers_filtering.py +++ b/tests/unit/test_anthropic_beta_headers_filtering.py @@ -16,7 +16,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import litellm +from litellm import anthropic_beta_headers_manager from litellm.anthropic_beta_headers_manager import ( + resolve_beta_provider, + is_foundry_api_base, GetAnthropicBetaHeadersConfig, filter_and_transform_beta_headers, get_beta_headers_config, @@ -53,9 +56,7 @@ class TestAnthropicBetaHeadersFiltering: def get_supported_headers(self, provider: str) -> List[str]: """Get headers with non-null values for a provider.""" provider_config = self.config.get(provider, {}) - return [ - header for header, value in provider_config.items() if value is not None - ] + return [header for header, value in provider_config.items() if value is not None] def get_unsupported_headers(self, provider: str) -> List[str]: """Get headers with null values for a provider.""" @@ -65,11 +66,7 @@ class TestAnthropicBetaHeadersFiltering: def get_mapped_headers(self, provider: str) -> Dict[str, str]: """Get mapping of input headers to provider-specific headers.""" provider_config = self.config.get(provider, {}) - return { - header: value - for header, value in provider_config.items() - if value is not None - } + return {header: value for header, value in provider_config.items() if value is not None} @pytest.mark.parametrize( "provider", @@ -82,24 +79,20 @@ class TestAnthropicBetaHeadersFiltering: unsupported_headers = self.get_unsupported_headers(provider) mapped_headers = self.get_mapped_headers(provider) - filtered = filter_and_transform_beta_headers( - beta_headers=all_headers, provider=provider - ) + filtered = filter_and_transform_beta_headers(beta_headers=all_headers, provider=provider) assert ("compact-2026-09-04" in filtered) is (provider == "anthropic") for header in unsupported_headers: - assert ( - header not in filtered - ), f"Unsupported header '{header}' should be filtered out for {provider}" - assert ( - mapped_headers.get(header) not in filtered - ), f"Mapped value of unsupported header '{header}' should not appear for {provider}" + assert header not in filtered, f"Unsupported header '{header}' should be filtered out for {provider}" + assert mapped_headers.get(header) not in filtered, ( + f"Mapped value of unsupported header '{header}' should not appear for {provider}" + ) for header in supported_headers: expected_mapped = mapped_headers[header] - assert ( - expected_mapped in filtered - ), f"Supported header '{header}' should be mapped to '{expected_mapped}' for {provider}" + assert expected_mapped in filtered, ( + f"Supported header '{header}' should be mapped to '{expected_mapped}' for {provider}" + ) @pytest.mark.parametrize( "provider", @@ -114,20 +107,14 @@ class TestAnthropicBetaHeadersFiltering: ] all_headers = self.get_all_beta_headers() + unknown_headers - filtered = filter_and_transform_beta_headers( - beta_headers=all_headers, provider=provider - ) + filtered = filter_and_transform_beta_headers(beta_headers=all_headers, provider=provider) for unknown in unknown_headers: - assert ( - unknown not in filtered - ), f"Unknown header '{unknown}' should be filtered out for {provider}" + assert unknown not in filtered, f"Unknown header '{unknown}' should be filtered out for {provider}" def test_update_request_with_filtered_beta_vertex_ai(self): """Test combined filtering for both HTTP headers and request body betas.""" - headers = { - "anthropic-beta": "files-api-2025-04-14,context-management-2025-06-27,code-execution-2025-05-22" - } + headers = {"anthropic-beta": "files-api-2025-04-14,context-management-2025-06-27,code-execution-2025-05-22"} request_data = { "anthropic_beta": [ "files-api-2025-04-14", @@ -143,9 +130,7 @@ class TestAnthropicBetaHeadersFiltering: ) assert filtered_headers.get("anthropic-beta") == "context-management-2025-06-27" - assert filtered_request_data.get("anthropic_beta") == [ - "context-management-2025-06-27" - ] + assert filtered_request_data.get("anthropic_beta") == ["context-management-2025-06-27"] @pytest.mark.asyncio async def test_anthropic_messages_http_headers_filtering(self): @@ -153,9 +138,7 @@ class TestAnthropicBetaHeadersFiltering: all_headers = self.get_all_beta_headers() unsupported = self.get_unsupported_headers("anthropic") - with patch( - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" - ) as mock_client_factory: + with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_client_factory: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { @@ -191,9 +174,9 @@ class TestAnthropicBetaHeadersFiltering: if beta_header: beta_values = [b.strip() for b in beta_header.split(",")] for unsupported_header in unsupported: - assert ( - unsupported_header not in beta_values - ), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Anthropic" + assert unsupported_header not in beta_values, ( + f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Anthropic" + ) @pytest.mark.asyncio async def test_azure_ai_messages_http_headers_filtering(self): @@ -201,9 +184,7 @@ class TestAnthropicBetaHeadersFiltering: all_headers = self.get_all_beta_headers() unsupported = self.get_unsupported_headers("azure_ai") - with patch( - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" - ) as mock_client_factory: + with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_client_factory: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { @@ -241,9 +222,9 @@ class TestAnthropicBetaHeadersFiltering: if beta_header: beta_values = [b.strip() for b in beta_header.split(",")] for unsupported_header in unsupported: - assert ( - unsupported_header not in beta_values - ), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Azure AI" + assert unsupported_header not in beta_values, ( + f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Azure AI" + ) @pytest.mark.asyncio async def test_bedrock_converse_headers_and_body_filtering(self): @@ -256,9 +237,7 @@ class TestAnthropicBetaHeadersFiltering: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "output": { - "message": {"role": "assistant", "content": [{"text": "Hello"}]} - }, + "output": {"message": {"role": "assistant", "content": [{"text": "Hello"}]}}, "stopReason": "end_turn", "usage": {"inputTokens": 10, "outputTokens": 20}, } @@ -290,27 +269,25 @@ class TestAnthropicBetaHeadersFiltering: if beta_header: beta_values = [b.strip() for b in beta_header.split(",")] for unsupported_header in unsupported: - assert ( - unsupported_header not in beta_values - ), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Bedrock Converse" + assert unsupported_header not in beta_values, ( + f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Bedrock Converse" + ) data = call_kwargs.get("data") if data: body = json.loads(data) - body_beta = body.get("additionalModelRequestFields", {}).get( - "anthropic_beta", [] - ) + body_beta = body.get("additionalModelRequestFields", {}).get("anthropic_beta", []) for unsupported_header in unsupported: - assert ( - unsupported_header not in body_beta - ), f"Unsupported header '{unsupported_header}' should not be in request body for Bedrock Converse" + assert unsupported_header not in body_beta, ( + f"Unsupported header '{unsupported_header}' should not be in request body for Bedrock Converse" + ) for header, mapped_value in mapped_headers.items(): if header in all_headers and mapped_value in body_beta: - assert ( - mapped_value in body_beta - ), f"Supported header '{header}' should be mapped to '{mapped_value}' in request body for Bedrock Converse" + assert mapped_value in body_beta, ( + f"Supported header '{header}' should be mapped to '{mapped_value}' in request body for Bedrock Converse" + ) @pytest.mark.asyncio async def test_vertex_ai_messages_http_headers_filtering(self): @@ -318,9 +295,7 @@ class TestAnthropicBetaHeadersFiltering: all_headers = self.get_all_beta_headers() unsupported = self.get_unsupported_headers("vertex_ai") - with patch( - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" - ) as mock_client_factory: + with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_client_factory: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { @@ -338,9 +313,7 @@ class TestAnthropicBetaHeadersFiltering: mock_client.post = AsyncMock(return_value=mock_response) mock_client_factory.return_value = mock_client - with patch( - "litellm.llms.vertex_ai.vertex_llm_base.VertexBase._ensure_access_token" - ) as mock_token: + with patch("litellm.llms.vertex_ai.vertex_llm_base.VertexBase._ensure_access_token") as mock_token: mock_token.return_value = ("test-token", "test-project") try: @@ -363,9 +336,9 @@ class TestAnthropicBetaHeadersFiltering: if beta_header: beta_values = [b.strip() for b in beta_header.split(",")] for unsupported_header in unsupported: - assert ( - unsupported_header not in beta_values - ), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Vertex AI" + assert unsupported_header not in beta_values, ( + f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Vertex AI" + ) def test_header_mapping_correctness(self): """Test that headers are mapped correctly for providers with transformations.""" @@ -402,17 +375,15 @@ class TestAnthropicBetaHeadersFiltering: beta_headers=[test_case["input"]], provider=test_case["provider"] ) - assert ( - test_case["expected"] in filtered - ), f"Header '{test_case['input']}' should be mapped to '{test_case['expected']}' for {test_case['provider']}, but got: {filtered}" + assert test_case["expected"] in filtered, ( + f"Header '{test_case['input']}' should be mapped to '{test_case['expected']}' for {test_case['provider']}, but got: {filtered}" + ) def test_filter_and_transform_beta_headers_vertex_ai_keeps_compact(self): """Vertex AI supports compact context edits, so the compact beta header must be forwarded instead of stripped (it was previously mapped to null, which broke compact_20260112 context edits over /v1/messages).""" - filtered = filter_and_transform_beta_headers( - beta_headers=["compact-2026-01-12"], provider="vertex_ai" - ) + filtered = filter_and_transform_beta_headers(beta_headers=["compact-2026-01-12"], provider="vertex_ai") assert filtered == ["compact-2026-01-12"] @@ -486,13 +457,11 @@ class TestAnthropicBetaHeadersFiltering: unsupported = self.get_unsupported_headers(provider) if unsupported: - filtered = filter_and_transform_beta_headers( - beta_headers=unsupported, provider=provider - ) + filtered = filter_and_transform_beta_headers(beta_headers=unsupported, provider=provider) - assert ( - len(filtered) == 0 - ), f"All null-value headers should be filtered out for {provider}, but got: {filtered}" + assert len(filtered) == 0, ( + f"All null-value headers should be filtered out for {provider}, but got: {filtered}" + ) def test_empty_headers_list(self): """Test that empty headers list returns empty result.""" @@ -503,13 +472,9 @@ class TestAnthropicBetaHeadersFiltering: "bedrock", "vertex_ai", ]: - filtered = filter_and_transform_beta_headers( - beta_headers=[], provider=provider - ) + filtered = filter_and_transform_beta_headers(beta_headers=[], provider=provider) - assert ( - len(filtered) == 0 - ), f"Empty headers list should return empty result for {provider}" + assert len(filtered) == 0, f"Empty headers list should return empty result for {provider}" def test_mixed_supported_and_unsupported_headers(self): """Test filtering with a mix of supported, unsupported, and unknown headers.""" @@ -529,20 +494,12 @@ class TestAnthropicBetaHeadersFiltering: test_headers = [supported[0]] + [unsupported[0]] + ["unknown-header-123"] - filtered = filter_and_transform_beta_headers( - beta_headers=test_headers, provider=provider - ) + filtered = filter_and_transform_beta_headers(beta_headers=test_headers, provider=provider) expected_mapped = mapped_headers[supported[0]] - assert ( - expected_mapped in filtered - ), f"Supported header should be in result for {provider}" - assert ( - unsupported[0] not in filtered - ), f"Unsupported header should not be in result for {provider}" - assert ( - "unknown-header-123" not in filtered - ), f"Unknown header should not be in result for {provider}" + assert expected_mapped in filtered, f"Supported header should be in result for {provider}" + assert unsupported[0] not in filtered, f"Unsupported header should not be in result for {provider}" + assert "unknown-header-123" not in filtered, f"Unknown header should not be in result for {provider}" @pytest.mark.parametrize("provider", ["anthropic", "bedrock", "bedrock_mantle", "vertex_ai"]) def test_blank_anthropic_beta_header_is_removed(self, provider): @@ -570,3 +527,77 @@ def test_local_flags_load_bundled_beta_headers_without_fetching(monkeypatch, fla config = get_beta_headers_config(url="https://example.test/anthropic_beta_headers_config.json") remote_get.assert_not_called() assert config == GetAnthropicBetaHeadersConfig.load_local_beta_headers_config() + + +FOUNDRY_API_BASE: Final = "https://my-resource.services.ai.azure.com/anthropic" +DIRECT_API_BASE: Final = "https://api.anthropic.com" +DIRECT_ONLY_BETA: Final = "advisor-tool-2026-03-01" +PRESERVED_THINKING_BETAS: Final = ( + "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21", + "mid-conversation-tool-changes-2026-07-01", + "mid-conversation-output-config-2026-07-01", + "thinking-display-updates-2026-08-18", +) + + +@pytest.fixture +def bundled_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") + anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None + + +def test_foundry_deployment_headers_follow_the_azure_ai_row(bundled_config: None) -> None: + headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} + result: dict[str, str] = update_headers_with_filtered_beta( + headers=headers, provider="anthropic", api_base=FOUNDRY_API_BASE + ) + assert result == {"anthropic-beta": "thinking-binding-controls-2026-08-01"} + + +def test_direct_deployment_headers_keep_every_anthropic_row_beta(bundled_config: None) -> None: + headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} + result: dict[str, str] = update_headers_with_filtered_beta( + headers=headers, provider="anthropic", api_base=DIRECT_API_BASE + ) + assert result == {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} + + +def test_foundry_deployment_request_filters_header_and_body_betas_together(bundled_config: None) -> None: + headers: dict[str, str] + data: dict[str, object] + headers, data = update_request_with_filtered_beta( + headers={"anthropic-beta": DIRECT_ONLY_BETA}, + request_data={"anthropic_beta": [DIRECT_ONLY_BETA, "thinking-binding-controls-2026-08-01"], "max_tokens": 8}, + provider="anthropic", + api_base=FOUNDRY_API_BASE, + ) + assert headers == {} + assert data == {"anthropic_beta": ["thinking-binding-controls-2026-08-01"], "max_tokens": 8} + + +PRESERVED_THINKING_BETAS: Final = ( + "thinking-binding-controls-2026-08-01", + "mid-conversation-system-clear-at-2026-08-21", + "mid-conversation-tool-changes-2026-07-01", + "mid-conversation-output-config-2026-07-01", + "thinking-display-updates-2026-08-18", +) + + +@pytest.mark.parametrize("provider", ["anthropic", "azure_ai", "vertex_ai"]) +def test_live_verified_platforms_forward_every_preserved_thinking_beta(bundled_config: None, provider: str) -> None: + result: dict[str, str] = update_headers_with_filtered_beta( + headers={"anthropic-beta": ",".join(PRESERVED_THINKING_BETAS)}, provider=provider + ) + assert sorted(result["anthropic-beta"].split(",")) == sorted(PRESERVED_THINKING_BETAS), result + + +@pytest.mark.parametrize("provider", ["anthropic", "azure_ai", "vertex_ai", "bedrock", "bedrock_converse"]) +def test_each_row_forwards_exactly_its_verified_preserved_thinking_cells(bundled_config: None, provider: str) -> None: + row: Final[dict[str, str | None]] = GetAnthropicBetaHeadersConfig.load_local_beta_headers_config()[provider] + forwarded: Final = tuple(beta for beta in PRESERVED_THINKING_BETAS if row.get(beta) is not None) + result: dict[str, str] = update_headers_with_filtered_beta( + headers={"anthropic-beta": ",".join(PRESERVED_THINKING_BETAS)}, provider=provider + ) + assert sorted(result["anthropic-beta"].split(",")) == sorted(forwarded), (provider, result) From a152ecf930bf522a1e995ed569f257629e1c60b0 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 27 Oct 2026 20:48:37 +0000 Subject: [PATCH 6/6] test(anthropic): drop the duplicated beta tuple and mark filter results Final Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_anthropic_beta_headers_filtering.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_anthropic_beta_headers_filtering.py b/tests/unit/test_anthropic_beta_headers_filtering.py index ec15f0a8dd5..630f3f75b9f 100644 --- a/tests/unit/test_anthropic_beta_headers_filtering.py +++ b/tests/unit/test_anthropic_beta_headers_filtering.py @@ -18,8 +18,6 @@ import pytest import litellm from litellm import anthropic_beta_headers_manager from litellm.anthropic_beta_headers_manager import ( - resolve_beta_provider, - is_foundry_api_base, GetAnthropicBetaHeadersConfig, filter_and_transform_beta_headers, get_beta_headers_config, @@ -549,7 +547,7 @@ def bundled_config(monkeypatch: pytest.MonkeyPatch) -> None: def test_foundry_deployment_headers_follow_the_azure_ai_row(bundled_config: None) -> None: headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} - result: dict[str, str] = update_headers_with_filtered_beta( + result: Final[dict[str, str]] = update_headers_with_filtered_beta( headers=headers, provider="anthropic", api_base=FOUNDRY_API_BASE ) assert result == {"anthropic-beta": "thinking-binding-controls-2026-08-01"} @@ -557,7 +555,7 @@ def test_foundry_deployment_headers_follow_the_azure_ai_row(bundled_config: None def test_direct_deployment_headers_keep_every_anthropic_row_beta(bundled_config: None) -> None: headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} - result: dict[str, str] = update_headers_with_filtered_beta( + result: Final[dict[str, str]] = update_headers_with_filtered_beta( headers=headers, provider="anthropic", api_base=DIRECT_API_BASE ) assert result == {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"} @@ -576,18 +574,9 @@ def test_foundry_deployment_request_filters_header_and_body_betas_together(bundl assert data == {"anthropic_beta": ["thinking-binding-controls-2026-08-01"], "max_tokens": 8} -PRESERVED_THINKING_BETAS: Final = ( - "thinking-binding-controls-2026-08-01", - "mid-conversation-system-clear-at-2026-08-21", - "mid-conversation-tool-changes-2026-07-01", - "mid-conversation-output-config-2026-07-01", - "thinking-display-updates-2026-08-18", -) - - @pytest.mark.parametrize("provider", ["anthropic", "azure_ai", "vertex_ai"]) def test_live_verified_platforms_forward_every_preserved_thinking_beta(bundled_config: None, provider: str) -> None: - result: dict[str, str] = update_headers_with_filtered_beta( + result: Final[dict[str, str]] = update_headers_with_filtered_beta( headers={"anthropic-beta": ",".join(PRESERVED_THINKING_BETAS)}, provider=provider ) assert sorted(result["anthropic-beta"].split(",")) == sorted(PRESERVED_THINKING_BETAS), result @@ -597,7 +586,7 @@ def test_live_verified_platforms_forward_every_preserved_thinking_beta(bundled_c def test_each_row_forwards_exactly_its_verified_preserved_thinking_cells(bundled_config: None, provider: str) -> None: row: Final[dict[str, str | None]] = GetAnthropicBetaHeadersConfig.load_local_beta_headers_config()[provider] forwarded: Final = tuple(beta for beta in PRESERVED_THINKING_BETAS if row.get(beta) is not None) - result: dict[str, str] = update_headers_with_filtered_beta( + result: Final[dict[str, str]] = update_headers_with_filtered_beta( headers={"anthropic-beta": ",".join(PRESERVED_THINKING_BETAS)}, provider=provider ) assert sorted(result["anthropic-beta"].split(",")) == sorted(forwarded), (provider, result)