From 7b5ea297e072896ce0fa318f1b05183eb7a00e34 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:54:40 -0800 Subject: [PATCH] fix(anthropic): add a thread's create betas to the client's own on continues and keep base header merging on chat and Responses --- litellm/llms/anthropic/common_utils.py | 25 ------------ litellm/llms/custom_httpx/llm_http_handler.py | 21 +++++++++- litellm/main.py | 9 ++--- .../proxy/hooks/message_thread_ownership.py | 37 +++++++++++++++++- litellm/responses/utils.py | 3 +- .../anthropic/test_anthropic_common_utils.py | 19 +-------- .../hooks/test_message_thread_ownership.py | 39 +++++++++++++++++++ 7 files changed, 99 insertions(+), 54 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 646e0387d9e..c9df903cbd6 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -517,31 +517,6 @@ def merge_anthropic_beta_headers(existing: str | Sequence[str] | None, new_beta: return ",".join(sorted(betas)) -_HEADER_VALUE_LIST: Final = TypeAdapter(tuple[object, ...]) - - -def _header_strings(value: object) -> tuple[str, ...]: - if isinstance(value, str): - return (value,) - if not isinstance(value, list | tuple): - return () - return tuple(entry for entry in _HEADER_VALUE_LIST.validate_python(value) if isinstance(entry, str)) - - -def merge_header_sources(*sources: Mapping[str, object] | None) -> Mapping[str, object]: - """Later sources win per header, except ``anthropic-beta``, which unions every source so client betas - never drop a deployment's configured ones.""" - entries: Final = tuple(chain.from_iterable((source or {}).items() for source in sources)) - beta_values: Final = tuple( - chain.from_iterable(_header_strings(value) for name, value in entries if name.lower() == "anthropic-beta") - ) - merged_beta: Final = merge_anthropic_beta_headers(beta_values, None) - return MappingProxyType( - {name: value for name, value in entries if name.lower() != "anthropic-beta"} - | ({"anthropic-beta": merged_beta} if merged_beta else {}) - ) - - def optionally_handle_anthropic_oauth(headers: dict, api_key: str | None) -> tuple[dict, str | None]: """ Handle Anthropic OAuth token detection and header setup. diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 83c135cd1dd..8a094cc6424 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -6,6 +6,7 @@ import ssl from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence from contextlib import asynccontextmanager from functools import lru_cache, partial, reduce +from itertools import chain from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -67,7 +68,7 @@ from litellm.litellm_core_utils.realtime_errors import ( ) from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base -from litellm.llms.anthropic.common_utils import AnthropicModelInfo, merge_header_sources +from litellm.llms.anthropic.common_utils import AnthropicModelInfo, merge_anthropic_beta_headers from litellm.llms.base_llm.anthropic_messages.transformation import ( BaseAnthropicMessagesConfig, ) @@ -648,6 +649,22 @@ def _unwrap_provider_error(error: Exception) -> Exception: return error +def _merge_messages_header_sources(*sources: Mapping[str, object] | None) -> Mapping[str, object]: + """Later sources win per header, except ``anthropic-beta``, which unions every source so client betas + never drop a deployment's configured ones.""" + entries: Final = tuple(chain.from_iterable((source or {}).items() for source in sources)) + beta_values: Final = tuple( + cast("str | Sequence[str]", value) # cast-ok: the merge helper skips non-str list entries + for name, value in entries + if name.lower() == "anthropic-beta" and isinstance(value, str | Sequence) + ) + merged_beta: Final = reduce(merge_anthropic_beta_headers, beta_values, "") + return MappingProxyType( + {name: value for name, value in entries if name.lower() != "anthropic-beta"} + | ({"anthropic-beta": merged_beta} if merged_beta else {}) + ) + + class BaseLLMHTTPHandler: async def _make_common_async_call( self, @@ -2556,7 +2573,7 @@ class BaseLLMHTTPHandler: forwarded_headers: Final = kwargs.get("headers", None) # Also check for extra_headers in kwargs (from config or direct calls) extra_headers_from_kwargs: Final = kwargs.get("extra_headers", None) - merged_headers: Final = merge_header_sources( + merged_headers: Final = _merge_messages_header_sources( forwarded_headers, extra_headers_from_kwargs, provider_specific_headers ) request_params: Final = reduce( diff --git a/litellm/main.py b/litellm/main.py index ca11693f6a4..78767b03eb7 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -242,7 +242,7 @@ from .litellm_core_utils.streaming_chunk_builder_utils import ( ChunkProcessor as ChunkProcessor, # noqa: PLC0414 # public re-export ) from .llms.anthropic.chat import AnthropicChatCompletion -from .llms.anthropic.common_utils import is_claude_tool_target, merge_header_sources +from .llms.anthropic.common_utils import is_claude_tool_target from .llms.azure.audio_transcriptions import AzureAudioTranscription from .llms.azure.azure import AzureChatCompletion, check_dynamic_azure_params from .llms.azure.chat.o_series_handler import AzureOpenAIO1ChatCompletion @@ -5957,15 +5957,12 @@ def completion( tools = _drop_anthropic_only_tool_keys(tools=tools) if provider_specific_header is not None: - merged_headers: Final = merge_header_sources( - headers, + headers.update( ProviderSpecificHeaderUtils.get_provider_specific_headers( provider_specific_header=provider_specific_header, custom_llm_provider=custom_llm_provider, - ), + ) ) - headers.clear() - headers.update(merged_headers) if model_response is not None and hasattr(model_response, HIDDEN_PARAMS_ATTR): model_response_hidden_params: Final = cast( # cast-ok: preserve dynamic mapping behavior diff --git a/litellm/proxy/hooks/message_thread_ownership.py b/litellm/proxy/hooks/message_thread_ownership.py index 5a32c7fcd81..ff68611d22d 100644 --- a/litellm/proxy/hooks/message_thread_ownership.py +++ b/litellm/proxy/hooks/message_thread_ownership.py @@ -32,6 +32,7 @@ _UNOWNED_THREAD_MESSAGE: Final = ( ) _MAPPING_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_MAPPINGS_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, object], ...]) class _ThreadBinding(BaseModel): @@ -91,6 +92,36 @@ def _with_anthropic_beta(extra_headers: object, anthropic_beta: str) -> Mapping[ } +def _as_mappings(value: object) -> tuple[Mapping[str, object], ...]: + try: + return _MAPPINGS_ADAPTER.validate_python(value) + except ValidationError: + return () + + +def _client_headers_with_anthropic_beta(scoped: Mapping[str, object], anthropic_beta: str) -> Mapping[str, object]: + client_headers: Final = _as_mapping(scoped.get("extra_headers")) + if client_headers is None or _anthropic_beta(client_headers) is None: + return scoped + return {**scoped, "extra_headers": dict(_with_anthropic_beta(client_headers, anthropic_beta))} + + +def _provider_headers_with_anthropic_beta( + provider_specific_header: object, anthropic_beta: str +) -> Mapping[str, object]: + single: Final = _as_mapping(provider_specific_header) + if single is not None: + return {"provider_specific_header": _client_headers_with_anthropic_beta(single, anthropic_beta)} + scoped_headers: Final = _as_mappings(provider_specific_header) + if not scoped_headers: + return {} + return { + "provider_specific_header": tuple( + _client_headers_with_anthropic_beta(scoped, anthropic_beta) for scoped in scoped_headers + ) + } + + def _owner_key(message_id: str) -> str: return f"{_OWNER_KEY_PREFIX}{message_id}" @@ -192,7 +223,11 @@ class MessageThreadOwnership(CustomLogger): raise HTTPException(status_code=404, detail=message_thread_not_found_body(_UNOWNED_THREAD_MESSAGE)) if binding.anthropic_beta is None or call_type == _PASS_THROUGH_CALL_TYPE: return None - return {**data, "extra_headers": _with_anthropic_beta(data.get("extra_headers"), binding.anthropic_beta)} + return { + **data, + "extra_headers": _with_anthropic_beta(data.get("extra_headers"), binding.anthropic_beta), + **_provider_headers_with_anthropic_beta(data.get("provider_specific_header"), binding.anthropic_beta), + } async def async_post_call_success_hook( self, diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 6b3b2a4f334..43ed9a3b96f 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -16,7 +16,6 @@ from litellm._logging import verbose_logger from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none, iter_items from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value, is_nested_path from litellm.litellm_core_utils.get_provider_specific_headers import ProviderSpecificHeaderUtils -from litellm.llms.anthropic.common_utils import merge_header_sources from litellm.llms.base_llm.responses.transformation import ( ANY_REASONING_FIELD_VALUES, REASONING_SUMMARY_VALUES, @@ -263,7 +262,7 @@ class ResponsesAPIRequestUtils: ) if extra_headers is None and not scoped_headers: return None - return dict(merge_header_sources(extra_headers, scoped_headers)) + return {**(extra_headers or {}), **scoped_headers} @staticmethod def _check_valid_arg( diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index 3feb730b2e1..cf0b8a39865 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -26,12 +26,7 @@ import pytest import respx import litellm -from litellm.llms.anthropic.common_utils import ( - ToolsWithoutStrict, - is_claude_tool_target, - merge_header_sources, - tools_without_strict, -) +from litellm.llms.anthropic.common_utils import ToolsWithoutStrict, is_claude_tool_target, tools_without_strict from litellm.llms.custom_httpx.http_handler import HTTPHandler sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))) @@ -4692,18 +4687,6 @@ def test_requires_native_compaction_beta_is_false_without_a_signal( assert requires_native_compaction_beta(provider, optional_params, messages) is False -def test_merge_header_sources_keeps_only_the_string_betas_of_a_mixed_list() -> None: - merged: Final = merge_header_sources( - {"anthropic-beta": ["context-1m-2025-08-07", b"bytes-beta", ("nested",), object(), 3]}, - {"Anthropic-Beta": "message-threads-2026-08-12", "x-other": "kept"}, - ) - - assert dict(merged) == { - "anthropic-beta": "context-1m-2025-08-07,message-threads-2026-08-12", - "x-other": "kept", - } - - @pytest.mark.parametrize( ("custom_llm_provider", "model", "expected"), [ diff --git a/tests/unit/proxy/hooks/test_message_thread_ownership.py b/tests/unit/proxy/hooks/test_message_thread_ownership.py index 5c22332f60d..9ff49ad55d6 100644 --- a/tests/unit/proxy/hooks/test_message_thread_ownership.py +++ b/tests/unit/proxy/hooks/test_message_thread_ownership.py @@ -271,6 +271,45 @@ async def test_a_continue_on_chat_or_responses_resends_the_betas_its_create_turn assert continued["extra_headers"] == {"anthropic-beta": "advisor-tool-2026-03-01,message-threads-2026-08-12"} +CLIENT_BETAS: Final = {"custom_llm_provider": "anthropic,azure_ai", "extra_headers": {"anthropic-beta": "context-1m-2025-08-07"}} +CLIENT_VERSION: Final = {"custom_llm_provider": "anthropic", "extra_headers": {"anthropic-version": "2023-06-01"}} +CLIENT_BETAS_WITH_THE_CREATES: Final = { + "custom_llm_provider": "anthropic,azure_ai", + "extra_headers": {"anthropic-beta": "advisor-tool-2026-03-01,context-1m-2025-08-07,message-threads-2026-08-12"}, +} + + +@pytest.mark.parametrize( + ("client_headers", "expected"), + ( + (CLIENT_BETAS, CLIENT_BETAS_WITH_THE_CREATES), + ([CLIENT_BETAS, CLIENT_VERSION], (CLIENT_BETAS_WITH_THE_CREATES, CLIENT_VERSION)), + (CLIENT_VERSION, CLIENT_VERSION), + ), +) +@pytest.mark.parametrize("call_type", ("acompletion", "aresponses", "anthropic_messages")) +@pytest.mark.asyncio +async def test_a_continue_that_sends_its_own_betas_keeps_the_create_turns_betas_next_to_them( + call_type: CallTypesLiteral, client_headers: object, expected: object +) -> None: + ownership: Final = hook() + await ownership.async_post_call_success_hook( + data=sent_with("advisor-tool-2026-03-01,message-threads-2026-08-12", thread=CREATE), + user_api_key_dict=OWNER, + response=anthropic_message("msg_client_betas"), + ) + continued: Final = await ownership.async_pre_call_hook( + user_api_key_dict=OWNER, + cache=DualCache(), + data={**continue_from("msg_client_betas"), "provider_specific_header": client_headers}, + call_type=call_type, + ) + + assert continued is not None + assert continued["provider_specific_header"] == expected + assert continued["extra_headers"] == {"anthropic-beta": "advisor-tool-2026-03-01,message-threads-2026-08-12"} + + @pytest.mark.asyncio async def test_a_pass_through_continue_is_checked_but_left_unchanged() -> None: ownership: Final = hook()