fix(anthropic): add a thread's create betas to the client's own on continues and keep base header merging on chat and Responses

This commit is contained in:
mateo-berri 2026-11-04 21:54:40 -08:00
parent ff4e80ca65
commit 7b5ea297e0
7 changed files with 99 additions and 54 deletions

View file

@ -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.

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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"),
[

View file

@ -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()