@@ -532,3 +532,250 @@ def test_multiple_partial_chunks_accumulation():
532532 assert result3 is not None
533533 assert iterator .accumulated_json == ""
534534 assert result3 .choices [0 ].delta .content == "Hello"
535+
536+
537+ def test_web_search_tool_result_no_extra_tool_calls ():
538+ """
539+ Test that web_search_tool_result blocks don't emit tool call chunks.
540+
541+ This tests the fix for https://github.com/BerriAI/litellm/issues/17254
542+ where streaming with Anthropic web search was adding trailing {} to tool call arguments.
543+
544+ The issue was that web_search_tool_result blocks have input_json_delta events with {}
545+ that were incorrectly being converted to tool calls.
546+ """
547+ iterator = ModelResponseIterator (
548+ streaming_response = MagicMock (), sync_stream = True , json_mode = False
549+ )
550+
551+ # Simulate the streaming sequence:
552+ # 1. server_tool_use block starts (web_search)
553+ # 2. input_json_delta with the query
554+ # 3. content_block_stop
555+ # 4. web_search_tool_result block starts
556+ # 5. input_json_delta with {} (this should NOT emit a tool call)
557+ # 6. content_block_stop
558+
559+ chunks = [
560+ # 1. server_tool_use block starts
561+ {
562+ "type" : "content_block_start" ,
563+ "index" : 0 ,
564+ "content_block" : {
565+ "type" : "server_tool_use" ,
566+ "id" : "srvtoolu_01ABC123" ,
567+ "name" : "web_search" ,
568+ },
569+ },
570+ # 2. input_json_delta with the query
571+ {
572+ "type" : "content_block_delta" ,
573+ "index" : 0 ,
574+ "delta" : {"type" : "input_json_delta" , "partial_json" : '{"query": "test"}' },
575+ },
576+ # 3. content_block_stop for server_tool_use
577+ {"type" : "content_block_stop" , "index" : 0 },
578+ # 4. web_search_tool_result block starts
579+ {
580+ "type" : "content_block_start" ,
581+ "index" : 1 ,
582+ "content_block" : {
583+ "type" : "web_search_tool_result" ,
584+ "tool_use_id" : "srvtoolu_01ABC123" ,
585+ "content" : [],
586+ },
587+ },
588+ # 5. input_json_delta with {} - this should NOT emit a tool call
589+ {
590+ "type" : "content_block_delta" ,
591+ "index" : 1 ,
592+ "delta" : {"type" : "input_json_delta" , "partial_json" : "{}" },
593+ },
594+ # 6. content_block_stop for web_search_tool_result
595+ {"type" : "content_block_stop" , "index" : 1 },
596+ # 7. Another web_search_tool_result with {} - also should NOT emit
597+ {
598+ "type" : "content_block_start" ,
599+ "index" : 2 ,
600+ "content_block" : {
601+ "type" : "web_search_tool_result" ,
602+ "tool_use_id" : "srvtoolu_01ABC123" ,
603+ "content" : [],
604+ },
605+ },
606+ {
607+ "type" : "content_block_delta" ,
608+ "index" : 2 ,
609+ "delta" : {"type" : "input_json_delta" , "partial_json" : "{}" },
610+ },
611+ {"type" : "content_block_stop" , "index" : 2 },
612+ ]
613+
614+ tool_calls_emitted = []
615+ for chunk in chunks :
616+ parsed = iterator .chunk_parser (chunk )
617+ if parsed .choices and parsed .choices [0 ].delta .tool_calls :
618+ for tc in parsed .choices [0 ].delta .tool_calls :
619+ tool_calls_emitted .append (tc )
620+
621+ # Should have exactly 2 tool calls:
622+ # 1. From content_block_start (server_tool_use) with id and name
623+ # 2. From content_block_delta with the actual query
624+ assert len (tool_calls_emitted ) == 2 , f"Expected 2 tool calls, got { len (tool_calls_emitted )} "
625+
626+ # First tool call should have the id and name
627+ assert tool_calls_emitted [0 ]["id" ] == "srvtoolu_01ABC123"
628+ assert tool_calls_emitted [0 ]["function" ]["name" ] == "web_search"
629+
630+ # Second tool call should have the query arguments
631+ assert tool_calls_emitted [1 ]["function" ]["arguments" ] == '{"query": "test"}'
632+
633+ # The {} chunks from web_search_tool_result should NOT have been emitted as tool calls
634+
635+
636+ def test_current_content_block_type_tracking ():
637+ """
638+ Test that current_content_block_type is properly tracked and reset.
639+ """
640+ iterator = ModelResponseIterator (
641+ streaming_response = MagicMock (), sync_stream = True , json_mode = False
642+ )
643+
644+ # Initially should be None
645+ assert iterator .current_content_block_type is None
646+
647+ # After server_tool_use block start
648+ chunk1 = {
649+ "type" : "content_block_start" ,
650+ "index" : 0 ,
651+ "content_block" : {
652+ "type" : "server_tool_use" ,
653+ "id" : "srvtoolu_01ABC" ,
654+ "name" : "web_search" ,
655+ },
656+ }
657+ iterator .chunk_parser (chunk1 )
658+ assert iterator .current_content_block_type == "server_tool_use"
659+
660+ # After content_block_stop
661+ chunk2 = {"type" : "content_block_stop" , "index" : 0 }
662+ iterator .chunk_parser (chunk2 )
663+ assert iterator .current_content_block_type is None
664+
665+ # After web_search_tool_result block start
666+ chunk3 = {
667+ "type" : "content_block_start" ,
668+ "index" : 1 ,
669+ "content_block" : {
670+ "type" : "web_search_tool_result" ,
671+ "tool_use_id" : "srvtoolu_01ABC" ,
672+ "content" : [],
673+ },
674+ }
675+ iterator .chunk_parser (chunk3 )
676+ assert iterator .current_content_block_type == "web_search_tool_result"
677+
678+ # After content_block_stop
679+ chunk4 = {"type" : "content_block_stop" , "index" : 1 }
680+ iterator .chunk_parser (chunk4 )
681+ assert iterator .current_content_block_type is None
682+
683+
684+ def test_web_search_tool_result_captured_in_provider_specific_fields ():
685+ """
686+ Test that web_search_tool_result content is captured in provider_specific_fields.
687+
688+ This tests the fix for https://github.com/BerriAI/litellm/issues/17737
689+ where streaming with Anthropic web search wasn't capturing web_search_tool_result
690+ blocks, causing multi-turn conversations to fail.
691+
692+ The web_search_tool_result content comes ALL AT ONCE in content_block_start,
693+ not in deltas, so we need to capture it there.
694+ """
695+ iterator = ModelResponseIterator (
696+ streaming_response = MagicMock (), sync_stream = True , json_mode = False
697+ )
698+
699+ # Simulate the streaming sequence with web_search_tool_result
700+ chunks = [
701+ # 1. message_start
702+ {
703+ "type" : "message_start" ,
704+ "message" : {
705+ "id" : "msg_123" ,
706+ "type" : "message" ,
707+ "role" : "assistant" ,
708+ "content" : [],
709+ "usage" : {"input_tokens" : 10 , "output_tokens" : 1 },
710+ },
711+ },
712+ # 2. server_tool_use block starts (web_search)
713+ {
714+ "type" : "content_block_start" ,
715+ "index" : 0 ,
716+ "content_block" : {
717+ "type" : "server_tool_use" ,
718+ "id" : "srvtoolu_01ABC123" ,
719+ "name" : "web_search" ,
720+ },
721+ },
722+ # 3. input_json_delta with the query
723+ {
724+ "type" : "content_block_delta" ,
725+ "index" : 0 ,
726+ "delta" : {"type" : "input_json_delta" , "partial_json" : '{"query": "otter facts"}' },
727+ },
728+ # 4. content_block_stop for server_tool_use
729+ {"type" : "content_block_stop" , "index" : 0 },
730+ # 5. web_search_tool_result block starts - THIS IS WHERE THE RESULTS ARE
731+ {
732+ "type" : "content_block_start" ,
733+ "index" : 1 ,
734+ "content_block" : {
735+ "type" : "web_search_tool_result" ,
736+ "tool_use_id" : "srvtoolu_01ABC123" ,
737+ "content" : [
738+ {
739+ "type" : "web_search_result" ,
740+ "url" : "https://example.com/otters" ,
741+ "title" : "Fun Otter Facts" ,
742+ "encrypted_content" : "abc123encrypted" ,
743+ },
744+ {
745+ "type" : "web_search_result" ,
746+ "url" : "https://example.com/otters2" ,
747+ "title" : "More Otter Facts" ,
748+ "encrypted_content" : "def456encrypted" ,
749+ },
750+ ],
751+ },
752+ },
753+ # 6. content_block_stop for web_search_tool_result
754+ {"type" : "content_block_stop" , "index" : 1 },
755+ ]
756+
757+ web_search_results = None
758+ for chunk in chunks :
759+ parsed = iterator .chunk_parser (chunk )
760+ if (
761+ parsed .choices
762+ and parsed .choices [0 ].delta .provider_specific_fields
763+ and "web_search_results" in parsed .choices [0 ].delta .provider_specific_fields
764+ ):
765+ web_search_results = parsed .choices [0 ].delta .provider_specific_fields [
766+ "web_search_results"
767+ ]
768+
769+ # Verify web_search_results was captured
770+ assert web_search_results is not None , "web_search_results should be captured"
771+ assert len (web_search_results ) == 1 , "Should have 1 web_search_tool_result block"
772+ assert (
773+ web_search_results [0 ]["type" ] == "web_search_tool_result"
774+ ), "Block type should be web_search_tool_result"
775+ assert (
776+ web_search_results [0 ]["tool_use_id" ] == "srvtoolu_01ABC123"
777+ ), "tool_use_id should match"
778+ assert len (web_search_results [0 ]["content" ]) == 2 , "Should have 2 search results"
779+ assert (
780+ web_search_results [0 ]["content" ][0 ]["title" ] == "Fun Otter Facts"
781+ ), "First result title should match"
0 commit comments