diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 9cfbf1b6d8d..8868fabdcef 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -740,9 +740,7 @@ class LiteLLMAnthropicMessagesAdapter: from litellm.types.llms.anthropic import TextBlock, ToolUseBlock for choice in choices: - if choice.delta.content is not None and len(choice.delta.content) > 0: - return "text", TextBlock(type="text", text="") - elif ( + 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 @@ -753,6 +751,8 @@ class LiteLLMAnthropicMessagesAdapter: name=choice.delta.tool_calls[0].function.name or "", input={}, # type: ignore[typeddict-item] ) + elif choice.delta.content is not None and len(choice.delta.content) > 0: + return "text", TextBlock(type="text", text="") elif isinstance(choice, StreamingChoices) and hasattr( choice.delta, "thinking_blocks" ): @@ -796,7 +796,7 @@ class LiteLLMAnthropicMessagesAdapter: for choice in choices: if choice.delta.content is not None and len(choice.delta.content) > 0: text += choice.delta.content - elif choice.delta.tool_calls is not None: + if choice.delta.tool_calls is not None: partial_json = "" for tool in choice.delta.tool_calls: if ( 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 9d6fbf66e48..6aadbc058d1 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 @@ -1055,3 +1055,56 @@ def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward f"got {type(tool_message['content'])}" ) assert tool_message["content"] == "72°F and sunny" + + +def test_streaming_chunk_with_both_text_and_tool_calls_issue_18238(): + """ + When a streaming choice contains both text content and tool_calls, + both should be processed (tool_calls should not be ignored). + """ + # streaming choice with both text and tool_calls + choices = [ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + provider_specific_fields=None, + content="Here is some text for litellm", + role=None, + function_call=None, + tool_calls=[ + ChatCompletionDeltaToolCall( + id="toolu_bdrk_013xRVejhv3ybmLEGCoZib2b", + function=Function(arguments='{"cmd": "init"}', name="Bash"), + type="function", + index=0, + ) + ], + audio=None, + ), + logprobs=None, + ) + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + + # When both text and tool_calls exist, tool_calls (input_json_delta) takes priority + ( + type_of_content, + content_block_delta, + ) = adapter._translate_streaming_openai_chunk_to_anthropic(choices=choices) + + assert type_of_content == "input_json_delta" + assert content_block_delta["partial_json"] == '{"cmd": "init"}' + + # When both text and tool_calls exist, tool_use should be detected and tool name captured + ( + block_type, + content_block_start, + ) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block( + choices=choices + ) + + assert block_type == "tool_use" + assert content_block_start["name"] == "Bash" + assert content_block_start["id"] == "toolu_bdrk_013xRVejhv3ybmLEGCoZib2b"