mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(vertex): incrementally parse accumulated Gemini stream JSON to prevent multi-value wedge (#34320)
The accumulated-JSON fallback ran json.loads over the whole buffer after every fragment and, on failure, kept the buffer without resetting it. A buffer that ever held more than one concatenated JSON value could never parse (json raises on trailing data), so it returned None on every subsequent chunk while growing without bound - an unrecoverable per-request CPU spin. Parse one value at a time from the front with raw_decode and keep the remainder, draining trailing values on later calls and at end of stream.
This commit is contained in:
parent
692b22655e
commit
f6a1050cbf
2 changed files with 147 additions and 20 deletions
|
|
@ -3333,27 +3333,37 @@ class ModelResponseIterator:
|
|||
|
||||
return self.chunk_parser(chunk=json_chunk)
|
||||
|
||||
def handle_accumulated_json_chunk(self, chunk: str) -> Optional["ModelResponseStream"]:
|
||||
chunk = litellm.CustomStreamWrapper._strip_sse_data_from_chunk(chunk) or ""
|
||||
message = chunk.replace("\n\n", "")
|
||||
def handle_accumulated_json_chunk(self, chunk: str, is_final: bool = False) -> Optional["ModelResponseStream"]:
|
||||
message = litellm.CustomStreamWrapper._strip_sse_data_from_chunk(chunk) or ""
|
||||
self.accumulated_json = (self.accumulated_json + message.replace("\n\n", "")).strip()
|
||||
|
||||
self.accumulated_json += message
|
||||
|
||||
# json.loads on the whole buffer after every fragment is O(n^2) and
|
||||
# holds the GIL, freezing the event loop for seconds on large responses
|
||||
# (https://github.com/BerriAI/litellm/issues/26181). A complete Gemini
|
||||
# chunk is a JSON object/array, so only attempt the parse once the
|
||||
# buffer's last non-whitespace byte can close one.
|
||||
stripped = self.accumulated_json.rstrip()
|
||||
if not stripped or stripped[-1] not in "}]":
|
||||
# Mid-stream, defer parsing until the buffer's last byte can close a value:
|
||||
# attempting a parse after every fragment of one large object is O(n^2) and
|
||||
# holds the GIL, freezing the event loop. At end of stream (is_final) no more
|
||||
# data is coming, so drain whatever complete values remain regardless of the
|
||||
# trailing byte, otherwise a complete leading value sitting behind a truncated
|
||||
# trailing one would be silently dropped.
|
||||
if not is_final and (not self.accumulated_json or self.accumulated_json[-1] not in "}]"):
|
||||
return None
|
||||
|
||||
try:
|
||||
_data = json.loads(self.accumulated_json)
|
||||
self.accumulated_json = "" # reset after successful parsing
|
||||
return self.chunk_parser(chunk=_data)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
# Peel one complete JSON value from the front of the buffer and keep the
|
||||
# unconsumed tail. Running json.loads over the whole buffer would fail
|
||||
# forever once it held more than one concatenated value ("Extra data") while
|
||||
# never resetting the buffer, so the buffer grew without bound and pinned the
|
||||
# core. raw_decode reports where the value ended, so concatenated values drain
|
||||
# one call at a time. A leading non-dict value (never emitted by Gemini in
|
||||
# practice) is consumed and skipped so it cannot block the dict values behind it.
|
||||
decoder = json.JSONDecoder()
|
||||
while self.accumulated_json:
|
||||
try:
|
||||
raw_value = decoder.raw_decode(self.accumulated_json)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
decoded, end_index = cast("tuple[object, int]", raw_value) # cast-ok: raw_decode -> tuple[Any,int]
|
||||
self.accumulated_json = self.accumulated_json[end_index:].strip()
|
||||
if isinstance(decoded, dict):
|
||||
return self.chunk_parser(chunk=decoded)
|
||||
return None
|
||||
|
||||
def _common_chunk_parsing_logic(self, chunk: str) -> Optional["ModelResponseStream"]:
|
||||
try:
|
||||
|
|
@ -3378,7 +3388,9 @@ class ModelResponseIterator:
|
|||
chunk = self.response_iterator.__next__()
|
||||
except StopIteration:
|
||||
if self.chunk_type == "accumulated_json" and self.accumulated_json:
|
||||
return self.handle_accumulated_json_chunk(chunk="")
|
||||
result = self.handle_accumulated_json_chunk(chunk="", is_final=True)
|
||||
if result is not None:
|
||||
return result
|
||||
raise StopIteration
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"Error receiving chunk from stream: {e}")
|
||||
|
|
@ -3400,7 +3412,9 @@ class ModelResponseIterator:
|
|||
chunk = await self.async_response_iterator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
if self.chunk_type == "accumulated_json" and self.accumulated_json:
|
||||
return self.handle_accumulated_json_chunk(chunk="")
|
||||
result = self.handle_accumulated_json_chunk(chunk="", is_final=True)
|
||||
if result is not None:
|
||||
return result
|
||||
raise StopAsyncIteration
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"Error receiving chunk from stream: {e}")
|
||||
|
|
|
|||
|
|
@ -5407,3 +5407,116 @@ def test_process_candidates_merges_thought_signatures_and_server_side_tools():
|
|||
fields = model_response.choices[-1].message.provider_specific_fields
|
||||
assert fields["thought_signatures"] == ["sig-text"]
|
||||
assert fields["server_side_tool_invocations"][0]["id"] == "tool-1"
|
||||
|
||||
|
||||
def _accumulating_gemini_iterator():
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
ModelResponseIterator,
|
||||
)
|
||||
|
||||
iterator = ModelResponseIterator(
|
||||
streaming_response=[], sync_stream=True, logging_obj=MagicMock()
|
||||
)
|
||||
iterator.chunk_type = "accumulated_json"
|
||||
return iterator
|
||||
|
||||
|
||||
def test_accumulated_json_chunk_multi_value_buffer_does_not_wedge():
|
||||
"""Two complete Gemini objects buffered together must both surface.
|
||||
|
||||
A whole-buffer json.loads raises "Extra data" on concatenated values and, since
|
||||
the buffer was never reset on failure, returned None forever while growing without
|
||||
bound. Peeling one value from the front keeps the remainder for the next call.
|
||||
"""
|
||||
obj = '{"candidates":[{"content":{"parts":[{"text":"a"}]}}],"usageMetadata":{}}'
|
||||
iterator = _accumulating_gemini_iterator()
|
||||
|
||||
first = iterator.handle_accumulated_json_chunk(chunk=obj + obj)
|
||||
assert first is not None
|
||||
assert first.choices[0].delta.content == "a"
|
||||
|
||||
second = iterator.handle_accumulated_json_chunk(chunk="")
|
||||
assert second is not None
|
||||
assert second.choices[0].delta.content == "a"
|
||||
|
||||
assert iterator.accumulated_json.strip() == ""
|
||||
|
||||
|
||||
def test_accumulated_json_end_of_stream_drains_all_buffered_values():
|
||||
"""End of stream must drain every buffered value and then terminate.
|
||||
|
||||
With concatenated values a whole-buffer parse never succeeds, so __next__ kept
|
||||
returning None without shrinking the buffer - an unrecoverable per-request spin.
|
||||
The bounded loop asserts the iterator both surfaces all values and terminates.
|
||||
"""
|
||||
obj = '{"candidates":[{"content":{"parts":[{"text":"a"}]}}],"usageMetadata":{}}'
|
||||
iterator = _accumulating_gemini_iterator()
|
||||
iterator.response_iterator = iter([])
|
||||
iterator.accumulated_json = obj + obj + obj
|
||||
|
||||
out = []
|
||||
terminated = False
|
||||
for _ in range(100):
|
||||
try:
|
||||
chunk = iterator.__next__()
|
||||
except StopIteration:
|
||||
terminated = True
|
||||
break
|
||||
if chunk is not None:
|
||||
out.append(chunk)
|
||||
|
||||
assert terminated, "iterator did not terminate - accumulated buffer wedged"
|
||||
assert len(out) == 3
|
||||
assert iterator.accumulated_json.strip() == ""
|
||||
|
||||
|
||||
def test_accumulated_json_end_of_stream_surfaces_leading_value_before_truncated_tail():
|
||||
"""A complete leading value must survive a truncated trailing value at end of stream.
|
||||
|
||||
The mid-stream perf guard only inspects the buffer's last byte, so a complete leading
|
||||
object followed by a truncated one (a server that cut the stream mid-object, last byte
|
||||
not a closer) would keep the guard from ever parsing and drop the complete value. At end
|
||||
of stream the drain ignores that guard, surfaces the complete value, and discards only
|
||||
the truncated tail.
|
||||
"""
|
||||
obj = '{"candidates":[{"content":{"parts":[{"text":"a"}]}}],"usageMetadata":{}}'
|
||||
iterator = _accumulating_gemini_iterator()
|
||||
iterator.response_iterator = iter([])
|
||||
iterator.accumulated_json = obj + '{"candidates":'
|
||||
|
||||
out = []
|
||||
for _ in range(100):
|
||||
try:
|
||||
chunk = iterator.__next__()
|
||||
except StopIteration:
|
||||
break
|
||||
if chunk is not None:
|
||||
out.append(chunk)
|
||||
|
||||
assert len(out) == 1
|
||||
assert out[0].choices[0].delta.content == "a"
|
||||
|
||||
|
||||
def test_accumulated_json_skips_non_dict_leading_value():
|
||||
"""A non-dict value at the front must not block the dict values behind it.
|
||||
|
||||
raw_decode advances past a decoded value, so a leading non-dict (a JSON array or scalar,
|
||||
which Gemini never emits but a malformed stream could) must be consumed and skipped. If
|
||||
the drain stopped on it, the trailing objects would be lost at end of stream.
|
||||
"""
|
||||
obj = '{"candidates":[{"content":{"parts":[{"text":"a"}]}}],"usageMetadata":{}}'
|
||||
iterator = _accumulating_gemini_iterator()
|
||||
iterator.response_iterator = iter([])
|
||||
iterator.accumulated_json = "[1, 2]" + obj
|
||||
|
||||
out = []
|
||||
for _ in range(100):
|
||||
try:
|
||||
chunk = iterator.__next__()
|
||||
except StopIteration:
|
||||
break
|
||||
if chunk is not None:
|
||||
out.append(chunk)
|
||||
|
||||
assert len(out) == 1
|
||||
assert out[0].choices[0].delta.content == "a"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue