mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
parent
ff4e80ca65
commit
7b5ea297e0
7 changed files with 99 additions and 54 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue