From 3734837163688e70027dde76f3c8574e16fb069b Mon Sep 17 00:00:00 2001 From: Lujaec <62204810+Lujaec@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:00:14 +0900 Subject: [PATCH] fix(anthropic): forward the Message Threads thread field on native /v1/messages The native /v1/messages request params come from AnthropicMessagesRequestOptionalParams, which lacked thread, so a Claude Code Message Threads continue request reached Anthropic without it. Anthropic then saw a fresh conversation holding only the newest message, and answered without the earlier turns, or with a 400 when that message was a tool result thread is now kept on the Anthropic route and dropped with a warning on providers that do not serve Message Threads --- litellm/llms/anthropic/chat/transformation.py | 15 +++++ .../messages/adapters/transformation.py | 2 +- .../messages/native/transformation.py | 2 +- .../llms/anthropic/messages/native/utils.py | 5 ++ litellm/types/llms/anthropic.py | 1 + .../native/test_anthropic_messages_handler.py | 60 +++++++++++++++++++ 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 92e7c84031c..5589d55c49f 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -415,6 +415,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." @@ -696,6 +700,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 1ed552d46e3..20f4a68535d 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -278,7 +278,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 cc53858f842..4fca703e428 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -711,7 +711,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 ) return dict(anthropic_messages_request) 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 de83e6ea66e..3ac057d5a0e 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 c11f58faec4..150383ddb74 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 @@ -1628,6 +1628,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."""