diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index d773e31c733..1ba36450e91 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -417,6 +417,10 @@ DROP_SERVICE_TIER_PROVIDER_WARNING: Final = ( "Dropping `service_tier` for model=%s on provider=%s. Only the Anthropic API routes accept it." ) +DROP_THREAD_PROVIDER_WARNING: Final = ( + "Dropping `thread` for model=%s on provider=%s. Only the Anthropic API keeps Message Threads." +) + DROP_UNSUPPORTED_TOOL_SEARCH_WARNING: Final = ( "Dropping the OpenAI `tool_search` tool and the `defer_loading` of the other tools for model=%s on %s " "(drop_params=True) because this route cannot run that tool search." @@ -698,6 +702,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): litellm.verbose_logger.warning(DROP_SERVICE_TIER_PROVIDER_WARNING, model, custom_llm_provider) optional_params.pop("service_tier", None) + @staticmethod + def maybe_drop_thread_param( + model: str, + optional_params: dict[str, object], # mutable-ok: mirrors override contract + custom_llm_provider: str | None = None, + ) -> None: + if "thread" not in optional_params or custom_llm_provider in (None, "anthropic"): + return + litellm.verbose_logger.warning(DROP_THREAD_PROVIDER_WARNING, model, custom_llm_provider) + optional_params.pop("thread", None) + @staticmethod def _raise_invalid_reasoning_effort(model: str, value: object, llm_provider: str) -> NoReturn: """Raise a ``BadRequestError`` for an unrecognised ``reasoning_effort``. diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index bc9649ed867..57c2ccc0acc 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -288,7 +288,7 @@ class AnthropicAdapter: ######################################################### # Created Typed Request Body ######################################################### - request_body: Final = AnthropicMessagesRequest(model=model, messages=messages, **kwargs) + request_body: Final = AnthropicMessagesRequest(model=model, messages=messages, **kwargs) # pyright: ignore[reportUnknownArgumentType] # the request params arrive as a bare dict ( translated_body, diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index 5281ad99620..366e4bbfb47 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -793,7 +793,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): ), max_tokens=max_tokens, model=model, - **anthropic_messages_optional_request_params, + **anthropic_messages_optional_request_params, # pyright: ignore[reportUnknownArgumentType] # the request params arrive as a bare dict ) request: Final = ( {**dict(anthropic_messages_request), "thinking": gated.thinking} diff --git a/litellm/llms/anthropic/messages/native/utils.py b/litellm/llms/anthropic/messages/native/utils.py index 29bbd8178ba..502f34a8d27 100644 --- a/litellm/llms/anthropic/messages/native/utils.py +++ b/litellm/llms/anthropic/messages/native/utils.py @@ -301,6 +301,11 @@ class AnthropicMessagesRequestUtils: optional_params=filtered_params, custom_llm_provider=custom_llm_provider, ) + AnthropicConfig.maybe_drop_thread_param( + model=model, + optional_params=filtered_params, + custom_llm_provider=custom_llm_provider, + ) for param in ("temperature", "top_p", "top_k"): if param in filtered_params: AnthropicModelInfo._apply_sampling_param( # pyright: ignore[reportPrivateUsage] # same gating the /chat/completions path applies; forking it would drift diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 152614e4385..92452326f0b 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -440,6 +440,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False): reasoning_effort: str | None safeguards: ReadOnly[list[dict[str, object]] | None] service_tier: ReadOnly[str | None] + thread: ReadOnly[Mapping[str, object] | None] class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False): diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py index af3108e5072..a629f037968 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_handler.py @@ -1633,6 +1633,66 @@ async def test_anthropic_messages_forwards_safeguards_and_unknown_beta_to_anthro assert response["safeguard_results"] == safeguard_results +@pytest.mark.asyncio +async def test_anthropic_messages_forwards_message_threads_continue_to_anthropic(): + """Shape is what Claude Code 2.1.312 sends with the message-threads-2026-08-12 beta, captured 2026-10-29.""" + from litellm.llms.anthropic.messages.native import handler + + thread = {"type": "continue", "previous_message_id": "msg_01ABC"} + tool_result_only = [ + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "README contents"}]} + ] + captured: dict[str, object] = {} + + def upstream_records_the_request(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg_02DEF", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + request=request, + ) + + upstream = AsyncHTTPHandler() + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_records_the_request)) + + await handler.anthropic_messages( + max_tokens=16, + messages=tool_result_only, + model="anthropic/claude-haiku-4-5", + custom_llm_provider="anthropic", + api_key="sk-test", + client=upstream, + thread=thread, + extra_headers={"anthropic-beta": "message-threads-2026-08-12"}, + ) + + assert captured["body"]["thread"] == thread + assert captured["body"]["messages"] == tool_result_only + + +@pytest.mark.parametrize("custom_llm_provider", ["vertex_ai", "bedrock", "azure_ai", "databricks"]) +def test_anthropic_messages_drops_thread_where_message_threads_are_not_served(custom_llm_provider: str) -> None: + from litellm.llms.anthropic.messages.native.utils import AnthropicMessagesRequestUtils + + optional_params = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param( + params={"max_tokens": 64, "thread": {"type": "create"}}, + model="claude-haiku-4-5", + custom_llm_provider=custom_llm_provider, + ) + + assert "thread" not in optional_params + assert dict(optional_params).get("max_tokens") == 64 + + @pytest.mark.asyncio async def test_anthropic_messages_streaming_forwards_safeguards_and_keeps_safeguard_results(): """Shapes are what Claude Code 2.1.278 sends and api.anthropic.com returns, captured 2026-09-21."""