Skip to content

Commit e5fdb51

Browse files
GWealecopybara-github
authored andcommitted
perf(litellm): track brace depth incrementally for streaming tool-call args
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 945173968
1 parent b55a50d commit e5fdb51

2 files changed

Lines changed: 313 additions & 7 deletions

File tree

src/google/adk/models/lite_llm.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2564,6 +2564,50 @@ def _warn_gemini_via_litellm(model_string: str) -> None:
25642564
)
25652565

25662566

2567+
class _BraceDepthTracker:
2568+
"""Streams JSON characters and reports when a top-level object closes.
2569+
2570+
Only `{`/`}` are counted; `[`/`]` are ignored. Tool-call arguments per
2571+
the OpenAI/LiteLLM spec are always top-level JSON objects, never arrays,
2572+
so array depth is irrelevant for detecting when the top-level container
2573+
closes. Arrays nested as values (e.g. `{"a": [{"b": 1}]}`) still balance
2574+
correctly because chars inside the array don't change brace depth.
2575+
"""
2576+
2577+
__slots__ = ("_depth", "_in_string", "_escaped", "_seen_open")
2578+
2579+
def __init__(self) -> None:
2580+
self._depth = 0
2581+
self._in_string = False
2582+
self._escaped = False
2583+
self._seen_open = False
2584+
2585+
def feed(self, fragment: str) -> bool:
2586+
"""Feeds new chars; returns True iff a top-level object just closed."""
2587+
closed = False
2588+
for ch in fragment:
2589+
if self._in_string:
2590+
if self._escaped:
2591+
self._escaped = False
2592+
elif ch == "\\":
2593+
self._escaped = True
2594+
elif ch == '"':
2595+
self._in_string = False
2596+
continue
2597+
if ch == '"':
2598+
self._in_string = True
2599+
elif ch == "{":
2600+
self._depth += 1
2601+
self._seen_open = True
2602+
elif ch == "}":
2603+
if self._depth > 0:
2604+
self._depth -= 1
2605+
if self._depth == 0 and self._seen_open:
2606+
closed = True
2607+
self._seen_open = False
2608+
return closed
2609+
2610+
25672611
def _redirect_litellm_loggers_to_stdout() -> None:
25682612
"""Redirects LiteLLM loggers from stderr to stdout.
25692613
@@ -2714,6 +2758,7 @@ async def generate_content_async(
27142758
reasoning_parts: List[types.Part] = []
27152759
# Track function calls by index
27162760
function_calls = {} # index -> {name, args, id}
2761+
tool_call_trackers: Dict[int, _BraceDepthTracker] = {}
27172762
completion_args["stream"] = True
27182763
completion_args["stream_options"] = {"include_usage": True}
27192764
aggregated_llm_response = None
@@ -2803,6 +2848,7 @@ def _reset_stream_buffers() -> None:
28032848
text = ""
28042849
reasoning_parts = []
28052850
function_calls.clear()
2851+
tool_call_trackers.clear()
28062852

28072853
async for part in await self.llm_client.acompletion(**completion_args):
28082854
# Grounding metadata can arrive on the first chunk (search queries) or
@@ -2821,13 +2867,17 @@ def _reset_stream_buffers() -> None:
28212867
if chunk.args:
28222868
function_calls[index]["args"] += chunk.args
28232869

2824-
# check if args is completed (workaround for improper chunk
2825-
# indexing)
2826-
try:
2827-
json.loads(function_calls[index]["args"])
2828-
fallback_index += 1
2829-
except json.JSONDecodeError:
2830-
pass
2870+
# Detect args completion to advance fallback_index (workaround
2871+
# for improper chunk indexing) without O(N^2) re-parsing.
2872+
tracker = tool_call_trackers.setdefault(
2873+
index, _BraceDepthTracker()
2874+
)
2875+
if tracker.feed(chunk.args):
2876+
try:
2877+
json.loads(function_calls[index]["args"])
2878+
fallback_index += 1
2879+
except json.JSONDecodeError:
2880+
pass
28312881

28322882
function_calls[index]["id"] = (
28332883
chunk.id or function_calls[index]["id"] or str(index)

tests/unittests/models/test_litellm.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from google.adk.models.lite_llm import _aggregate_streaming_thought_parts
3131
from google.adk.models.lite_llm import _append_fallback_user_content_if_missing
32+
from google.adk.models.lite_llm import _BraceDepthTracker
3233
from google.adk.models.lite_llm import _content_to_message_param
3334
from google.adk.models.lite_llm import _convert_reasoning_value_to_parts
3435
from google.adk.models.lite_llm import _enforce_strict_openai_schema
@@ -6080,3 +6081,258 @@ async def test_generate_content_async_passes_http_options_extra_body(
60806081
assert "extra_body" in kwargs
60816082
assert kwargs["extra_body"]["custom_field"] == "custom_value"
60826083
assert kwargs["extra_body"]["priority"] == "high"
6084+
6085+
6086+
def _split_into_chunks(text, sizes):
6087+
pieces = []
6088+
pos = 0
6089+
for size in sizes:
6090+
pieces.append(text[pos : pos + size])
6091+
pos += size
6092+
if pos < len(text):
6093+
pieces.append(text[pos:])
6094+
return pieces
6095+
6096+
6097+
def test_brace_depth_tracker_simple_object():
6098+
tracker = _BraceDepthTracker()
6099+
assert tracker.feed('{"a": 1}') is True
6100+
6101+
6102+
def test_brace_depth_tracker_empty_object():
6103+
tracker = _BraceDepthTracker()
6104+
assert tracker.feed("{}") is True
6105+
6106+
6107+
def test_brace_depth_tracker_only_opens():
6108+
tracker = _BraceDepthTracker()
6109+
assert tracker.feed('{"a": ') is False
6110+
6111+
6112+
def test_brace_depth_tracker_completes_after_more_fragments():
6113+
tracker = _BraceDepthTracker()
6114+
assert tracker.feed('{"a": ') is False
6115+
assert tracker.feed('"b"') is False
6116+
assert tracker.feed("}") is True
6117+
6118+
6119+
def test_brace_depth_tracker_nested_objects():
6120+
tracker = _BraceDepthTracker()
6121+
assert tracker.feed('{"a": {"b": {"c": 1}}}') is True
6122+
6123+
6124+
def test_brace_depth_tracker_nested_split_across_fragments():
6125+
tracker = _BraceDepthTracker()
6126+
fragments = _split_into_chunks(
6127+
'{"a": {"b": {"c": 1}, "d": [1, 2, 3]}, "e": "f"}', [3, 5, 4, 7, 9, 2, 1]
6128+
)
6129+
closes = [tracker.feed(f) for f in fragments]
6130+
assert sum(closes) == 1
6131+
assert closes[-1] is True
6132+
6133+
6134+
def test_brace_depth_tracker_string_with_braces_ignored():
6135+
tracker = _BraceDepthTracker()
6136+
# Braces inside strings must not affect depth.
6137+
assert tracker.feed('{"x": "{}{{}}"}') is True
6138+
6139+
6140+
def test_brace_depth_tracker_string_with_braces_split_across_fragments():
6141+
tracker = _BraceDepthTracker()
6142+
fragments = ['{"x": "', "abc{def", "}ghi", '"}']
6143+
closes = [tracker.feed(f) for f in fragments]
6144+
assert closes == [False, False, False, True]
6145+
6146+
6147+
def test_brace_depth_tracker_escaped_quote_in_string():
6148+
tracker = _BraceDepthTracker()
6149+
# Escaped quote should not end the string; the trailing } closes the obj.
6150+
assert tracker.feed(r'{"x": "a\"b}c"}') is True
6151+
6152+
6153+
def test_brace_depth_tracker_escaped_backslash_then_quote_ends_string():
6154+
tracker = _BraceDepthTracker()
6155+
# \\ is an escaped backslash; the next " ends the string. Then } closes.
6156+
assert tracker.feed(r'{"x": "a\\"}') is True
6157+
6158+
6159+
def test_brace_depth_tracker_escape_split_across_fragments():
6160+
tracker = _BraceDepthTracker()
6161+
# Backslash arrives in one fragment, the escaped quote in the next.
6162+
fragments = ['{"x": "a', "\\", '"', 'b"}']
6163+
closes = [tracker.feed(f) for f in fragments]
6164+
assert closes == [False, False, False, True]
6165+
6166+
6167+
def test_brace_depth_tracker_two_consecutive_objects():
6168+
tracker = _BraceDepthTracker()
6169+
assert tracker.feed('{"a": 1}{"b": 2}') is True
6170+
6171+
6172+
def test_brace_depth_tracker_one_char_at_a_time():
6173+
tracker = _BraceDepthTracker()
6174+
text = '{"key": {"nested": "v{}al"}, "n": 42}'
6175+
closes = [tracker.feed(ch) for ch in text]
6176+
assert sum(closes) == 1
6177+
assert closes[-1] is True
6178+
6179+
6180+
def test_brace_depth_tracker_leading_whitespace_ignored():
6181+
tracker = _BraceDepthTracker()
6182+
assert tracker.feed(' \n {"a": 1}') is True
6183+
6184+
6185+
def _function_chunks_for_args(arg_fragments):
6186+
return [
6187+
FunctionChunk(
6188+
id="call_xyz" if i == 0 else None,
6189+
name="my_func" if i == 0 else None,
6190+
args=fragment,
6191+
index=0,
6192+
)
6193+
for i, fragment in enumerate(arg_fragments)
6194+
]
6195+
6196+
6197+
def _stream_chunks_from_function_chunks(function_chunks):
6198+
stream = []
6199+
for chunk in function_chunks:
6200+
delta_kwargs = {"role": "assistant"}
6201+
if chunk.args is not None:
6202+
delta_kwargs["tool_calls"] = [
6203+
ChatCompletionDeltaToolCall(
6204+
type="function",
6205+
id=chunk.id,
6206+
function=Function(name=chunk.name, arguments=chunk.args),
6207+
index=chunk.index,
6208+
)
6209+
]
6210+
stream.append(
6211+
ModelResponseStream(
6212+
choices=[
6213+
StreamingChoices(
6214+
finish_reason=None, delta=Delta(**delta_kwargs)
6215+
)
6216+
]
6217+
)
6218+
)
6219+
stream.append(
6220+
ModelResponseStream(
6221+
choices=[StreamingChoices(finish_reason="tool_calls", delta=Delta())]
6222+
)
6223+
)
6224+
return stream
6225+
6226+
6227+
@pytest.mark.asyncio
6228+
async def test_streaming_tool_call_args_assembled_from_many_fragments(
6229+
mock_completion, lite_llm_instance
6230+
):
6231+
full_args = (
6232+
'{"city": "San Francisco", "details": {"radius": 5, "tags": ["a{}",'
6233+
' "b\\"c"]}}'
6234+
)
6235+
fragments = _split_into_chunks(full_args, [4, 6, 1, 8, 3, 11, 2, 9, 7, 5, 1])
6236+
mock_completion.return_value = iter(
6237+
_stream_chunks_from_function_chunks(_function_chunks_for_args(fragments))
6238+
)
6239+
6240+
responses = [
6241+
r
6242+
async for r in lite_llm_instance.generate_content_async(
6243+
LLM_REQUEST_WITH_FUNCTION_DECLARATION, stream=True
6244+
)
6245+
]
6246+
6247+
assert len(responses) == 1
6248+
function_call = responses[0].content.parts[0].function_call
6249+
assert function_call.name == "my_func"
6250+
assert function_call.id == "call_xyz"
6251+
assert function_call.args == json.loads(full_args)
6252+
6253+
6254+
async def _count_full_buffer_loads(
6255+
lite_llm_instance, mock_completion, full_args, fragments
6256+
):
6257+
mock_completion.return_value = iter(
6258+
_stream_chunks_from_function_chunks(_function_chunks_for_args(fragments))
6259+
)
6260+
real_loads = json.loads
6261+
with patch(
6262+
"google.adk.models.lite_llm.json.loads", side_effect=real_loads
6263+
) as patched_loads:
6264+
responses = [
6265+
r
6266+
async for r in lite_llm_instance.generate_content_async(
6267+
LLM_REQUEST_WITH_FUNCTION_DECLARATION, stream=True
6268+
)
6269+
]
6270+
full_buffer_calls = [
6271+
c
6272+
for c in patched_loads.call_args_list
6273+
if c.args and c.args[0] == full_args
6274+
]
6275+
return responses, len(full_buffer_calls)
6276+
6277+
6278+
@pytest.mark.asyncio
6279+
async def test_streaming_tool_call_json_loads_count_independent_of_fragment_count(
6280+
mock_completion, lite_llm_instance
6281+
):
6282+
# The previous implementation called json.loads(buffer) after every
6283+
# fragment, so the count grew with the fragment count (O(N) calls and
6284+
# O(N^2) total parse cost). The fix makes the count constant.
6285+
full_args = '{"a": 1, "b": {"c": 2}}'
6286+
one_chunk = [full_args]
6287+
one_char_at_a_time = _split_into_chunks(full_args, [1] * len(full_args))
6288+
6289+
_, count_one_chunk = await _count_full_buffer_loads(
6290+
lite_llm_instance, mock_completion, full_args, one_chunk
6291+
)
6292+
_, count_many_chunks = await _count_full_buffer_loads(
6293+
lite_llm_instance, mock_completion, full_args, one_char_at_a_time
6294+
)
6295+
assert count_one_chunk == count_many_chunks
6296+
6297+
6298+
@pytest.mark.asyncio
6299+
async def test_streaming_tool_call_brace_in_string_does_not_falsely_complete(
6300+
mock_completion, lite_llm_instance
6301+
):
6302+
# The closing brace inside the string must not advance fallback_index.
6303+
# If it did, the second tool call would be merged into a single bucket
6304+
# and the assembled args would be invalid.
6305+
full_args_a = '{"text": "a{b}c"}'
6306+
full_args_b = '{"x": 1}'
6307+
fragments_a = _split_into_chunks(full_args_a, [1] * len(full_args_a))
6308+
fragments_b = _split_into_chunks(full_args_b, [1] * len(full_args_b))
6309+
6310+
function_chunks = _function_chunks_for_args(fragments_a)
6311+
# Second tool call: provider emits no index, relies on fallback_index advance.
6312+
for i, fragment in enumerate(fragments_b):
6313+
function_chunks.append(
6314+
FunctionChunk(
6315+
id="call_2" if i == 0 else None,
6316+
name="other_func" if i == 0 else None,
6317+
args=fragment,
6318+
index=0,
6319+
)
6320+
)
6321+
6322+
mock_completion.return_value = iter(
6323+
_stream_chunks_from_function_chunks(function_chunks)
6324+
)
6325+
6326+
responses = [
6327+
r
6328+
async for r in lite_llm_instance.generate_content_async(
6329+
LLM_REQUEST_WITH_FUNCTION_DECLARATION, stream=True
6330+
)
6331+
]
6332+
6333+
assert len(responses) == 1
6334+
parts = responses[0].content.parts
6335+
assert len(parts) == 2
6336+
args_by_name = {p.function_call.name: p.function_call.args for p in parts}
6337+
assert args_by_name["my_func"] == json.loads(full_args_a)
6338+
assert args_by_name["other_func"] == json.loads(full_args_b)

0 commit comments

Comments
 (0)