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(
431433return 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(
584698if message is None and response ["choices" ][0 ].get ("delta" ,None ):
585699message = 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
593715if tool_call .type == "function" :
594716func_name = tool_call .function .name
595717func_args = tool_call .function .arguments
718+ func_index = getattr (tool_call ,"index" ,idx )
596719
597720# Ignore empty chunks that don't carry any information.
598721if not func_name and not func_args :
@@ -602,12 +725,10 @@ def _model_response_to_chunk(
602725id = tool_call .id ,
603726name = func_name ,
604727args = 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 ):
611732yield None ,finish_reason
612733
613734if not message :
@@ -687,11 +808,12 @@ def _message_to_generate_content_response(
687808 """
688809
689810parts = []
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 :
695817if tool_call .type == "function" :
696818part = types .Part .from_function_call (
697819name = tool_call .function .name ,