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(