|
29 | 29 |
|
30 | 30 | from google.adk.models.lite_llm import _aggregate_streaming_thought_parts |
31 | 31 | from google.adk.models.lite_llm import _append_fallback_user_content_if_missing |
| 32 | +from google.adk.models.lite_llm import _BraceDepthTracker |
32 | 33 | from google.adk.models.lite_llm import _content_to_message_param |
33 | 34 | from google.adk.models.lite_llm import _convert_reasoning_value_to_parts |
34 | 35 | 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( |
6080 | 6081 | assert "extra_body" in kwargs |
6081 | 6082 | assert kwargs["extra_body"]["custom_field"] == "custom_value" |
6082 | 6083 | 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