3131from typing import Tuple
3232from typing import TypedDict
3333from typing import Union
34+ import uuid
3435import warnings
3536
3637from google .genai import types
6465_NEW_LINE = "\n "
6566_EXCLUDED_PART_FIELD = {"inline_data" : {"data" }}
6667_LITELLM_STRUCTURED_TYPES = {"json_object" , "json_schema" }
68+ _JSON_DECODER = json .JSONDecoder ()
6769
6870# Mapping of LiteLLM finish_reason strings to FinishReason enum values
6971# Note: tool_calls/function_call map to STOP because:
@@ -431,6 +433,118 @@ def _get_content(
431433 return content_objects
432434
433435
436+ def _build_tool_call_from_json_dict (
437+ candidate : Any , * , index : int
438+ ) -> Optional [ChatCompletionMessageToolCall ]:
439+ """Creates a tool call object from JSON content embedded in text."""
440+
441+ if not isinstance (candidate , dict ):
442+ return None
443+
444+ name = candidate .get ("name" )
445+ args = candidate .get ("arguments" )
446+ if not isinstance (name , str ) or args is None :
447+ return None
448+
449+ if isinstance (args , str ):
450+ arguments_payload = args
451+ else :
452+ try :
453+ arguments_payload = json .dumps (args , ensure_ascii = False )
454+ except (TypeError , ValueError ):
455+ arguments_payload = _safe_json_serialize (args )
456+
457+ call_id = candidate .get ("id" ) or f"adk_tool_call_{ uuid .uuid4 ().hex } "
458+ call_index = candidate .get ("index" )
459+ if isinstance (call_index , int ):
460+ index = call_index
461+
462+ function = Function (
463+ name = name ,
464+ arguments = arguments_payload ,
465+ )
466+ # Some LiteLLM types carry an `index` field only in streaming contexts,
467+ # so guard the assignment to stay compatible with older versions.
468+ if hasattr (function , "index" ):
469+ function .index = index # type: ignore[attr-defined]
470+
471+ tool_call = ChatCompletionMessageToolCall (
472+ type = "function" ,
473+ id = str (call_id ),
474+ function = function ,
475+ )
476+ # Same reasoning as above: not every ChatCompletionMessageToolCall exposes it.
477+ if hasattr (tool_call , "index" ):
478+ tool_call .index = index # type: ignore[attr-defined]
479+
480+ return tool_call
481+
482+
483+ def _parse_tool_calls_from_text (
484+ text_block : str ,
485+ ) -> tuple [list [ChatCompletionMessageToolCall ], Optional [str ]]:
486+ """Extracts inline JSON tool calls from LiteLLM text responses."""
487+
488+ tool_calls = []
489+ if not text_block :
490+ return tool_calls , None
491+
492+ remainder_segments = []
493+ cursor = 0
494+ text_length = len (text_block )
495+
496+ while cursor < text_length :
497+ brace_index = text_block .find ("{" , cursor )
498+ if brace_index == - 1 :
499+ remainder_segments .append (text_block [cursor :])
500+ break
501+
502+ remainder_segments .append (text_block [cursor :brace_index ])
503+ try :
504+ candidate , end = _JSON_DECODER .raw_decode (text_block , brace_index )
505+ except json .JSONDecodeError :
506+ remainder_segments .append (text_block [brace_index ])
507+ cursor = brace_index + 1
508+ continue
509+
510+ tool_call = _build_tool_call_from_json_dict (
511+ candidate , index = len (tool_calls )
512+ )
513+ if tool_call :
514+ tool_calls .append (tool_call )
515+ else :
516+ remainder_segments .append (text_block [brace_index :end ])
517+ cursor = end
518+
519+ remainder = "" .join (segment for segment in remainder_segments if segment )
520+ remainder = remainder .strip ()
521+
522+ return tool_calls , remainder or None
523+
524+
525+ def _split_message_content_and_tool_calls (
526+ message : Message ,
527+ ) -> tuple [Optional [OpenAIMessageContent ], list [ChatCompletionMessageToolCall ]]:
528+ """Returns message content and tool calls, parsing inline JSON when needed."""
529+
530+ existing_tool_calls = message .get ("tool_calls" ) or []
531+ normalized_tool_calls = (
532+ list (existing_tool_calls ) if existing_tool_calls else []
533+ )
534+ content = message .get ("content" )
535+
536+ # LiteLLM responses either provide structured tool_calls or inline JSON, not
537+ # both. When tool_calls are present we trust them and skip the fallback parser.
538+ if normalized_tool_calls or not isinstance (content , str ):
539+ return content , normalized_tool_calls
540+
541+ fallback_tool_calls , remainder = _parse_tool_calls_from_text (content )
542+ if fallback_tool_calls :
543+ return remainder , fallback_tool_calls
544+
545+ return content , []
546+
547+
434548def _to_litellm_role (role : Optional [str ]) -> Literal ["user" , "assistant" ]:
435549 """Converts a types.Content role to a litellm role.
436550
@@ -584,15 +698,24 @@ def _model_response_to_chunk(
584698 if message is None and response ["choices" ][0 ].get ("delta" , None ):
585699 message = response ["choices" ][0 ]["delta" ]
586700
587- if message .get ("content" , None ):
588- yield TextChunk (text = message .get ("content" )), finish_reason
701+ message_content : Optional [OpenAIMessageContent ] = None
702+ tool_calls : list [ChatCompletionMessageToolCall ] = []
703+ if message is not None :
704+ (
705+ message_content ,
706+ tool_calls ,
707+ ) = _split_message_content_and_tool_calls (message )
589708
590- if message .get ("tool_calls" , None ):
591- for tool_call in message .get ("tool_calls" ):
709+ if message_content :
710+ yield TextChunk (text = message_content ), finish_reason
711+
712+ if tool_calls :
713+ for idx , tool_call in enumerate (tool_calls ):
592714 # aggregate tool_call
593715 if tool_call .type == "function" :
594716 func_name = tool_call .function .name
595717 func_args = tool_call .function .arguments
718+ func_index = getattr (tool_call , "index" , idx )
596719
597720 # Ignore empty chunks that don't carry any information.
598721 if not func_name and not func_args :
@@ -602,12 +725,10 @@ def _model_response_to_chunk(
602725 id = tool_call .id ,
603726 name = func_name ,
604727 args = func_args ,
605- index = tool_call . index ,
728+ index = func_index ,
606729 ), finish_reason
607730
608- if finish_reason and not (
609- message .get ("content" , None ) or message .get ("tool_calls" , None )
610- ):
731+ if finish_reason and not (message_content or tool_calls ):
611732 yield None , finish_reason
612733
613734 if not message :
@@ -687,11 +808,12 @@ def _message_to_generate_content_response(
687808 """
688809
689810 parts = []
690- if message .get ("content" , None ):
691- parts .append (types .Part .from_text (text = message .get ("content" )))
811+ message_content , tool_calls = _split_message_content_and_tool_calls (message )
812+ if isinstance (message_content , str ) and message_content :
813+ parts .append (types .Part .from_text (text = message_content ))
692814
693- if message . get ( " tool_calls" , None ) :
694- for tool_call in message . get ( " tool_calls" ) :
815+ if tool_calls :
816+ for tool_call in tool_calls :
695817 if tool_call .type == "function" :
696818 part = types .Part .from_function_call (
697819 name = tool_call .function .name ,
0 commit comments