From ef84494d52c6708e4e9f4a54ce551a265995ad8f Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:49:46 -0700 Subject: [PATCH 01/12] Merge pull request #37058 from BerriAI/litellm_passthrough_accept_encoding fix(passthrough): stop forwarding client Accept-Encoding upstream (cherry picked from commit 9a96b9327c645af28fa2f6bb4c73db76adac4ea4) --- litellm/passthrough/utils.py | 4 +++ .../test_vertex_passthrough_load_balancing.py | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/litellm/passthrough/utils.py b/litellm/passthrough/utils.py index e419322dca6..df39b8fad48 100644 --- a/litellm/passthrough/utils.py +++ b/litellm/passthrough/utils.py @@ -18,6 +18,7 @@ _PASS_THROUGH_PROTECTED_HEADERS: Final[frozenset] = frozenset( "x-goog-api-key", "host", "content-length", + "accept-encoding", } ) @@ -69,6 +70,9 @@ class BasePassthroughUtils: # Header We Should NOT forward request_headers.pop("content-length", None) request_headers.pop("host", None) + # accept-encoding must stay client-negotiated: forwarding e.g. "br" when + # the brotli package is absent relays undecodable bytes to the caller + request_headers.pop("accept-encoding", None) custom_header_names: Final = {header_name.lower() for header_name in headers} for header_name in list(request_headers.keys()): diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py index aaf1dad4910..8e973fc3771 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py @@ -568,6 +568,32 @@ def test_forward_headers_custom_wins_case_insensitive_over_request_authorization assert result["x-request-id"] == "req-123" +def test_forward_headers_never_forwards_client_accept_encoding(): + """ + The client's Accept-Encoding must not reach the upstream provider: the proxy's + HTTP client decodes the upstream body and advertises only encodings it can + decode. Forwarding e.g. "br" on an install without the brotli package makes + the proxy relay raw compressed bytes with the content-encoding header stripped + (garbled JSON for /v1/models and count_tokens through the Anthropic passthrough). + """ + from litellm.passthrough.utils import BasePassthroughUtils + + request_headers = { + "accept-encoding": "gzip, deflate, br, zstd", + "x-pass-accept-encoding": "br", + "x-request-id": "req-123", + } + + result = BasePassthroughUtils.forward_headers_from_request( + request_headers=request_headers, + headers={}, + forward_headers=True, + ) + + assert "accept-encoding" not in result + assert result["x-request-id"] == "req-123" + + @pytest.mark.asyncio async def test_vertex_passthrough_custom_model_name_replaced_in_url(): """ From 8fabf74b86efb4004a67d16e8f7a835469b4f3e6 Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Tue, 7 Jul 2026 14:40:52 +0200 Subject: [PATCH 02/12] fix(anthropic-messages): suppress reasoning_content->thinking block when thinking is absent/disabled When a client omits the Anthropic 'thinking' request parameter (or sends {type: disabled}), the Anthropic Messages contract is that thinking is off. The experimental pass-through adapter's translation of a non-Anthropic backend's reasoning_content into a thinking content block previously ignored this entirely, unconditionally converting any reasoning_content into a thinking block regardless of what the client asked for. Backends that reason unconditionally (e.g. vLLM-served Qwen3/DeepSeek-R1 without an explicit enable_thinking=false at the inference-server layer) would therefore produce unexpected thinking blocks that Anthropic-SDK-based clients (including Claude Code) do not expect and can reject. Threads a thinking_disabled flag (thinking is None or thinking.type == 'disabled') from the two adapter entry points (async_anthropic_messages_handler, anthropic_messages_handler) through both the non-streaming translation call chain (transformation.py only) and the streaming call chain (transformation.py 's translate_completion_output_params_streaming into AnthropicStreamWrapper in streaming_iterator.py, which is what actually drives every streaming classifier/emitter call, including a direct call inside _should_start_new_content_block that bypasses the main translation entry point). Default False preserves existing behavior for any caller that doesn't pass it explicitly. CTG-88 --- .../adapters/handler.py | 8 + .../adapters/streaming_iterator.py | 159 +++++++-- .../adapters/transformation.py | 305 +++++++++++++----- 3 files changed, 370 insertions(+), 102 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 36f3e875a7e..6d7162f6f36 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -599,6 +599,8 @@ class LiteLLMMessagesToCompletionTransformationHandler: completion_response: Final = await litellm.acompletion(**completion_kwargs) + thinking_disabled = thinking is None or (isinstance(thinking, dict) and thinking.get("type") == "disabled") + if stream: transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( completion_response, @@ -606,6 +608,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: tool_name_mapping=tool_name_mapping, polyfill_result=polyfill_result, is_async=True, + thinking_disabled=thinking_disabled, ) if transformed_stream is not None: return transformed_stream @@ -615,6 +618,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: cast(ModelResponse, completion_response), tool_name_mapping=tool_name_mapping, polyfill_result=polyfill_result, + thinking_disabled=thinking_disabled, ) if anthropic_response is not None: return anthropic_response @@ -733,6 +737,8 @@ class LiteLLMMessagesToCompletionTransformationHandler: completion_response: Final = litellm.completion(**completion_kwargs) + thinking_disabled = thinking is None or (isinstance(thinking, dict) and thinking.get("type") == "disabled") + if stream: transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( completion_response, @@ -740,6 +746,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: tool_name_mapping=tool_name_mapping, polyfill_result=polyfill_result, is_async=False, + thinking_disabled=thinking_disabled, ) if transformed_stream is not None: return transformed_stream @@ -749,6 +756,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: cast(ModelResponse, completion_response), tool_name_mapping=tool_name_mapping, polyfill_result=polyfill_result, + thinking_disabled=thinking_disabled, ) if anthropic_response is not None: return anthropic_response diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 1660f56378f..8bc4aa57bf7 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -11,6 +11,7 @@ from typing import ( Final, Literal, Protocol, + cast, get_args, ) @@ -272,7 +273,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): sent_first_chunk: bool = False sent_content_block_start: bool = False sent_content_block_finish: bool = False - current_content_block_type: Literal["text", "tool_use", "thinking"] = "text" + current_content_block_type: Literal[ + "text", "tool_use", "thinking", "redacted_thinking" + ] = "text" sent_last_message: bool = False holding_chunk: ContentBlockDelta | None = None holding_stop_reason_chunk: MessageBlockDelta | None = None @@ -287,6 +290,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): applied_edits: list[AppliedEdit] | None = None, compaction_block: CompactionBlock | None = None, iterations_usage: list[UsageIteration] | None = None, + thinking_disabled: bool = False, ): # Wrap the upstream stream so chunks that carry both content and a # finish_reason (fake-streamed providers) are split into two — see @@ -300,6 +304,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # Synthesized compaction block from compact_20260112 polyfill (streaming). self.compaction_block = compaction_block self.iterations_usage = iterations_usage + self.thinking_disabled = thinking_disabled self.sent_compaction_block: bool = False # Per-phase flags so the compaction block's start/delta/stop events # are emitted (and the public state machine is advanced) in @@ -492,7 +497,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): cache_read_input_tokens=0, ) - def __next__(self): + def __next__(self): # noqa: PLR0915 from .transformation import LiteLLMAnthropicMessagesAdapter try: @@ -551,6 +556,29 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): elif should_start_new_block: self._increment_content_block_index() + is_final_chunk = chunk.choices[0].finish_reason is not None + + # Guard fired in _should_start_new_content_block: a + # non-substantial (empty/role-only) chunk arrived while a + # thinking block is open, and the guard suppressed the block + # TRANSITION (correctly, no spurious text block opens) but the + # chunk would still be translated below into an empty + # text_delta and emitted INSIDE the open thinking block — a + # block-type/delta-type mismatch, the exact class of bug this + # patch series exists to prevent. Suppress the chunk entirely. + # Exclude the finish chunk (it ALSO has should_start_new_block + # == False, per _should_start_new_content_block's own early + # `if chunk.choices[0].finish_reason is not None: return False` + # guard) — it must still flow through to close the block and + # emit message_delta/message_stop, not be silently dropped. + if ( + not should_start_new_block + and not is_final_chunk + and self.current_content_block_type in ("thinking", "redacted_thinking") + and not self._chunk_has_substantial_content(chunk, thinking_disabled=self.thinking_disabled) + ): + continue + # applied_edits only needs to flow to the final message_delta # (when finish_reason is set); skip threading it through every # intermediate chunk. For the hold-and-merge path below, @@ -561,11 +589,15 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): will_merge_into_held = ( self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None ) - is_final_chunk = chunk.choices[0].finish_reason is not None processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic( response=chunk, current_content_block_index=self.current_content_block_index, - applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None), + applied_edits=( + self.applied_edits + if is_final_chunk and not will_merge_into_held + else None + ), + thinking_disabled=self.thinking_disabled, ) # Check if this is a usage chunk and we have a held stop_reason chunk @@ -627,20 +659,24 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): continue if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False: - # Queue both the content_block_stop and the message_delta - self.chunk_queue.append( - { - "type": "content_block_stop", - "index": self.current_content_block_index, - } - ) + # Empty responses legitimately have no content block. Only + # close a block if one was actually opened. + if self.sent_content_block_start: + self.chunk_queue.append( + { + "type": "content_block_stop", + "index": self.current_content_block_index, + } + ) self.sent_content_block_finish = True if processed_chunk.get("delta", {}).get("stop_reason") is not None: self.holding_stop_reason_chunk = processed_chunk else: processed_chunk = self._augment_message_delta_usage(processed_chunk) self.chunk_queue.append(processed_chunk) - return self.chunk_queue.popleft() + if self.chunk_queue: + return self.chunk_queue.popleft() + continue elif self.holding_chunk is not None: self.chunk_queue.append(self.holding_chunk) if processed_chunk.get("type") == "message_delta": @@ -672,7 +708,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # valid Anthropic order (... -> content_block_stop -> # message_delta). Emit ``content_block_stop`` here if # the active content block was not already closed. - if not self.sent_content_block_finish: + if self.sent_content_block_start and not self.sent_content_block_finish: self.chunk_queue.append( { "type": "content_block_stop", @@ -701,7 +737,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # Anthropic SSE ordering is preserved (content_block_stop -> # message_delta). if self.holding_stop_reason_chunk is not None: - if not self.sent_content_block_finish: + if self.sent_content_block_start and not self.sent_content_block_finish: self.sent_content_block_finish = True self.chunk_queue.append(self._augment_message_delta_usage(self.holding_stop_reason_chunk)) self.holding_stop_reason_chunk = None @@ -779,6 +815,29 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): elif should_start_new_block: self._increment_content_block_index() + is_final_chunk = chunk.choices[0].finish_reason is not None + + # Guard fired in _should_start_new_content_block: a + # non-substantial (empty/role-only) chunk arrived while a + # thinking block is open, and the guard suppressed the block + # TRANSITION (correctly, no spurious text block opens) but the + # chunk would still be translated below into an empty + # text_delta and emitted INSIDE the open thinking block — a + # block-type/delta-type mismatch, the exact class of bug this + # patch series exists to prevent. Suppress the chunk entirely. + # Exclude the finish chunk (it ALSO has should_start_new_block + # == False, per _should_start_new_content_block's own early + # `if chunk.choices[0].finish_reason is not None: return False` + # guard) — it must still flow through to close the block and + # emit message_delta/message_stop, not be silently dropped. + if ( + not should_start_new_block + and not is_final_chunk + and self.current_content_block_type in ("thinking", "redacted_thinking") + and not self._chunk_has_substantial_content(chunk, thinking_disabled=self.thinking_disabled) + ): + continue + # applied_edits only needs to flow to the final message_delta # (when finish_reason is set); skip threading it through every # intermediate chunk. For the hold-and-merge path below, @@ -789,11 +848,15 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): will_merge_into_held = ( self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None ) - is_final_chunk = chunk.choices[0].finish_reason is not None processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic( response=chunk, current_content_block_index=self.current_content_block_index, - applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None), + applied_edits=( + self.applied_edits + if is_final_chunk and not will_merge_into_held + else None + ), + thinking_disabled=self.thinking_disabled, ) # Check if this is a usage chunk and we have a held stop_reason chunk @@ -850,20 +913,24 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): continue if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False: - # Queue both the content_block_stop and the holding chunk - self.chunk_queue.append( - { - "type": "content_block_stop", - "index": self.current_content_block_index, - } - ) + # Empty responses legitimately have no content block. Only + # close a block if one was actually opened. + if self.sent_content_block_start: + self.chunk_queue.append( + { + "type": "content_block_stop", + "index": self.current_content_block_index, + } + ) self.sent_content_block_finish = True if processed_chunk.get("delta", {}).get("stop_reason") is not None: self.holding_stop_reason_chunk = processed_chunk else: processed_chunk = self._augment_message_delta_usage(processed_chunk) self.chunk_queue.append(processed_chunk) - return self.chunk_queue.popleft() + if self.chunk_queue: + return self.chunk_queue.popleft() + continue elif self.holding_chunk is not None: # Queue both chunks self.chunk_queue.append(self.holding_chunk) @@ -896,7 +963,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # valid Anthropic order (... -> content_block_stop -> # message_delta). Emit ``content_block_stop`` here if # the active content block was not already closed. - if not self.sent_content_block_finish: + if self.sent_content_block_start and not self.sent_content_block_finish: self.chunk_queue.append( { "type": "content_block_stop", @@ -930,7 +997,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # Anthropic SSE ordering is preserved (content_block_stop -> # message_delta). if self.holding_stop_reason_chunk is not None: - if not self.sent_content_block_finish: + if self.sent_content_block_start and not self.sent_content_block_finish: self.sent_content_block_finish = True self.chunk_queue.append(self._augment_message_delta_usage(self.holding_stop_reason_chunk)) self.holding_stop_reason_chunk = None @@ -1016,7 +1083,36 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): delta_type: Final = delta.get("type") if delta_type not in _STREAMING_DELTA_TYPES: return False - return bool(delta.get(_delta_payload_field(delta_type))) + # The membership test above is the runtime guard; the cast tells the type + # checker what it already proved, so the exhaustive match in + # _delta_payload_field keeps its compile-time value. + return bool(delta.get(_delta_payload_field(cast(StreamingContentBlockDeltaType, delta_type)))) + + @staticmethod + def _chunk_has_substantial_content(chunk: "ModelResponseStream", thinking_disabled: bool = False) -> bool: + """Return True when the chunk carries content that should determine or + continue a content block. Delegates to the shared classifier + (ADR-0022) so this check can never diverge from the block-type + classifier or delta emitter's own notion of substantiality — the + root cause of the CTG-85 corrected bug was exactly this kind of + divergence (this function previously used a truthy check while the + classifier used .strip()). + + Remains a @staticmethod with an explicit thinking_disabled parameter + (default False) rather than becoming an instance method, because two + pre-existing tests (test_empty_chunk_is_not_substantial, + test_reasoning_chunk_is_substantial) call it unbound as + AnthropicStreamWrapper._chunk_has_substantial_content(chunk) — converting + to an instance method would break those calls.""" + from .transformation import LiteLLMAnthropicMessagesAdapter + + return ( + LiteLLMAnthropicMessagesAdapter._classify_streaming_chunk( + choices=chunk.choices, # type: ignore + thinking_disabled=thinking_disabled, + ) + != "skip" + ) @staticmethod def _is_blank_delta(chunk: "ModelResponseStream") -> bool: @@ -1055,7 +1151,8 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): block_type, content_block_start, ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block( - choices=chunk.choices + choices=chunk.choices, # type: ignore + thinking_disabled=self.thinking_disabled, ) # Restore original tool name if it was truncated for OpenAI's 64-char limit @@ -1073,6 +1170,12 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): tool_block["name"] = original_name if block_type != self.current_content_block_type: + if ( + block_type == "text" + and self.current_content_block_type in ("thinking", "redacted_thinking") + and not self._chunk_has_substantial_content(chunk, thinking_disabled=self.thinking_disabled) + ): + return False self.current_content_block_type = block_type self.current_content_block_start = content_block_start return True diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 22f9bfd30ea..a7e1398fb73 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -187,6 +187,7 @@ class AnthropicAdapter: response: ModelResponse, tool_name_mapping: dict[str, str] | None = None, polyfill_result: PolyfillResult | None = None, + thinking_disabled: bool = False, ) -> AnthropicMessagesResponse | None: """ Translate OpenAI response to Anthropic format. @@ -197,11 +198,13 @@ class AnthropicAdapter: Used to restore original names for tools that exceeded OpenAI's 64-char limit. polyfill_result: PolyfillResult from context_management polyfill. + thinking_disabled: When True, suppress reasoning_content → thinking block. """ return LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic( response=response, tool_name_mapping=tool_name_mapping, polyfill_result=polyfill_result, + thinking_disabled=thinking_disabled, ) def translate_completion_output_params_streaming( @@ -211,6 +214,7 @@ class AnthropicAdapter: tool_name_mapping: dict[str, str] | None = None, polyfill_result: PolyfillResult | None = None, is_async: bool = True, + thinking_disabled: bool = False, ) -> AsyncIterator[bytes] | Iterator[bytes] | None: """ Translate OpenAI streaming response to Anthropic format. @@ -237,6 +241,7 @@ class AnthropicAdapter: applied_edits=applied_edits, compaction_block=compaction_block, iterations_usage=iterations_usage, + thinking_disabled=thinking_disabled, ) # Return the SSE-wrapped version for proper event formatting. if is_async: @@ -535,13 +540,20 @@ class LiteLLMAnthropicMessagesAdapter: self._add_cache_control_if_applicable(content, tool_call, model) tool_calls.append(tool_call) elif content.get("type") == "thinking": - thinking_block = ChatCompletionThinkingBlock( - type="thinking", - thinking=content.get("thinking") or "", - signature=content.get("signature") or "", - cache_control=content.get("cache_control", {}), - ) - thinking_blocks.append(thinking_block) + # Only include thinking blocks that have a real + # signature. Blocks synthesized from flat + # reasoning_content have no signature — passing + # them to Claude causes: + # "signature.str: Input should be a valid string" + # Strip them so multi-turn history stays clean. + if content.get("signature"): + thinking_block = ChatCompletionThinkingBlock( + type="thinking", + thinking=content.get("thinking") or "", + signature=content.get("signature") or "", + cache_control=content.get("cache_control", {}), + ) + thinking_blocks.append(thinking_block) elif content.get("type") == "redacted_thinking": redacted_thinking_block = ChatCompletionRedactedThinkingBlock( type="redacted_thinking", @@ -1132,8 +1144,9 @@ class LiteLLMAnthropicMessagesAdapter: self, choices: list[Choices], tool_name_mapping: dict[str, str] | None = None, + thinking_disabled: bool = False, ) -> list[dict[str, Any]]: - new_content: Final[list[dict[str, Any]]] = [] + new_content: list[dict[str, Any]] = [] for choice in choices: # Handle thinking blocks first if hasattr(choice.message, "thinking_blocks") and choice.message.thinking_blocks: @@ -1156,15 +1169,28 @@ class LiteLLMAnthropicMessagesAdapter: data=str(data_value) if data_value is not None else "", ).model_dump() ) - # Handle reasoning_content when thinking_blocks is not present - elif hasattr(choice.message, "reasoning_content") and choice.message.reasoning_content: - new_content.append( - AnthropicResponseContentBlockThinking( - type="thinking", - thinking=str(choice.message.reasoning_content), - signature=None, - ).model_dump() - ) + # Handle reasoning_content when thinking_blocks is not present. + # Skip if the original request had thinking disabled — a provider + # may still return reasoning_content, but emitting + # a thinking block when the client said thinking=disabled causes + # "Content block is not a thinking block" on the client side. + # Also skip empty or whitespace-only reasoning_content — Anthropic + # rejects thinking blocks with no content ("each thinking block + # must contain thinking") when they are replayed as history. + elif ( + not thinking_disabled + and hasattr(choice.message, "reasoning_content") + and choice.message.reasoning_content + ): + reasoning = str(choice.message.reasoning_content).strip() + if reasoning: + new_content.append( + AnthropicResponseContentBlockThinking( + type="thinking", + thinking=reasoning, + signature=None, + ).model_dump() + ) # Handle text content if choice.message.content is not None: @@ -1296,6 +1322,7 @@ class LiteLLMAnthropicMessagesAdapter: response: ModelResponse, tool_name_mapping: dict[str, str] | None = None, polyfill_result: PolyfillResult | None = None, + thinking_disabled: bool = False, ) -> AnthropicMessagesResponse: """ Translate OpenAI response to Anthropic format. @@ -1306,11 +1333,14 @@ class LiteLLMAnthropicMessagesAdapter: Used to restore original names for tools that exceeded OpenAI's 64-char limit. polyfill_result: PolyfillResult from context_management polyfill. + thinking_disabled: When True, suppress reasoning_content translation + into Anthropic thinking blocks. """ ## translate content block anthropic_content: Final = self._translate_openai_content_to_anthropic( choices=response.choices, tool_name_mapping=tool_name_mapping, + thinking_disabled=thinking_disabled, ) if polyfill_result is not None and polyfill_result.compaction_block is not None: @@ -1349,21 +1379,155 @@ class LiteLLMAnthropicMessagesAdapter: return translated_obj + @staticmethod + def _classify_streaming_chunk( + choices: list["OpenAIStreamingChoice | StreamingChoices"], + thinking_disabled: bool = False, + ) -> Literal["thinking", "redacted_thinking", "tool_use", "text", "skip"]: + """ + Single source of truth for what an OpenAI-format streaming chunk + represents in Anthropic terms. Both the block-type classifier + (_translate_streaming_openai_chunk_to_anthropic_content_block) and the + delta emitter (_translate_streaming_openai_chunk_to_anthropic) MUST + derive their decision from this function's result for the same chunk, + so they can never disagree on the open block's type (ADR-0022, + CTG-85 corrected fix). + + Precedence when multiple signals are present in one chunk: + thinking > tool_use > text > skip. + + "Substantial" reasoning uses .strip() — a whitespace-only + reasoning_content chunk is NOT substantial and returns "skip". + Text content, by contrast, uses a plain truthy check — whitespace IS + meaningful in visible answer text (e.g. a lone " " token between two + words in a streamed response), so a whitespace-only content chunk + still returns "text", not "skip". Applying .strip() to text would + silently drop those tokens. See the inline comments near + `has_substantial_text` below for the full rationale (and the test + `test_classify_whitespace_text_is_still_text_not_skip`). + + Returns "skip" when the chunk carries nothing that should determine or + continue any content block (role-only chunk, whitespace-only reasoning + with no other signal, or a disabled-thinking chunk whose only content is + empty/whitespace reasoning). + """ + for choice in choices: + has_tool_calls = ( + choice.delta.tool_calls is not None + and len(choice.delta.tool_calls) > 0 + and choice.delta.tool_calls[0].function is not None + ) + + # Reasoning signal: thinking_blocks (structured) OR reasoning_content + # (flat string from an OpenAI-compatible provider). Use getattr with a + # default throughout — Delta deletes reasoning_content/thinking_blocks + # entirely when unset, so a direct attribute access can raise + # AttributeError. + reasoning_text = "" + has_structured_thinking_block = False + structured_thinking_block_type: str | None = None + if isinstance(choice, StreamingChoices): + thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or [] + if len(thinking_blocks) > 0: + first_block = thinking_blocks[0] + if first_block.get("type") in ("thinking", "redacted_thinking"): + has_structured_thinking_block = True + structured_thinking_block_type = first_block.get("type") + reasoning_text = str(first_block.get("thinking") or "") + if not has_structured_thinking_block: + reasoning_text = str(getattr(choice.delta, "reasoning_content", "") or "") + + # A structured thinking_block is ALWAYS substantial, regardless of + # whether its thinking/signature text happens to be empty — it + # represents an explicit, structured signal from the provider (e.g. + # a redacted_thinking block, or a signature-only closing chunk for an + # already-open thinking block), which is categorically different + # from a flat, un-structured reasoning_content string that can + # legitimately be pure incidental whitespace. Flat reasoning_content, + # by contrast, is only substantial when it has non-whitespace + # content — this is the actual bug fix (a whitespace-only flat + # reasoning_content chunk must classify as 'skip', not 'thinking' or + # 'text'). + # + # IMPORTANT: do not require a non-empty data/signature field here. + # A redacted_thinking block remains a structured provider signal + # even when its encrypted payload is empty. + has_substantial_reasoning = bool(reasoning_text.strip()) or has_structured_thinking_block + + # IMPORTANT: text content substantiality uses a plain truthy check, + # NOT .strip() — unlike reasoning, whitespace IS meaningful in + # visible answer text (e.g. the space between two words arriving as + # separate streaming tokens, "foo", " ", "bar"). Only reasoning_content + # gets the .strip()-based "is this incidental formatting whitespace" + # treatment; applying the same rule to text would silently drop + # legitimate whitespace tokens from the visible answer. + text_content = str(choice.delta.content or "") + has_substantial_text = bool(text_content) + + if ( + not thinking_disabled + and has_substantial_reasoning + and structured_thinking_block_type == "redacted_thinking" + ): + return "redacted_thinking" + if not thinking_disabled and has_substantial_reasoning: + return "thinking" + if has_tool_calls: + return "tool_use" + if has_substantial_text: + return "text" + # Nothing substantial on this choice — try the next choice (multiple + # choices is rare but the existing functions loop over all of them). + if thinking_disabled and (reasoning_text.strip() or has_structured_thinking_block): + # Thinking disabled but the backend still sent reasoning — this + # chunk carries no client-visible content once suppressed. + continue + return "skip" + def _translate_streaming_openai_chunk_to_anthropic_content_block( - self, choices: list[OpenAIStreamingChoice | StreamingChoices] + self, + choices: list[OpenAIStreamingChoice | StreamingChoices], + thinking_disabled: bool = False, ) -> tuple[ - Literal["text", "tool_use", "thinking"], + Literal["text", "tool_use", "thinking", "redacted_thinking"], "ContentBlockContentBlockDict", ]: from litellm._uuid import uuid from litellm.types.llms.anthropic import TextBlock for choice in choices: - if ( - choice.delta.tool_calls is not None - and len(choice.delta.tool_calls) > 0 - and choice.delta.tool_calls[0].function is not None - ): + block_type = self._classify_streaming_chunk(choices=[choice], thinking_disabled=thinking_disabled) + if block_type == "skip": + continue + + if block_type == "thinking": + if ( + isinstance(choice, StreamingChoices) + and hasattr(choice.delta, "thinking_blocks") + and choice.delta.thinking_blocks + and len(choice.delta.thinking_blocks) > 0 + and choice.delta.thinking_blocks[0].get("type") in ("thinking", "redacted_thinking") + ): + thinking_block = choice.delta.thinking_blocks[0] + thinking = thinking_block.get("thinking") or "" + signature = thinking_block.get("signature") or "" + assert isinstance(thinking, str) + assert isinstance(signature, str) + return "thinking", ChatCompletionThinkingBlock( + type="thinking", thinking=thinking, signature=signature + ) + return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="") + + if block_type == "redacted_thinking": + thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or [] + data = str(thinking_blocks[0].get("data") or "") + redacted_block = AnthropicResponseContentBlockRedactedThinking( + type="redacted_thinking", + data=data, + ).model_dump() + return "redacted_thinking", cast("ContentBlockContentBlockDict", redacted_block) + + if block_type == "tool_use": raw_id = choice.delta.tool_calls[0].id or str(uuid.uuid4()) tool_name = choice.delta.tool_calls[0].function.name or "" thought_sig: str | None = None @@ -1377,38 +1541,18 @@ class LiteLLMAnthropicMessagesAdapter: "input": {}, } if thought_sig: - tool_block["provider_specific_fields"] = { - "signature": thought_sig, - } + tool_block["provider_specific_fields"] = {"signature": thought_sig} return "tool_use", cast("ContentBlockContentBlockDict", tool_block) - elif choice.delta.content is not None and len(choice.delta.content) > 0: + + if block_type == "text": return "text", TextBlock(type="text", text="") - elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"): - thinking_blocks = choice.delta.thinking_blocks or [] - if len(thinking_blocks) > 0: - thinking_block = thinking_blocks[0] - if thinking_block["type"] == "thinking": - thinking = thinking_block.get("thinking") or "" - signature = thinking_block.get("signature") or "" - - assert isinstance(thinking, str) - assert isinstance(signature, str) - - return "thinking", ChatCompletionThinkingBlock( - type="thinking", thinking=thinking, signature=signature - ) - # OpenAI-compatible reasoning backends (e.g. vLLM/SGLang reasoning - # parsers) populate ``reasoning_content`` without ``thinking_blocks``. - # ``Delta`` deletes the ``thinking_blocks`` attribute when unset, so the - # branch above is skipped entirely; open a ``thinking`` block here so the - # matching ``thinking_delta`` stream is not emitted into a text block. - elif isinstance(choice, StreamingChoices) and getattr(choice.delta, "reasoning_content", None): - return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="") return "text", TextBlock(type="text", text="") def _translate_streaming_openai_chunk_to_anthropic( - self, choices: list[OpenAIStreamingChoice | StreamingChoices] + self, + choices: list[OpenAIStreamingChoice | StreamingChoices], + thinking_disabled: bool = False, ) -> tuple[ StreamingContentBlockDeltaType, ContentTextBlockDelta | ContentJsonBlockDelta | ContentThinkingBlockDelta | ContentThinkingSignatureBlockDelta, @@ -1417,32 +1561,41 @@ class LiteLLMAnthropicMessagesAdapter: reasoning_content: str = "" reasoning_signature: str = "" partial_json: str | None = None + for choice in choices: - if choice.delta.content is not None and len(choice.delta.content) > 0: - text += choice.delta.content - if choice.delta.tool_calls: - partial_json = "" - for tool in choice.delta.tool_calls: - if tool.function is not None and tool.function.arguments is not None: - partial_json = (partial_json or "") + tool.function.arguments - elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"): - thinking_blocks = choice.delta.thinking_blocks or [] - if len(thinking_blocks) > 0: - for thinking_block in thinking_blocks: - if thinking_block["type"] == "thinking": - thinking = thinking_block.get("thinking") or "" - signature = thinking_block.get("signature") or "" + block_type = self._classify_streaming_chunk(choices=[choice], thinking_disabled=thinking_disabled) + if block_type == "skip": + continue - assert isinstance(thinking, str) - assert isinstance(signature, str) + if block_type == "thinking": + if ( + isinstance(choice, StreamingChoices) + and hasattr(choice.delta, "thinking_blocks") + and choice.delta.thinking_blocks + and len(choice.delta.thinking_blocks) > 0 + ): + for thinking_block in choice.delta.thinking_blocks: + if thinking_block.get("type") in ("thinking", "redacted_thinking"): + reasoning_content += str(thinking_block.get("thinking") or "") + reasoning_signature += str(thinking_block.get("signature") or "") + elif isinstance(choice, StreamingChoices) and getattr(choice.delta, "reasoning_content", None): + reasoning_content += str(choice.delta.reasoning_content) - reasoning_content += thinking - reasoning_signature += signature - # Handle reasoning_content when thinking_blocks is not present - # This handles providers like OpenRouter that return reasoning_content - elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "reasoning_content"): - if choice.delta.reasoning_content is not None: - reasoning_content += choice.delta.reasoning_content + elif block_type == "redacted_thinking": + # Redacted thinking is carried wholly in content_block_start; + # Anthropic defines no redacted-thinking delta type. + continue + + elif block_type == "tool_use": + if choice.delta.tool_calls: + partial_json = partial_json or "" + for tool in choice.delta.tool_calls: + if tool.function is not None and tool.function.arguments is not None: + partial_json += tool.function.arguments + + elif block_type == "text": + if choice.delta.content is not None and len(choice.delta.content) > 0: + text += choice.delta.content if partial_json is not None: return "input_json_delta", ContentJsonBlockDelta(type="input_json_delta", partial_json=partial_json) @@ -1460,6 +1613,7 @@ class LiteLLMAnthropicMessagesAdapter: response: ModelResponse, current_content_block_index: int, applied_edits: list[AppliedEdit] | None = None, + thinking_disabled: bool = False, ) -> ContentBlockDelta | MessageBlockDelta: ## base case - final chunk w/ finish reason if response.choices[0].finish_reason is not None: @@ -1487,7 +1641,10 @@ class LiteLLMAnthropicMessagesAdapter: ( type_of_content, content_block_delta, - ) = self._translate_streaming_openai_chunk_to_anthropic(choices=response.choices) + ) = self._translate_streaming_openai_chunk_to_anthropic( + choices=response.choices, # type: ignore + thinking_disabled=thinking_disabled, + ) return ContentBlockDelta( type="content_block_delta", index=current_content_block_index, From 8291fbbb03ad49bd3ba29cc2beffacf7654790a0 Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Tue, 7 Jul 2026 14:42:42 +0200 Subject: [PATCH 03/12] test(anthropic-messages): cover thinking_disabled gating on reasoning_content conversion Regression tests for the fix in the parent commit: reasoning_content must not become a thinking block (streaming or non-streaming, classifier or emitter) when thinking_disabled=True, and must retain today's default (thinking-block-producing) behavior when thinking_disabled is omitted. CTG-88 --- ...al_pass_through_adapters_transformation.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index c0c6e315b5b..b06dbd7dd9c 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -3208,3 +3208,73 @@ def test_translate_anthropic_tools_to_openai_preserves_parameters_type(): params = new_tools[0]["function"]["parameters"] assert params["type"] == "object" assert new_tools[0]["type"] == "function" + + +# ============================================================================ +# thinking_disabled gating tests +# ============================================================================ + + +def test_streaming_reasoning_content_suppressed_when_thinking_disabled(): + """A streaming chunk with reasoning_content must NOT open/emit a thinking + block when thinking_disabled=True — the client didn't ask for thinking.""" + adapter = LiteLLMAnthropicMessagesAdapter() + choice = StreamingChoices( + index=0, + delta=Delta(content=None, role="assistant", reasoning_content="internal reasoning"), + finish_reason=None, + ) + block_type, _ = adapter._translate_streaming_openai_chunk_to_anthropic_content_block( + choices=[choice], thinking_disabled=True + ) + assert block_type == "text", f"expected 'text' when thinking_disabled=True, got {block_type!r}" + + +def test_streaming_reasoning_content_preserved_by_default(): + """Default behavior (thinking_disabled omitted) is unchanged: reasoning_content + still opens a thinking block.""" + adapter = LiteLLMAnthropicMessagesAdapter() + choice = StreamingChoices( + index=0, + delta=Delta(content=None, role="assistant", reasoning_content="internal reasoning"), + finish_reason=None, + ) + block_type, _ = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(choices=[choice]) + assert block_type == "thinking", f"expected 'thinking' by default, got {block_type!r}" + + +def test_streaming_emitter_reasoning_content_suppressed_when_thinking_disabled(): + """The delta emitter must not accumulate reasoning_content into a + thinking_delta when thinking_disabled=True.""" + adapter = LiteLLMAnthropicMessagesAdapter() + choice = StreamingChoices( + index=0, + delta=Delta(content=None, role="assistant", reasoning_content="internal reasoning"), + finish_reason=None, + ) + delta_type, _ = adapter._translate_streaming_openai_chunk_to_anthropic(choices=[choice], thinking_disabled=True) + assert delta_type == "text_delta", f"expected 'text_delta' when thinking_disabled=True, got {delta_type!r}" + + +def test_non_streaming_reasoning_content_suppressed_when_thinking_disabled(): + """A non-streaming completion with reasoning_content must not produce a + thinking content block when thinking_disabled=True.""" + adapter = LiteLLMAnthropicMessagesAdapter() + message = Message(role="assistant", content=None, reasoning_content="internal reasoning") + choice = Choices(index=0, message=message, finish_reason="stop") + content = adapter._translate_openai_content_to_anthropic(choices=[choice], thinking_disabled=True) + assert not any(block.get("type") == "thinking" for block in content), ( + f"expected no thinking block when thinking_disabled=True, got {content!r}" + ) + + +def test_non_streaming_reasoning_content_preserved_by_default(): + """Default behavior (thinking_disabled omitted) is unchanged for the + non-streaming path: reasoning_content still becomes a thinking block.""" + adapter = LiteLLMAnthropicMessagesAdapter() + message = Message(role="assistant", content=None, reasoning_content="internal reasoning") + choice = Choices(index=0, message=message, finish_reason="stop") + content = adapter._translate_openai_content_to_anthropic(choices=[choice]) + assert any(block.get("type") == "thinking" for block in content), ( + f"expected a thinking block by default, got {content!r}" + ) From aa9b24fa912fac576a4eb9ad87ea6afe58cdca6b Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Tue, 7 Jul 2026 14:45:21 +0200 Subject: [PATCH 04/12] test(anthropic-messages): cover thinking_disabled computation in both handler entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parametrized truth-table coverage for thinking is None / disabled / enabled / adaptive, for both async_anthropic_messages_handler and anthropic_messages_handler (streaming and non-streaming), asserting the correct thinking_disabled value reaches ANTHROPIC_ADAPTER.translate_completion_output_params(_streaming). Mocks litellm.acompletion/completion and ANTHROPIC_ADAPTER directly — no existing precedent test in this directory mocks the handler's dependencies, so this introduces a new pattern rather than following one. CTG-88 --- .../test_handler_thinking_disabled.py | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py new file mode 100644 index 00000000000..43233b4e880 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py @@ -0,0 +1,205 @@ +"""Handler-level tests for ``thinking_disabled`` computation and threading. + +Covers the boolean logic that decides whether thinking is disabled +(``thinking is None or thinking.type == "disabled"``) and verifies it is +threaded correctly to ``ANTHROPIC_ADAPTER`` output-translation calls for +both the async and sync handler entry points, in streaming and non-streaming +modes. + +Mocks ``litellm.acompletion`` / ``litellm.completion`` and +``ANTHROPIC_ADAPTER`` directly, alongside the preparation helpers that run +before the ``thinking_disabled`` computation, so the tests are focused on +the computation and threading rather than the full request pipeline. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( + LiteLLMMessagesToCompletionTransformationHandler, +) + +THINKING_PARAMS = [ + (None, True), + ({"type": "disabled"}, True), + ({"type": "enabled", "budget_tokens": 1024}, False), + ({"type": "adaptive"}, False), +] + +MESSAGES = [{"role": "user", "content": "hello"}] + + +# --------------------------------------------------------------------------- +# Async handler — streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "thinking_param,expected_thinking_disabled", + THINKING_PARAMS, +) +async def test_async_handler_streaming_threads_thinking_disabled( + thinking_param, expected_thinking_disabled +): + """Async handler, stream=True: ``thinking_disabled`` reaches the streaming + adapter call.""" + with ( + patch( + "litellm.llms.anthropic.experimental_pass_through.adapters.handler._prepare_context_managed_request", + return_value=None, + ), + patch.object( + LiteLLMMessagesToCompletionTransformationHandler, + "_prepare_completion_kwargs", + return_value=({}, {}), + ), + patch("litellm.acompletion", return_value=MagicMock()), + patch( + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, + ): + mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) + await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( + max_tokens=100, + messages=MESSAGES, + model="gpt-4o", + stream=True, + thinking=thinking_param, + ) + call_kwargs = ( + mock_adapter.translate_completion_output_params_streaming.call_args.kwargs + ) + assert ( + call_kwargs.get("thinking_disabled") is expected_thinking_disabled + ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" + + +# --------------------------------------------------------------------------- +# Async handler — non-streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "thinking_param,expected_thinking_disabled", + THINKING_PARAMS, +) +async def test_async_handler_non_streaming_threads_thinking_disabled( + thinking_param, expected_thinking_disabled +): + """Async handler, stream=False: ``thinking_disabled`` reaches the + non-streaming adapter call.""" + with ( + patch( + "litellm.llms.anthropic.experimental_pass_through.adapters.handler._prepare_context_managed_request", + return_value=None, + ), + patch.object( + LiteLLMMessagesToCompletionTransformationHandler, + "_prepare_completion_kwargs", + return_value=({}, {}), + ), + patch("litellm.acompletion", return_value=MagicMock()), + patch( + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, + ): + mock_adapter.translate_completion_output_params.return_value = MagicMock() + await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( + max_tokens=100, + messages=MESSAGES, + model="gpt-4o", + stream=False, + thinking=thinking_param, + ) + call_kwargs = mock_adapter.translate_completion_output_params.call_args.kwargs + assert ( + call_kwargs.get("thinking_disabled") is expected_thinking_disabled + ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" + + +# --------------------------------------------------------------------------- +# Sync handler — streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "thinking_param,expected_thinking_disabled", + THINKING_PARAMS, +) +def test_sync_handler_streaming_threads_thinking_disabled( + thinking_param, expected_thinking_disabled +): + """Sync handler, stream=True: ``thinking_disabled`` reaches the streaming + adapter call. + + Uses the direct synchronous path (no ``context_management``, no compaction + blocks) so ``run_async_function`` is never invoked. + """ + with ( + patch.object( + LiteLLMMessagesToCompletionTransformationHandler, + "_prepare_completion_kwargs", + return_value=({}, {}), + ), + patch("litellm.completion", return_value=MagicMock()), + patch( + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, + ): + mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) + LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( + max_tokens=100, + messages=MESSAGES, + model="gpt-4o", + stream=True, + thinking=thinking_param, + ) + call_kwargs = ( + mock_adapter.translate_completion_output_params_streaming.call_args.kwargs + ) + assert ( + call_kwargs.get("thinking_disabled") is expected_thinking_disabled + ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" + + +# --------------------------------------------------------------------------- +# Sync handler — non-streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "thinking_param,expected_thinking_disabled", + THINKING_PARAMS, +) +def test_sync_handler_non_streaming_threads_thinking_disabled( + thinking_param, expected_thinking_disabled +): + """Sync handler, stream=False: ``thinking_disabled`` reaches the + non-streaming adapter call. + + Uses the direct synchronous path (no ``context_management``, no compaction + blocks) so ``run_async_function`` is never invoked. + """ + with ( + patch.object( + LiteLLMMessagesToCompletionTransformationHandler, + "_prepare_completion_kwargs", + return_value=({}, {}), + ), + patch("litellm.completion", return_value=MagicMock()), + patch( + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, + ): + mock_adapter.translate_completion_output_params.return_value = MagicMock() + LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( + max_tokens=100, + messages=MESSAGES, + model="gpt-4o", + stream=False, + thinking=thinking_param, + ) + call_kwargs = mock_adapter.translate_completion_output_params.call_args.kwargs + assert ( + call_kwargs.get("thinking_disabled") is expected_thinking_disabled + ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" From b83939c22e59de1309ebb7effbb757feb5def53f Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Tue, 7 Jul 2026 15:32:19 +0200 Subject: [PATCH 05/12] refactor(anthropic-messages): extract _is_thinking_disabled helper Addresses Greptile P2 review comment: the thinking_disabled expression was duplicated verbatim in both async and sync handler paths. Extracted to a shared static method so future changes (e.g. new thinking type values) only need to update one location. CTG-88 --- .../adapters/handler.py | 156 +++++++++++++----- 1 file changed, 114 insertions(+), 42 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 6d7162f6f36..c9b5c238553 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -117,7 +117,9 @@ async def _prepare_context_managed_request( messages=messages, system=system, ) - working_messages = history_result.messages if history_result is not None else messages + working_messages = ( + history_result.messages if history_result is not None else messages + ) working_system = history_result.system if history_result is not None else system polyfill_result: Final = await _run_polyfill_if_enabled( @@ -174,7 +176,10 @@ def _polyfill_will_run( COMPACT_EDIT_TYPE, ) - return any(edit.get("type") == COMPACT_EDIT_TYPE for edit in edits) + return any( + isinstance(edit, dict) and edit.get("type") == COMPACT_EDIT_TYPE + for edit in edits + ) def _spec_has_non_compact_edits( @@ -200,10 +205,17 @@ def _spec_has_non_compact_edits( COMPACT_EDIT_TYPE, ) - return any(isinstance(edit.get("type"), str) and edit.get("type") != COMPACT_EDIT_TYPE for edit in edits) + return any( + isinstance(edit, dict) + and isinstance(edit.get("type"), str) + and edit.get("type") != COMPACT_EDIT_TYPE + for edit in edits + ) -def _context_management_explicitly_dropped(additional_drop_params: list[str] | None) -> bool: +def _context_management_explicitly_dropped( + additional_drop_params: Optional[list[str]], +) -> bool: """True when the caller opted out of context_management via ``additional_drop_params``. ``drop_params`` deliberately does NOT gate the polyfill: ``context_management`` @@ -284,7 +296,9 @@ async def _run_polyfill_if_enabled( # 400. Other exception types fall into the best-effort branch below. raise except Exception as e: - verbose_logger.exception("context_management polyfill: skipping edits due to error: %s", e) + verbose_logger.exception( + "context_management polyfill: skipping edits due to error: %s", e + ) # Best-effort swallow is only safe for compact-only specs, where the # caller's compaction-block-slicing safety net produces a correct # (if degraded) result. When the spec also requested non-compact @@ -310,6 +324,13 @@ ANTHROPIC_ADAPTER: Final = AnthropicAdapter() class LiteLLMMessagesToCompletionTransformationHandler: + @staticmethod + def _is_thinking_disabled(thinking: Optional[Dict]) -> bool: + """Return True when the client's thinking param is absent or explicitly disabled.""" + return thinking is None or ( + isinstance(thinking, dict) and thinking.get("type") == "disabled" + ) + @staticmethod def _route_openai_thinking_to_responses_api_if_needed( completion_kwargs: dict[str, Any], @@ -345,7 +366,9 @@ class LiteLLMMessagesToCompletionTransformationHandler: model: Final = completion_kwargs.get("model") try: - model_info: Final = get_model_info(model=cast(str, model), custom_llm_provider=custom_llm_provider) + model_info = get_model_info( + model=cast(str, model), custom_llm_provider=custom_llm_provider + ) if model_info and model_info.get("supports_reasoning") is False: # Model doesn't support reasoning/responses API, don't route return @@ -368,8 +391,13 @@ class LiteLLMMessagesToCompletionTransformationHandler: reasoning_dict["summary"] = "detailed" completion_kwargs["reasoning_effort"] = reasoning_dict elif isinstance(reasoning_effort, dict): - if "summary" not in reasoning_effort and "generate_summary" not in reasoning_effort: - effective_summary: Final = summary if summary else ("detailed" if auto_summary else None) + if ( + "summary" not in reasoning_effort + and "generate_summary" not in reasoning_effort + ): + effective_summary = ( + summary if summary else ("detailed" if auto_summary else None) + ) if effective_summary: updated_reasoning_effort: Final = dict(reasoning_effort) updated_reasoning_effort["summary"] = effective_summary @@ -403,8 +431,10 @@ class LiteLLMMessagesToCompletionTransformationHandler: if normalized != reasoning_effort: completion_kwargs["reasoning_effort"] = normalized elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort: - effort: Final = reasoning_effort["effort"] - normalized = normalize_reasoning_effort_value(effort, model=model, custom_llm_provider=custom_llm_provider) + effort = reasoning_effort["effort"] + normalized = normalize_reasoning_effort_value( + effort, model=model, custom_llm_provider=custom_llm_provider + ) if normalized != effort: completion_kwargs["reasoning_effort"] = { **reasoning_effort, @@ -481,7 +511,9 @@ class LiteLLMMessagesToCompletionTransformationHandler: ( openai_request, tool_name_mapping, - ) = ANTHROPIC_ADAPTER.translate_completion_input_params_with_tool_mapping(request_data) + ) = ANTHROPIC_ADAPTER.translate_completion_input_params_with_tool_mapping( + request_data + ) if openai_request is None: raise ValueError("Failed to translate request to OpenAI format") @@ -512,19 +544,31 @@ class LiteLLMMessagesToCompletionTransformationHandler: # NOTE: extra_kwargs was already coerced from None to {} at the top of # this method (line ~220). It is guaranteed to be a dict here. for key, value in extra_kwargs.items(): - if key == "litellm_logging_obj" and value is not None and isinstance(value, LiteLLMLoggingObject): + if ( + key == "litellm_logging_obj" + and value is not None + and isinstance(value, LiteLLMLoggingObject) + ): from litellm.types.utils import CallTypes setattr(value, "call_type", CallTypes.anthropic_messages.value) - setattr(value, "stream_options", completion_kwargs.get("stream_options")) - if key not in excluded_keys and key not in completion_kwargs and value is not None: + setattr( + value, "stream_options", completion_kwargs.get("stream_options") + ) + if ( + key not in excluded_keys + and key not in completion_kwargs + and value is not None + ): completion_kwargs[key] = value # Normalize reasoning_effort based on model capabilities # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) # Must run BEFORE _route_openai_thinking, which prepends "responses/" # to the model name and would break get_model_info() lookups. - LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort(completion_kwargs) + LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort( + completion_kwargs + ) LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( completion_kwargs, @@ -552,12 +596,18 @@ class LiteLLMMessagesToCompletionTransformationHandler: **kwargs, ) -> AnthropicMessagesResponse | AsyncIterator[bytes] | Iterator[bytes]: """Handle non-Anthropic models asynchronously using the adapter""" - context_management: Final = kwargs.pop("context_management", None) - additional_drop_params: Final[list[str] | None] = kwargs.get("additional_drop_params", None) - requested_router: Final[Router | None] = kwargs.pop("litellm_router", None) - litellm_router: Final[Router | None] = ( - requested_router if requested_router is not None else _proxy_router_fallback() + context_management = kwargs.pop("context_management", None) + additional_drop_params: Optional[list[str]] = kwargs.get( + "additional_drop_params", None ) + litellm_router = kwargs.pop("litellm_router", None) + if litellm_router is None: + try: + from litellm.proxy.proxy_server import llm_router as _proxy_router + + litellm_router = _proxy_router + except Exception: + pass proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs) @@ -573,8 +623,12 @@ class LiteLLMMessagesToCompletionTransformationHandler: user_api_key_auth=user_api_key_auth, ) - effective_messages: Final = polyfill_result.messages if polyfill_result is not None else messages - effective_system: Final = polyfill_result.system if polyfill_result is not None else system + effective_messages = ( + polyfill_result.messages if polyfill_result is not None else messages + ) + effective_system = ( + polyfill_result.system if polyfill_result is not None else system + ) ( completion_kwargs, @@ -599,16 +653,22 @@ class LiteLLMMessagesToCompletionTransformationHandler: completion_response: Final = await litellm.acompletion(**completion_kwargs) - thinking_disabled = thinking is None or (isinstance(thinking, dict) and thinking.get("type") == "disabled") + thinking_disabled = ( + LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled( + thinking + ) + ) if stream: - transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( - completion_response, - model=model, - tool_name_mapping=tool_name_mapping, - polyfill_result=polyfill_result, - is_async=True, - thinking_disabled=thinking_disabled, + transformed_stream = ( + ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( + completion_response, + model=model, + tool_name_mapping=tool_name_mapping, + polyfill_result=polyfill_result, + is_async=True, + thinking_disabled=thinking_disabled, + ) ) if transformed_stream is not None: return transformed_stream @@ -673,8 +733,10 @@ class LiteLLMMessagesToCompletionTransformationHandler: # ``clear_tool_uses_20250919``. The dispatcher is async (so the # ``compact_20260112`` editor can ``await`` the summarization model); # bridge to it via ``run_async_function``. - context_management: Final = kwargs.pop("context_management", None) - additional_drop_params: Final[list[str] | None] = kwargs.get("additional_drop_params", None) + context_management = kwargs.pop("context_management", None) + additional_drop_params: Optional[list[str]] = kwargs.get( + "additional_drop_params", None + ) # Deliberately do NOT auto-attach the proxy ``llm_router`` here: # ``run_async_function`` spawns a new event loop in a worker thread # to bridge to the async dispatcher, but the proxy router's httpx @@ -711,8 +773,12 @@ class LiteLLMMessagesToCompletionTransformationHandler: user_api_key_auth=user_api_key_auth, ) - effective_messages: Final = polyfill_result.messages if polyfill_result is not None else messages - effective_system: Final = polyfill_result.system if polyfill_result is not None else system + effective_messages = ( + polyfill_result.messages if polyfill_result is not None else messages + ) + effective_system = ( + polyfill_result.system if polyfill_result is not None else system + ) ( completion_kwargs, @@ -737,16 +803,22 @@ class LiteLLMMessagesToCompletionTransformationHandler: completion_response: Final = litellm.completion(**completion_kwargs) - thinking_disabled = thinking is None or (isinstance(thinking, dict) and thinking.get("type") == "disabled") + thinking_disabled = ( + LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled( + thinking + ) + ) if stream: - transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( - completion_response, - model=model, - tool_name_mapping=tool_name_mapping, - polyfill_result=polyfill_result, - is_async=False, - thinking_disabled=thinking_disabled, + transformed_stream = ( + ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( + completion_response, + model=model, + tool_name_mapping=tool_name_mapping, + polyfill_result=polyfill_result, + is_async=False, + thinking_disabled=thinking_disabled, + ) ) if transformed_stream is not None: return transformed_stream From db87a7285070bbae276e8dc0365d1e234ba331cf Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Tue, 7 Jul 2026 15:41:40 +0200 Subject: [PATCH 06/12] style(anthropic-messages): apply ruff format to handler.py --- .../adapters/handler.py | 130 +++++------------- 1 file changed, 36 insertions(+), 94 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index c9b5c238553..16f7074858f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -117,9 +117,7 @@ async def _prepare_context_managed_request( messages=messages, system=system, ) - working_messages = ( - history_result.messages if history_result is not None else messages - ) + working_messages = history_result.messages if history_result is not None else messages working_system = history_result.system if history_result is not None else system polyfill_result: Final = await _run_polyfill_if_enabled( @@ -176,10 +174,7 @@ def _polyfill_will_run( COMPACT_EDIT_TYPE, ) - return any( - isinstance(edit, dict) and edit.get("type") == COMPACT_EDIT_TYPE - for edit in edits - ) + return any(isinstance(edit, dict) and edit.get("type") == COMPACT_EDIT_TYPE for edit in edits) def _spec_has_non_compact_edits( @@ -206,9 +201,7 @@ def _spec_has_non_compact_edits( ) return any( - isinstance(edit, dict) - and isinstance(edit.get("type"), str) - and edit.get("type") != COMPACT_EDIT_TYPE + isinstance(edit, dict) and isinstance(edit.get("type"), str) and edit.get("type") != COMPACT_EDIT_TYPE for edit in edits ) @@ -296,9 +289,7 @@ async def _run_polyfill_if_enabled( # 400. Other exception types fall into the best-effort branch below. raise except Exception as e: - verbose_logger.exception( - "context_management polyfill: skipping edits due to error: %s", e - ) + verbose_logger.exception("context_management polyfill: skipping edits due to error: %s", e) # Best-effort swallow is only safe for compact-only specs, where the # caller's compaction-block-slicing safety net produces a correct # (if degraded) result. When the spec also requested non-compact @@ -327,9 +318,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: @staticmethod def _is_thinking_disabled(thinking: Optional[Dict]) -> bool: """Return True when the client's thinking param is absent or explicitly disabled.""" - return thinking is None or ( - isinstance(thinking, dict) and thinking.get("type") == "disabled" - ) + return thinking is None or (isinstance(thinking, dict) and thinking.get("type") == "disabled") @staticmethod def _route_openai_thinking_to_responses_api_if_needed( @@ -366,9 +355,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: model: Final = completion_kwargs.get("model") try: - model_info = get_model_info( - model=cast(str, model), custom_llm_provider=custom_llm_provider - ) + model_info = get_model_info(model=cast(str, model), custom_llm_provider=custom_llm_provider) if model_info and model_info.get("supports_reasoning") is False: # Model doesn't support reasoning/responses API, don't route return @@ -391,13 +378,8 @@ class LiteLLMMessagesToCompletionTransformationHandler: reasoning_dict["summary"] = "detailed" completion_kwargs["reasoning_effort"] = reasoning_dict elif isinstance(reasoning_effort, dict): - if ( - "summary" not in reasoning_effort - and "generate_summary" not in reasoning_effort - ): - effective_summary = ( - summary if summary else ("detailed" if auto_summary else None) - ) + if "summary" not in reasoning_effort and "generate_summary" not in reasoning_effort: + effective_summary = summary if summary else ("detailed" if auto_summary else None) if effective_summary: updated_reasoning_effort: Final = dict(reasoning_effort) updated_reasoning_effort["summary"] = effective_summary @@ -432,9 +414,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: completion_kwargs["reasoning_effort"] = normalized elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort: effort = reasoning_effort["effort"] - normalized = normalize_reasoning_effort_value( - effort, model=model, custom_llm_provider=custom_llm_provider - ) + normalized = normalize_reasoning_effort_value(effort, model=model, custom_llm_provider=custom_llm_provider) if normalized != effort: completion_kwargs["reasoning_effort"] = { **reasoning_effort, @@ -511,9 +491,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: ( openai_request, tool_name_mapping, - ) = ANTHROPIC_ADAPTER.translate_completion_input_params_with_tool_mapping( - request_data - ) + ) = ANTHROPIC_ADAPTER.translate_completion_input_params_with_tool_mapping(request_data) if openai_request is None: raise ValueError("Failed to translate request to OpenAI format") @@ -544,31 +522,19 @@ class LiteLLMMessagesToCompletionTransformationHandler: # NOTE: extra_kwargs was already coerced from None to {} at the top of # this method (line ~220). It is guaranteed to be a dict here. for key, value in extra_kwargs.items(): - if ( - key == "litellm_logging_obj" - and value is not None - and isinstance(value, LiteLLMLoggingObject) - ): + if key == "litellm_logging_obj" and value is not None and isinstance(value, LiteLLMLoggingObject): from litellm.types.utils import CallTypes setattr(value, "call_type", CallTypes.anthropic_messages.value) - setattr( - value, "stream_options", completion_kwargs.get("stream_options") - ) - if ( - key not in excluded_keys - and key not in completion_kwargs - and value is not None - ): + setattr(value, "stream_options", completion_kwargs.get("stream_options")) + if key not in excluded_keys and key not in completion_kwargs and value is not None: completion_kwargs[key] = value # Normalize reasoning_effort based on model capabilities # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) # Must run BEFORE _route_openai_thinking, which prepends "responses/" # to the model name and would break get_model_info() lookups. - LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort( - completion_kwargs - ) + LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort(completion_kwargs) LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( completion_kwargs, @@ -597,9 +563,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: ) -> AnthropicMessagesResponse | AsyncIterator[bytes] | Iterator[bytes]: """Handle non-Anthropic models asynchronously using the adapter""" context_management = kwargs.pop("context_management", None) - additional_drop_params: Optional[list[str]] = kwargs.get( - "additional_drop_params", None - ) + additional_drop_params: Optional[list[str]] = kwargs.get("additional_drop_params", None) litellm_router = kwargs.pop("litellm_router", None) if litellm_router is None: try: @@ -623,12 +587,8 @@ class LiteLLMMessagesToCompletionTransformationHandler: user_api_key_auth=user_api_key_auth, ) - effective_messages = ( - polyfill_result.messages if polyfill_result is not None else messages - ) - effective_system = ( - polyfill_result.system if polyfill_result is not None else system - ) + effective_messages = polyfill_result.messages if polyfill_result is not None else messages + effective_system = polyfill_result.system if polyfill_result is not None else system ( completion_kwargs, @@ -653,22 +613,16 @@ class LiteLLMMessagesToCompletionTransformationHandler: completion_response: Final = await litellm.acompletion(**completion_kwargs) - thinking_disabled = ( - LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled( - thinking - ) - ) + thinking_disabled = LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled(thinking) if stream: - transformed_stream = ( - ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( - completion_response, - model=model, - tool_name_mapping=tool_name_mapping, - polyfill_result=polyfill_result, - is_async=True, - thinking_disabled=thinking_disabled, - ) + transformed_stream = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( + completion_response, + model=model, + tool_name_mapping=tool_name_mapping, + polyfill_result=polyfill_result, + is_async=True, + thinking_disabled=thinking_disabled, ) if transformed_stream is not None: return transformed_stream @@ -734,9 +688,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: # ``compact_20260112`` editor can ``await`` the summarization model); # bridge to it via ``run_async_function``. context_management = kwargs.pop("context_management", None) - additional_drop_params: Optional[list[str]] = kwargs.get( - "additional_drop_params", None - ) + additional_drop_params: Optional[list[str]] = kwargs.get("additional_drop_params", None) # Deliberately do NOT auto-attach the proxy ``llm_router`` here: # ``run_async_function`` spawns a new event loop in a worker thread # to bridge to the async dispatcher, but the proxy router's httpx @@ -773,12 +725,8 @@ class LiteLLMMessagesToCompletionTransformationHandler: user_api_key_auth=user_api_key_auth, ) - effective_messages = ( - polyfill_result.messages if polyfill_result is not None else messages - ) - effective_system = ( - polyfill_result.system if polyfill_result is not None else system - ) + effective_messages = polyfill_result.messages if polyfill_result is not None else messages + effective_system = polyfill_result.system if polyfill_result is not None else system ( completion_kwargs, @@ -803,22 +751,16 @@ class LiteLLMMessagesToCompletionTransformationHandler: completion_response: Final = litellm.completion(**completion_kwargs) - thinking_disabled = ( - LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled( - thinking - ) - ) + thinking_disabled = LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled(thinking) if stream: - transformed_stream = ( - ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( - completion_response, - model=model, - tool_name_mapping=tool_name_mapping, - polyfill_result=polyfill_result, - is_async=False, - thinking_disabled=thinking_disabled, - ) + transformed_stream = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming( + completion_response, + model=model, + tool_name_mapping=tool_name_mapping, + polyfill_result=polyfill_result, + is_async=False, + thinking_disabled=thinking_disabled, ) if transformed_stream is not None: return transformed_stream From 64d54eff0473a0471fab979b4f4a2f3535225eb5 Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Mon, 14 Sep 2026 18:41:42 +0200 Subject: [PATCH 07/12] style: fix ruff format debt in touched files (repo ruff 0.15.3, 120-col) The lint job's 'Check ruff format' gate flagged: - streaming_iterator.py: 3 line-wraps the branch carried from an 88-column formatting pass (Literal[...] class attr, 2x applied_edits kwargs) that do not fit the repo's 120-column config. - test_handler_thinking_disabled.py: same 88-column wraps on a couple of def lines / patch() calls. Base test file's pre-existing format debt left untouched (it fails the check on base too, so the gate excludes it). --- .../adapters/streaming_iterator.py | 16 +---- .../test_handler_thinking_disabled.py | 60 +++++++------------ 2 files changed, 23 insertions(+), 53 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 0b05625f353..04b9834ca1f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -300,9 +300,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): sent_first_chunk: bool = False sent_content_block_start: bool = False sent_content_block_finish: bool = False - current_content_block_type: Literal[ - "text", "tool_use", "thinking", "redacted_thinking" - ] = "text" + current_content_block_type: Literal["text", "tool_use", "thinking", "redacted_thinking"] = "text" sent_last_message: bool = False holding_chunk: ContentBlockDelta | None = None holding_stop_reason_chunk: MessageBlockDelta | None = None @@ -624,11 +622,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic( response=chunk, current_content_block_index=self.current_content_block_index, - applied_edits=( - self.applied_edits - if is_final_chunk and not will_merge_into_held - else None - ), + applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None), thinking_disabled=self.thinking_disabled, ) processed_chunk = self._with_refusal_stop_details(processed_chunk) @@ -890,11 +884,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic( response=chunk, current_content_block_index=self.current_content_block_index, - applied_edits=( - self.applied_edits - if is_final_chunk and not will_merge_into_held - else None - ), + applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None), thinking_disabled=self.thinking_disabled, ) processed_chunk = self._with_refusal_stop_details(processed_chunk) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py index ac0173a8bd0..c193ab221ec 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py @@ -39,9 +39,7 @@ MESSAGES = [{"role": "user", "content": "hello"}] "thinking_param,expected_thinking_disabled", THINKING_PARAMS, ) -async def test_async_handler_streaming_threads_thinking_disabled( - thinking_param, expected_thinking_disabled -): +async def test_async_handler_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled): """Async handler, stream=True: ``thinking_disabled`` reaches the streaming adapter call.""" with ( @@ -55,9 +53,7 @@ async def test_async_handler_streaming_threads_thinking_disabled( return_value=({}, {}), ), patch("litellm.acompletion", return_value=MagicMock()), - patch( - "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" - ) as mock_adapter, + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, ): mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( @@ -67,12 +63,10 @@ async def test_async_handler_streaming_threads_thinking_disabled( stream=True, thinking=thinking_param, ) - call_kwargs = ( - mock_adapter.translate_completion_output_params_streaming.call_args.kwargs + call_kwargs = mock_adapter.translate_completion_output_params_streaming.call_args.kwargs + assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, ( + f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" ) - assert ( - call_kwargs.get("thinking_disabled") is expected_thinking_disabled - ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" # --------------------------------------------------------------------------- @@ -84,9 +78,7 @@ async def test_async_handler_streaming_threads_thinking_disabled( "thinking_param,expected_thinking_disabled", THINKING_PARAMS, ) -async def test_async_handler_non_streaming_threads_thinking_disabled( - thinking_param, expected_thinking_disabled -): +async def test_async_handler_non_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled): """Async handler, stream=False: ``thinking_disabled`` reaches the non-streaming adapter call.""" with ( @@ -100,9 +92,7 @@ async def test_async_handler_non_streaming_threads_thinking_disabled( return_value=({}, {}), ), patch("litellm.acompletion", return_value=MagicMock()), - patch( - "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" - ) as mock_adapter, + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, ): mock_adapter.translate_completion_output_params.return_value = MagicMock() await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( @@ -113,9 +103,9 @@ async def test_async_handler_non_streaming_threads_thinking_disabled( thinking=thinking_param, ) call_kwargs = mock_adapter.translate_completion_output_params.call_args.kwargs - assert ( - call_kwargs.get("thinking_disabled") is expected_thinking_disabled - ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" + assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, ( + f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" + ) # --------------------------------------------------------------------------- @@ -127,9 +117,7 @@ async def test_async_handler_non_streaming_threads_thinking_disabled( "thinking_param,expected_thinking_disabled", THINKING_PARAMS, ) -def test_sync_handler_streaming_threads_thinking_disabled( - thinking_param, expected_thinking_disabled -): +def test_sync_handler_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled): """Sync handler, stream=True: ``thinking_disabled`` reaches the streaming adapter call. @@ -143,9 +131,7 @@ def test_sync_handler_streaming_threads_thinking_disabled( return_value=({}, {}), ), patch("litellm.completion", return_value=MagicMock()), - patch( - "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" - ) as mock_adapter, + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, ): mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( @@ -155,12 +141,10 @@ def test_sync_handler_streaming_threads_thinking_disabled( stream=True, thinking=thinking_param, ) - call_kwargs = ( - mock_adapter.translate_completion_output_params_streaming.call_args.kwargs + call_kwargs = mock_adapter.translate_completion_output_params_streaming.call_args.kwargs + assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, ( + f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" ) - assert ( - call_kwargs.get("thinking_disabled") is expected_thinking_disabled - ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" # --------------------------------------------------------------------------- @@ -172,9 +156,7 @@ def test_sync_handler_streaming_threads_thinking_disabled( "thinking_param,expected_thinking_disabled", THINKING_PARAMS, ) -def test_sync_handler_non_streaming_threads_thinking_disabled( - thinking_param, expected_thinking_disabled -): +def test_sync_handler_non_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled): """Sync handler, stream=False: ``thinking_disabled`` reaches the non-streaming adapter call. @@ -188,9 +170,7 @@ def test_sync_handler_non_streaming_threads_thinking_disabled( return_value=({}, {}), ), patch("litellm.completion", return_value=MagicMock()), - patch( - "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" - ) as mock_adapter, + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, ): mock_adapter.translate_completion_output_params.return_value = MagicMock() LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( @@ -201,6 +181,6 @@ def test_sync_handler_non_streaming_threads_thinking_disabled( thinking=thinking_param, ) call_kwargs = mock_adapter.translate_completion_output_params.call_args.kwargs - assert ( - call_kwargs.get("thinking_disabled") is expected_thinking_disabled - ), f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" + assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, ( + f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}" + ) From 9ed3724789d14e3342a89fcd46f88ed5bd620aca Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Mon, 14 Sep 2026 18:48:24 +0200 Subject: [PATCH 08/12] fix: clear strict-rule gate breaches (C901 +1, RUF100 +1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C901: the rewritten delta emitter crossed the 15-complexity ceiling. Extract the per-choice payload accumulation into Accumulate streaming chunk payloads (and drop a redundant isinstance+hasattr+truthy+len chain — both choice types share the same Delta, whose optional fields simply default to None), leaving a small delta-type selector in the original method. RUF100: the # noqa: PLR0915 on the sync stream method was stale: PLR0915 is not selected in either config (ruff tom, ruff strict toml), so the directive itself was the violation. Verified with the ruff strict gate comparison against the merge base: every strict rule back within its ceiling. --- .../adapters/streaming_iterator.py | 2 +- .../adapters/transformation.py | 54 +++++++++++-------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 04b9834ca1f..2efa9157575 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -527,7 +527,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): cache_read_input_tokens=0, ) - def __next__(self): # noqa: PLR0915 + def __next__(self): from .transformation import LiteLLMAnthropicMessagesAdapter try: diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index a170282c95e..546ae55ec29 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1738,6 +1738,34 @@ class LiteLLMAnthropicMessagesAdapter: StreamingContentBlockDeltaType, ContentTextBlockDelta | ContentJsonBlockDelta | ContentThinkingBlockDelta | ContentThinkingSignatureBlockDelta, ]: + text, reasoning_content, reasoning_signature, partial_json = self._accumulate_streaming_chunk_payloads( + choices, thinking_disabled=thinking_disabled + ) + if partial_json is not None: + return "input_json_delta", ContentJsonBlockDelta(type="input_json_delta", partial_json=partial_json) + elif reasoning_signature: + return "signature_delta", ContentThinkingSignatureBlockDelta( + type="signature_delta", signature=reasoning_signature + ) + elif reasoning_content: + return "thinking_delta", ContentThinkingBlockDelta(type="thinking_delta", thinking=reasoning_content) + else: + refusal_text: Final = "".join( + refusal for choice in choices if (refusal := openai_chat_refusal_text(choice.delta)) is not None + ) + return "text_delta", ContentTextBlockDelta(type="text_delta", text=text + refusal_text) + + def _accumulate_streaming_chunk_payloads( + self, + choices: list[OpenAIStreamingChoice | StreamingChoices], + thinking_disabled: bool = False, + ) -> tuple[str, str, str, str | None]: + """Fold a chunk's choices into (text, reasoning_content, reasoning_signature, partial_json). + + ``partial_json`` is ``None`` when the chunk carries no tool calls — the + caller uses that to decide the delta type's precedence (tool JSON beats + thinking/thinking-signature text). + """ text: str = "" reasoning_content: str = "" reasoning_signature: str = "" @@ -1749,17 +1777,13 @@ class LiteLLMAnthropicMessagesAdapter: continue if block_type == "thinking": - if ( - isinstance(choice, StreamingChoices) - and hasattr(choice.delta, "thinking_blocks") - and choice.delta.thinking_blocks - and len(choice.delta.thinking_blocks) > 0 - ): - for thinking_block in choice.delta.thinking_blocks: + thinking_blocks = getattr(choice.delta, "thinking_blocks", None) + if isinstance(choice, StreamingChoices) and thinking_blocks: + for thinking_block in thinking_blocks: if thinking_block.get("type") in ("thinking", "redacted_thinking"): reasoning_content += str(thinking_block.get("thinking") or "") reasoning_signature += str(thinking_block.get("signature") or "") - elif isinstance(choice, StreamingChoices) and getattr(choice.delta, "reasoning_content", None): + elif getattr(choice.delta, "reasoning_content", None): reasoning_content += str(choice.delta.reasoning_content) elif block_type == "redacted_thinking": @@ -1778,19 +1802,7 @@ class LiteLLMAnthropicMessagesAdapter: if choice.delta.content is not None and len(choice.delta.content) > 0: text += choice.delta.content - if partial_json is not None: - return "input_json_delta", ContentJsonBlockDelta(type="input_json_delta", partial_json=partial_json) - elif reasoning_signature: - return "signature_delta", ContentThinkingSignatureBlockDelta( - type="signature_delta", signature=reasoning_signature - ) - elif reasoning_content: - return "thinking_delta", ContentThinkingBlockDelta(type="thinking_delta", thinking=reasoning_content) - else: - refusal_text: Final = "".join( - refusal for choice in choices if (refusal := openai_chat_refusal_text(choice.delta)) is not None - ) - return "text_delta", ContentTextBlockDelta(type="text_delta", text=text + refusal_text) + return text, reasoning_content, reasoning_signature, partial_json def translate_streaming_openai_response_to_anthropic( self, From 54c74050839fb5042e3799a4407c90b469014e0e Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Mon, 14 Sep 2026 19:00:20 +0200 Subject: [PATCH 09/12] fix: clear type-discipline gate breaches (LIT001 +3, LIT009 +3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LIT009: the PR's three # type: ignore on chunk.choices / response.choices were dead (enableTypeIgnoreComments is false), and LIT009 is frozen at limit 0. Removed them and widened the three receiving signatures to Sequence[... | Choices] so the list/Choices/StreamingChoices call sites assign cleanly by covariance. LIT001: the +3 came from the new code's mutable-collection annotations (classifier choices list, new accumulator helper choices list, and the _is_thinking_disabled dict param) — switched to Sequence / Mapping read-only views. --- .../experimental_pass_through/adapters/handler.py | 2 +- .../adapters/streaming_iterator.py | 4 ++-- .../adapters/transformation.py | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 87bb3c4ed25..06446de1ff1 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -319,7 +319,7 @@ ANTHROPIC_ADAPTER: Final = AnthropicAdapter() class LiteLLMMessagesToCompletionTransformationHandler: @staticmethod - def _is_thinking_disabled(thinking: dict | None) -> bool: + def _is_thinking_disabled(thinking: Mapping | None) -> bool: """Return True when the client's thinking param is absent or explicitly disabled.""" return thinking is None or (isinstance(thinking, dict) and thinking.get("type") == "disabled") diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 2efa9157575..0c77fd495e8 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -1169,7 +1169,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): return ( LiteLLMAnthropicMessagesAdapter._classify_streaming_chunk( - choices=chunk.choices, # type: ignore + choices=chunk.choices, thinking_disabled=thinking_disabled, ) != "skip" @@ -1232,7 +1232,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): block_type, content_block_start, ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block( - choices=chunk.choices, # type: ignore + choices=chunk.choices, thinking_disabled=self.thinking_disabled, ) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 546ae55ec29..2bc925c6c83 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1562,7 +1562,7 @@ class LiteLLMAnthropicMessagesAdapter: @staticmethod def _classify_streaming_chunk( - choices: list["OpenAIStreamingChoice | StreamingChoices"], + choices: Sequence["OpenAIStreamingChoice | StreamingChoices"], thinking_disabled: bool = False, ) -> Literal["thinking", "redacted_thinking", "tool_use", "text", "skip"]: """ @@ -1667,7 +1667,7 @@ class LiteLLMAnthropicMessagesAdapter: def _translate_streaming_openai_chunk_to_anthropic_content_block( self, - choices: list[OpenAIStreamingChoice | StreamingChoices], + choices: Sequence["OpenAIStreamingChoice | StreamingChoices | Choices"], thinking_disabled: bool = False, ) -> tuple[ Literal["text", "tool_use", "thinking", "redacted_thinking"], @@ -1732,7 +1732,7 @@ class LiteLLMAnthropicMessagesAdapter: def _translate_streaming_openai_chunk_to_anthropic( self, - choices: list[OpenAIStreamingChoice | StreamingChoices], + choices: Sequence["OpenAIStreamingChoice | StreamingChoices | Choices"], thinking_disabled: bool = False, ) -> tuple[ StreamingContentBlockDeltaType, @@ -1757,7 +1757,7 @@ class LiteLLMAnthropicMessagesAdapter: def _accumulate_streaming_chunk_payloads( self, - choices: list[OpenAIStreamingChoice | StreamingChoices], + choices: Sequence["OpenAIStreamingChoice | StreamingChoices"], thinking_disabled: bool = False, ) -> tuple[str, str, str, str | None]: """Fold a chunk's choices into (text, reasoning_content, reasoning_signature, partial_json). @@ -1838,7 +1838,7 @@ class LiteLLMAnthropicMessagesAdapter: type_of_content, content_block_delta, ) = self._translate_streaming_openai_chunk_to_anthropic( - choices=response.choices, # type: ignore + choices=response.choices, thinking_disabled=thinking_disabled, ) return ContentBlockDelta( From 63323a1dfdaa3a5bbbfb339cbdf654ba663c69f5 Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Mon, 14 Sep 2026 19:52:34 +0200 Subject: [PATCH 10/12] fix: clear test-quality (TQ008) and basedpyright gate breaches TQ008: the 16 new handler-level tests patch litellm. internals (litellm.acompletion, the handler adapter, _prepare_* seams). Each patch line carries an explainable test-quality-ok: reason - the unit under test IS the handler's thinking_disabled translation wiring, not the transport. basedpyright (delta vs base): - reportPrivateUsage: the shared-classifier delegation added a protected cross-class call. _chunk_has_substantial_content now derives the decision inline (same per-choice conditions, same getattr guards, same .strip()/truthy semantics as the classifier, documented). - reportOptionalSubscript/MemberAccess: the content_block tool branch relies on the classifier for tool_call presence; restored explicit narrowing (assert + local first_tool_call), behaviour-neutral. - dropped Choices from the emitter/content_block Sequence unions: the bare-Choices member re-opened Optional on delta.tool_calls[0].function (the 3 # type: ignore it used to sit next to were dead code anyway). litellm.types.utils.StreamingChoices imported at top level. --- .../adapters/streaming_iterator.py | 47 +++++++++++++++---- .../adapters/transformation.py | 16 +++++-- .../test_handler_thinking_disabled.py | 28 +++++------ 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 0c77fd495e8..e6321c6c6fc 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -31,7 +31,7 @@ from litellm.types.llms.anthropic import ( UsageDelta, UsageIteration, ) -from litellm.types.utils import AdapterCompletionStreamWrapper, Delta +from litellm.types.utils import AdapterCompletionStreamWrapper, Delta, StreamingChoices if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject @@ -1164,16 +1164,45 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): pre-existing tests (test_empty_chunk_is_not_substantial, test_reasoning_chunk_is_substantial) call it unbound as AnthropicStreamWrapper._chunk_has_substantial_content(chunk) — converting - to an instance method would break those calls.""" - from .transformation import LiteLLMAnthropicMessagesAdapter + to an instance method would break those calls. - return ( - LiteLLMAnthropicMessagesAdapter._classify_streaming_chunk( - choices=chunk.choices, - thinking_disabled=thinking_disabled, + The two sites' conditions are kept identical in code so they cannot + drift into two different notions of substantiality (the CTG-85 failure + mode). Rules mirrored from the classifier, per choice, with the same + getattr-with-default guards (Delta deletes reasoning_content / + thinking_blocks entirely when unset): + + - a reasoning-only chunk is not substantial when thinking is disabled; + - a structured thinking / redacted block is always substantial (even + with an empty payload) when thinking is enabled; + - a flat reasoning_content string is substantial only when it carries + non-whitespace, when thinking is enabled; + - a tool call with a function, and a truthy (NOT .strip()-based) text + content, are substantial regardless of thinking state.""" + for choice in chunk.choices: + reasoning_text = "" + has_structured_thinking_block = False + if isinstance(choice, StreamingChoices): + thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or [] + if len(thinking_blocks) > 0: + first_block = thinking_blocks[0] + if first_block.get("type") in ("thinking", "redacted_thinking"): + has_structured_thinking_block = True + reasoning_text = str(first_block.get("thinking") or "") + if not has_structured_thinking_block: + reasoning_text = str(getattr(choice.delta, "reasoning_content", "") or "") + has_substantial_reasoning = bool(reasoning_text.strip()) or has_structured_thinking_block + has_tool_calls = ( + choice.delta.tool_calls is not None + and len(choice.delta.tool_calls) > 0 + and choice.delta.tool_calls[0].function is not None ) - != "skip" - ) + text_content = str(choice.delta.content or "") + if has_tool_calls or bool(text_content): + return True + if not thinking_disabled and has_substantial_reasoning: + return True + return False @staticmethod def _is_blank_delta(chunk: "ModelResponseStream") -> bool: diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 2bc925c6c83..c94b8c2eb50 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1667,7 +1667,7 @@ class LiteLLMAnthropicMessagesAdapter: def _translate_streaming_openai_chunk_to_anthropic_content_block( self, - choices: Sequence["OpenAIStreamingChoice | StreamingChoices | Choices"], + choices: Sequence["OpenAIStreamingChoice | StreamingChoices"], thinking_disabled: bool = False, ) -> tuple[ Literal["text", "tool_use", "thinking", "redacted_thinking"], @@ -1709,8 +1709,16 @@ class LiteLLMAnthropicMessagesAdapter: return "redacted_thinking", cast("ContentBlockContentBlockDict", redacted_block) if block_type == "tool_use": - raw_id = choice.delta.tool_calls[0].id or str(uuid.uuid4()) - tool_name = choice.delta.tool_calls[0].function.name or "" + # Explicit narrowing (base pattern): the classifier only emits + # "tool_use" when the first tool call carries a function, so + # these asserts hold and keep the member accesses below + # optional-free without changing behaviour. + tool_calls = choice.delta.tool_calls + assert tool_calls is not None and len(tool_calls) > 0 + first_tool_call = tool_calls[0] + assert first_tool_call.function is not None + raw_id = first_tool_call.id or str(uuid.uuid4()) + tool_name = first_tool_call.function.name or "" thought_sig: str | None = None if THOUGHT_SIGNATURE_SEPARATOR in raw_id: parts = raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1) @@ -1732,7 +1740,7 @@ class LiteLLMAnthropicMessagesAdapter: def _translate_streaming_openai_chunk_to_anthropic( self, - choices: Sequence["OpenAIStreamingChoice | StreamingChoices | Choices"], + choices: Sequence["OpenAIStreamingChoice | StreamingChoices"], thinking_disabled: bool = False, ) -> tuple[ StreamingContentBlockDeltaType, diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py index c193ab221ec..0d3697d54b8 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py @@ -43,17 +43,17 @@ async def test_async_handler_streaming_threads_thinking_disabled(thinking_param, """Async handler, stream=True: ``thinking_disabled`` reaches the streaming adapter call.""" with ( - patch( + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport "litellm.llms.anthropic.experimental_pass_through.adapters.handler._prepare_context_managed_request", return_value=None, ), - patch.object( + patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport LiteLLMMessagesToCompletionTransformationHandler, "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.acompletion", return_value=MagicMock()), - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, + patch("litellm.acompletion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( @@ -82,17 +82,17 @@ async def test_async_handler_non_streaming_threads_thinking_disabled(thinking_pa """Async handler, stream=False: ``thinking_disabled`` reaches the non-streaming adapter call.""" with ( - patch( + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport "litellm.llms.anthropic.experimental_pass_through.adapters.handler._prepare_context_managed_request", return_value=None, ), - patch.object( + patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport LiteLLMMessagesToCompletionTransformationHandler, "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.acompletion", return_value=MagicMock()), - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, + patch("litellm.acompletion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params.return_value = MagicMock() await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( @@ -125,13 +125,13 @@ def test_sync_handler_streaming_threads_thinking_disabled(thinking_param, expect blocks) so ``run_async_function`` is never invoked. """ with ( - patch.object( + patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport LiteLLMMessagesToCompletionTransformationHandler, "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.completion", return_value=MagicMock()), - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, + patch("litellm.completion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( @@ -164,13 +164,13 @@ def test_sync_handler_non_streaming_threads_thinking_disabled(thinking_param, ex blocks) so ``run_async_function`` is never invoked. """ with ( - patch.object( + patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport LiteLLMMessagesToCompletionTransformationHandler, "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.completion", return_value=MagicMock()), - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, + patch("litellm.completion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params.return_value = MagicMock() LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( From b7deea30aa369c0a40a46f7d009b154567bb1099 Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Mon, 14 Sep 2026 20:21:57 +0200 Subject: [PATCH 11/12] fix: pass together_ai replay/streaming tests under the thinking contract Controlled base-vs-head run of the full llms test shard exposed two together_ai anthropic-messages tests broken by the thinking_disabled contract change in this PR: - test_anthropic_messages_replays_tool_loop: an unsigned thinking block in replayed history was being dropped entirely. Unsigned thinking texts now map to the provider-facing reasoning_content field (keeping the signature-400 defense: they still stay out of thinking_blocks). - replay + streaming tests asserted provider reasoning is surfaced without a thinking param on the request; per the PR contract that is suppressed. Updated both to assert the suppression (sending thinking=enabled would fail together's parameter validation via reasoning_effort). --- .../adapters/transformation.py | 13 +++++++++++++ .../chat/test_together_ai_chat_transformation.py | 13 +++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index c94b8c2eb50..c63890cee8e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -495,6 +495,7 @@ class LiteLLMAnthropicMessagesAdapter: has_cache_control_in_text = False tool_calls: list[ChatCompletionAssistantToolCall] = [] thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] = [] + unsigned_thinking_texts: list[str] = [] if m["role"] == "assistant": if isinstance(m.get("content"), str): assistant_message_str = str(m.get("content", "")) @@ -555,6 +556,15 @@ class LiteLLMAnthropicMessagesAdapter: signature=content.get("signature") or "", ) thinking_blocks.append(thinking_block) + else: + # Unsigned text is NOT dropped: it is + # replayed as the flat reasoning_content field + # below (the provider-visible form), while + # staying out of thinking_blocks, so the + # signature-400 stays avoided. + unsigned_text = str(content.get("thinking") or "") + if unsigned_text: + unsigned_thinking_texts.append(unsigned_text) elif content.get("type") == "redacted_thinking": redacted_thinking_block = ChatCompletionRedactedThinkingBlock( type="redacted_thinking", @@ -587,6 +597,9 @@ class LiteLLMAnthropicMessagesAdapter: if len(thinking_blocks) > 0: assistant_message["thinking_blocks"] = thinking_blocks reasoning_content = reasoning_content_from_thinking_blocks(thinking_blocks) + if unsigned_thinking_texts: + unsigned = "\n".join(unsigned_thinking_texts) + reasoning_content = f"{reasoning_content}\n{unsigned}" if reasoning_content else unsigned if reasoning_content: assistant_message["reasoning_content"] = reasoning_content new_messages.append(assistant_message) diff --git a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py index 7eb7dc41d4f..ce8a51b00f6 100644 --- a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py +++ b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py @@ -1013,7 +1013,11 @@ def test_anthropic_messages_replays_tool_loop_and_maps_reasoning_to_thinking_blo assert tool_turn["content"] == "Sunny, 18C" blocks = {block["type"]: block for block in response["content"]} - assert blocks["thinking"]["thinking"] == "Tool said sunny." + + # Contract (thinking param absent): the provider's reasoning is NOT + # surfaced as an Anthropic thinking block. The mock response still + # carries reasoning, and this asserts it is suppressed. + assert "thinking" not in blocks assert blocks["text"]["text"] == "Sunny in SF." assert response["stop_reason"] == "end_turn" @@ -1031,6 +1035,9 @@ def test_anthropic_messages_streams_together_tool_call_as_input_json_delta(): captured_requests: list[httpx.Request] = [] client = _sync_client(captured_requests, _sse_response(*PARALLEL_TOOL_CALL_STREAM)) + # Explicit thinking= would be needed to surface provider reasoning + # (the pass-through contract suppresses it when the thinking param is + # absent); this test asserts the streaming translation only. events = _anthropic_sse_events( litellm.anthropic.messages.create( model=f"together_ai/{UNMAPPED_MODEL}", @@ -1067,7 +1074,9 @@ def test_anthropic_messages_streams_together_tool_call_as_input_json_delta(): for event in events if event["type"] == "content_block_delta" and event["delta"]["type"] == "thinking_delta" ) - assert thinking_text == "Need weather and time." + # thinking param absent in the request: provider reasoning is + # suppressed per the contract, so no thinking block may appear. + assert thinking_text == "" assert [event["delta"]["stop_reason"] for event in events if event["type"] == "message_delta"] == ["tool_use"] From d1ce467d044bea94618d9cda5be4a9d611565047 Mon Sep 17 00:00:00 2001 From: Daniel Cherubini Date: Tue, 15 Sep 2026 09:45:36 +0200 Subject: [PATCH 12/12] fix: fail-closed thinking default (Veria review finding) _threaded thinking was fail-open for a malformed config: with thinking: {} (missing type) the request side (translate anthropic_thinking_to_reasoning_effort) defaulted to disabled, but the response side still converted provider reasoning_content into a thinking block, leaking internal reasoning. _is_thinking_disabled now suppresses unless the client explicitly opted in (type in enabled/adaptive). Truth-table tests extended to cover {}, {budget_tokens: 500} and an unknown type (all -> disabled). Bot review comments answered. --- .../adapters/handler.py | 13 +++++- .../test_handler_thinking_disabled.py | 44 ++++++++++++++----- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 06446de1ff1..e89fcd52fc4 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -320,8 +320,17 @@ ANTHROPIC_ADAPTER: Final = AnthropicAdapter() class LiteLLMMessagesToCompletionTransformationHandler: @staticmethod def _is_thinking_disabled(thinking: Mapping | None) -> bool: - """Return True when the client's thinking param is absent or explicitly disabled.""" - return thinking is None or (isinstance(thinking, dict) and thinking.get("type") == "disabled") + """Return True (suppressed) unless the client explicitly opted in. + + Only ``{"type": "enabled"|"adaptive"}`` enables the reasoning + translation. Absent, disabled, or malformed objects (missing + ``type``) fail closed: the request side + (``translate_anthropic_thinking_to_reasoning_effort``) already + defaults a missing ``type`` to ``disabled``, and a malformed + object must not surface provider ``reasoning_content`` through + a thinking block (review finding). + """ + return not (isinstance(thinking, dict) and thinking.get("type") in ("enabled", "adaptive")) @staticmethod def _route_openai_thinking_to_responses_api_if_needed( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py index 0d3697d54b8..77cb1eea527 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_handler_thinking_disabled.py @@ -1,10 +1,11 @@ """Handler-level tests for ``thinking_disabled`` computation and threading. Covers the boolean logic that decides whether thinking is disabled -(``thinking is None or thinking.type == "disabled"``) and verifies it is -threaded correctly to ``ANTHROPIC_ADAPTER`` output-translation calls for -both the async and sync handler entry points, in streaming and non-streaming -modes. +(fail-closed: only an explicit ``{\"type\": \"enabled"|\"adaptive\"}`` +enables it; absent, disabled, or malformed objects disable it) and +verifies it is threaded correctly to ``ANTHROPIC_ADAPTER`` output- +translation calls for both the async and sync handler entry points, in +streaming and non-streaming modes. Mocks ``litellm.acompletion`` / ``litellm.completion`` and ``ANTHROPIC_ADAPTER`` directly, alongside the preparation helpers that run @@ -22,7 +23,10 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( THINKING_PARAMS = [ (None, True), + ({}, True), ({"type": "disabled"}, True), + ({"budget_tokens": 1024}, True), + ({"type": "weird"}, True), ({"type": "enabled", "budget_tokens": 1024}, False), ({"type": "adaptive"}, False), ] @@ -52,8 +56,12 @@ async def test_async_handler_streaming_threads_thinking_disabled(thinking_param, "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.acompletion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.acompletion", return_value=MagicMock() + ), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( @@ -91,8 +99,12 @@ async def test_async_handler_non_streaming_threads_thinking_disabled(thinking_pa "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.acompletion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.acompletion", return_value=MagicMock() + ), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params.return_value = MagicMock() await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( @@ -130,8 +142,12 @@ def test_sync_handler_streaming_threads_thinking_disabled(thinking_param, expect "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.completion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.completion", return_value=MagicMock() + ), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params_streaming.return_value = iter([]) LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( @@ -169,8 +185,12 @@ def test_sync_handler_non_streaming_threads_thinking_disabled(thinking_param, ex "_prepare_completion_kwargs", return_value=({}, {}), ), - patch("litellm.completion", return_value=MagicMock()), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport - patch("litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER") as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.completion", return_value=MagicMock() + ), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport + "litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER" + ) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport ): mock_adapter.translate_completion_output_params.return_value = MagicMock() LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler(