litellm/tests/unit/interactions/test_interactions_streaming_iterator.py
rj-cairn d06b889d41
fix(streaming): keep merged think tags balanced and split SSE lines only on CR/LF (#48755)
* fix(vertex_ai): split Gemini SSE lines on CR/LF only so U+2028 in text keeps the stream

The Gemini and Vertex AI streaming path read the SSE body with httpx
iter_lines()/aiter_lines(). Those follow str.splitlines(), which also breaks
on U+0085, U+2028, U+2029, \x0b, \x0c and \x1c-\x1e. JSON leaves U+0085,
U+2028 and U+2029 unescaped inside strings, so a data: line whose text holds
one of them was cut in two. The tail is not a data: line and the head never
parses, and the accumulate-and-retry fallback then carries the unparsed
prefix forward, so every later event is appended to it and the stream ends
with no more content and no usage.

SSE lines end only at CRLF, LF or CR. The new iter_sse_lines/aiter_sse_lines
split decoded text on exactly those, treating a CR at the end of one chunk and
an LF at the start of the next as one break. The first request and every
long-decode hop now read through them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(streaming): keep merged think tags balanced and split shared SSE lines only on CR/LF

Close an open <think> block on the first content or tool-call delta and at stream end, reopen it when reasoning returns, and route the shared chat completions stream through the SSE line splitter so U+2028 and U+2029 inside JSON no longer drop chunks.

* fix(streaming): exact-match the SSE [DONE] marker in BaseModelResponseIterator

* fix(streaming): split every SSE reader on CR/LF only and keep the logged think block closed

Switch the remaining httpx stream readers (Anthropic chat, Google GenAI,
Interactions, Codestral, openai_like, Predibase, LangGraph, Vertex Agent
Engine, A2A providers, Azure AI agents, sandboxes, the A2A endpoint, and
others) from iter_lines/aiter_lines to iter_sse_lines/aiter_sse_lines, so a
raw U+2028 or U+2029 inside a JSON string no longer splits an event.

Keep the chunk that closes a think block in the logged and cached response
when a stream ends without a finish_reason, close the block before a legacy
function_call, and make sync Codestral FIM streaming send stream=true and use
the text-completion-codestral stream parser.

Test fakes that only mocked iter_lines now stream real httpx bytes and
assert the content they feed in.

* fix(streaming): split buffered SSE parsers only on CR and LF, shard the codestral tests

Buffered SSE and JSONL parsers used str.splitlines(), which also breaks on
U+2028, U+2029 and U+0085 that JSON allows raw inside strings. They now use
split_sse_lines from sse_lines.py, which also gains a str.split fast path.
Adds tests/unit/llms/codestral to the unit workflow shard.

* fix(streaming): keep U+2028 in restamped Anthropic frames and fold CRLF before splitting SSE lines

* test: type the new stream tests, drop a comment, and benchmark the SSE line splitter

* test: type the iterator class parameters in the line-separator stream tests

* fix(responses): stream tool call arguments opened in a text chunk

* fix(responses): hold a tool call that shares a chunk with text until the text ends

* test: type the a2a SSE handler helper and the sandbox fake responses

---------

Co-authored-by: Tim Vink <vinktim@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: michelligabriele <gabriele.michelli@icloud.com>
2026-10-28 17:00:29 -07:00

193 lines
7 KiB
Python

"""
Regression test for LIT-4210: completing an async Interactions API stream must
not run the sync success_handler on the thread-pool executor concurrently with
async_success_handler (cross-thread pydantic mutation segfaults pydantic-core).
"""
import asyncio
import json
import time
from datetime import datetime
from typing import Final
import httpx
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.interactions import streaming_iterator as interactions_streaming_iterator_module
from litellm.interactions.streaming_iterator import (
InteractionsAPIStreamingIterator,
SyncInteractionsAPIStreamingIterator,
)
from litellm.litellm_core_utils import thread_pool_executor as thread_pool_executor_module
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.llms.gemini.interactions.transformation import GoogleAIStudioInteractionsConfig
from litellm.types.interactions import InteractionsAPIStreamingResponse
class RecordingCustomLogger(CustomLogger):
def __init__(self):
super().__init__()
self.async_hook_fired = False
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self.async_hook_fired = True
async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time):
self.async_hook_fired = True
class RecordingExecutor:
def __init__(self, inner):
self._inner = inner
self.submits: list = []
def submit(self, fn, *args, **kwargs):
self.submits.append(fn)
return self._inner.submit(fn, *args, **kwargs)
def submitted_for(self, logging_obj) -> list:
return [fn for fn in self.submits if getattr(fn, "__self__", None) is logging_obj]
@pytest.fixture(autouse=True)
def _isolate_callbacks():
saved = (
litellm.callbacks,
litellm.success_callback,
litellm._async_success_callback,
litellm.failure_callback,
litellm._async_failure_callback,
)
yield
(
litellm.callbacks,
litellm.success_callback,
litellm._async_success_callback,
litellm.failure_callback,
litellm._async_failure_callback,
) = saved
@pytest.mark.asyncio
async def test_custom_logger_only_never_submits_sync_success_handler(monkeypatch):
recording_executor = RecordingExecutor(thread_pool_executor_module.executor)
monkeypatch.setattr(thread_pool_executor_module, "executor", recording_executor)
monkeypatch.setattr(interactions_streaming_iterator_module, "executor", recording_executor)
recorder = RecordingCustomLogger()
litellm.success_callback = [recorder]
litellm._async_success_callback = [recorder]
logging_obj = LitellmLogging(
model="gemini/gemini-3-pro-preview",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="ainteraction",
start_time=time.time(),
litellm_call_id="lit-4210-test",
function_id="lit-4210-test",
)
iterator = InteractionsAPIStreamingIterator(
response=httpx.Response(200),
model="gemini/gemini-3-pro-preview",
interactions_api_config=None,
logging_obj=logging_obj,
)
iterator.completed_response = InteractionsAPIStreamingResponse()
existing_tasks = asyncio.all_tasks()
iterator._handle_logging_completed_response()
spawned_tasks = asyncio.all_tasks() - existing_tasks
await asyncio.wait_for(asyncio.gather(*spawned_tasks, return_exceptions=True), timeout=5)
assert recorder.async_hook_fired is True
assert recording_executor.submitted_for(logging_obj) == []
class _SuccessRecorder(CustomLogger):
def __init__(self):
super().__init__()
self.logged: list[tuple[datetime, object]] = []
async def async_log_success_event(
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
self.logged.append((start_time, kwargs.get("completion_start_time")))
@pytest.mark.asyncio
async def test_stream_logs_its_first_event_time_measured_from_the_call_start():
call_start: Final = datetime(2026, 10, 9, 12, 0, 0)
recorder: Final = _SuccessRecorder()
logging_obj: Final = LitellmLogging(
model="gemini/gemini-3-pro-preview",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="ainteraction",
start_time=call_start,
litellm_call_id="interactions-stream",
function_id="interactions-stream",
dynamic_async_success_callbacks=[recorder],
)
events: Final = (
{"event_type": "step.delta", "id": "interaction-1"},
{"event_type": "interaction.completed", "id": "interaction-1", "status": "completed"},
)
response: Final = httpx.Response(200, content="".join(f"data: {json.dumps(e)}\n\n" for e in events).encode())
iterator: Final = InteractionsAPIStreamingIterator(
response=response,
model="gemini/gemini-3-pro-preview",
interactions_api_config=GoogleAIStudioInteractionsConfig(),
logging_obj=logging_obj,
)
tasks_before: Final = asyncio.all_tasks()
_ = await iterator.__anext__()
first_event_at: Final = logging_obj.completion_start_time
_ = [event async for event in iterator]
await asyncio.gather(*(asyncio.all_tasks() - tasks_before))
assert first_event_at is not None
assert recorder.logged == [(call_start, first_event_at)]
_LINE_SEPARATED_TEXT: Final = "before\u2028between\u2029after"
async def _first_event(
iterator: InteractionsAPIStreamingIterator | SyncInteractionsAPIStreamingIterator,
) -> InteractionsAPIStreamingResponse:
if isinstance(iterator, InteractionsAPIStreamingIterator):
return await iterator.__anext__()
return next(iterator)
@pytest.mark.asyncio
@pytest.mark.parametrize("iterator_cls", [InteractionsAPIStreamingIterator, SyncInteractionsAPIStreamingIterator])
async def test_stream_keeps_unicode_line_separators_inside_the_delta_text(
iterator_cls: type[InteractionsAPIStreamingIterator] | type[SyncInteractionsAPIStreamingIterator],
) -> None:
delta: Final = {"type": "text", "text": _LINE_SEPARATED_TEXT}
event: Final = {"event_type": "step.delta", "id": "interaction-1", "delta": delta}
iterator: Final = iterator_cls(
response=httpx.Response(
200, stream=httpx.ByteStream(f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode())
),
model="gemini/gemini-3.1-pro-preview",
interactions_api_config=GoogleAIStudioInteractionsConfig(),
logging_obj=LitellmLogging(
model="gemini/gemini-3.1-pro-preview",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="ainteraction",
start_time=datetime(2026, 10, 9, 12, 0, 0),
litellm_call_id="interactions-separators",
function_id="interactions-separators",
),
)
first: Final = await _first_event(iterator)
assert first.delta == delta