litellm/litellm/interactions/streaming_iterator.py
Sameer Kankute f3a669fc5d
feat(interactions): migrate to Google Interactions API steps schema (May 2026) (#28153)
* feat(interactions): migrate to Google Interactions API steps schema (May 2026)

Default to Api-Revision: 2026-05-20 (new `steps` schema). Add
`litellm.use_legacy_interactions_schema` global flag that sends
Api-Revision: 2026-05-07 for operators who need the legacy `outputs`
schema until June 8, 2026.

- Inject Api-Revision header in GoogleAIStudioInteractionsConfig.validate_environment()
- Auto-coalesce response_mime_type → response_format and image_config migration on new schema
- Add steps field to InteractionsAPIResponse and InteractionsAPIStreamingResponse
- Add StepStart/StepDelta/StepStop/InteractionCreated/etc. SSE event types
- Update streaming completion detection to handle interaction.completed event
- Bridge transformer populates both outputs and steps fields
- Bridge streaming iterator emits new-schema events by default

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(interactions): address greptile review feedback

- Avoid mutating caller's generation_config dict by shallow-copying
  before popping image_config, preventing silent failures on retries
- Skip schema key in response_format when response_format is None to
  avoid sending schema: null to the Google Interactions API
- Remove delta field from step.stop events (new schema only); the
  StepStop model has no delta field and sending it duplicates already-
  streamed text and breaks spec-conformant clients

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(proxy): parse use_legacy_interactions_schema string values safely

bool("false") returns True in Python, so quoted YAML values like
"false" or "False" silently activated the legacy Interactions API
schema. Match the env-var parsing pattern in litellm/__init__.py by
treating string inputs as true only when they equal "true" (case
insensitive).

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(interactions): only set object/id/delta on step.stop for legacy schema

StepStop (new schema) has no object, id, or delta fields. Setting them
unconditionally caused spec-breaking extra fields on new-schema step.stop
events in all four construction sites (sync/async × main-loop/StopIteration).

Legacy content.stop still receives id, object, and delta unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(interactions): stabilize streaming bridge schema, dict aliasing, and lost first delta

- Capture use_legacy_interactions_schema once at iterator construction so
  all events emitted by a single stream use a consistent schema, even if
  the global flag is mutated mid-stream.
- Check for the buffered interaction.complete/completed event before the
  finished check in __next__/__anext__ so the final completion event
  (which carries the full collected text in steps) is not dropped after
  self.finished is set.
- Copy text content entries before appending to both outputs and the
  steps content list to avoid shared mutable dict aliasing between the
  two response fields.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix tests

* fix greptile review

* fix(interactions): address Greptile P1 review on schema coalescing and legacy deltas

Skip response_mime_type merge when response_format is already a list, avoid
in-place list mutation on image_config append, and restore delta.type on
legacy content.delta events.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(interactions): black-format gemini transformation.py

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 13:32:12 -07:00

275 lines
9.3 KiB
Python

"""
Streaming iterators for the Interactions API.
This module provides streaming iterators that properly stream SSE responses
from the Google Interactions API, similar to the responses API streaming iterator.
"""
import asyncio
import json
from datetime import datetime
from typing import Any, Dict, Optional
import httpx
from litellm._logging import verbose_logger
from litellm.constants import STREAM_SSE_DONE_STRING
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.llm_response_utils.get_api_base import get_api_base
from litellm.litellm_core_utils.thread_pool_executor import executor
from litellm.llms.base_llm.interactions.transformation import BaseInteractionsAPIConfig
from litellm.types.interactions import (
InteractionsAPIStreamingResponse,
)
from litellm.utils import CustomStreamWrapper
class BaseInteractionsAPIStreamingIterator:
"""
Base class for streaming iterators that process responses from the Interactions API.
This class contains shared logic for both synchronous and asynchronous iterators.
"""
def __init__(
self,
response: httpx.Response,
model: Optional[str],
interactions_api_config: BaseInteractionsAPIConfig,
logging_obj: LiteLLMLoggingObj,
litellm_metadata: Optional[Dict[str, Any]] = None,
custom_llm_provider: Optional[str] = None,
):
self.response = response
self.model = model
self.logging_obj = logging_obj
self.finished = False
self.interactions_api_config = interactions_api_config
self.completed_response: Optional[InteractionsAPIStreamingResponse] = None
self.start_time = datetime.now()
# set request kwargs
self.litellm_metadata = litellm_metadata
self.custom_llm_provider = custom_llm_provider
# set hidden params for response headers
_api_base = get_api_base(
model=model or "",
optional_params=self.logging_obj.model_call_details.get(
"litellm_params", {}
),
)
_model_info: Dict = (
litellm_metadata.get("model_info", {}) if litellm_metadata else {}
)
self._hidden_params = {
"model_id": _model_info.get("id", None),
"api_base": _api_base,
}
self._hidden_params["additional_headers"] = process_response_headers(
self.response.headers or {}
)
def _process_chunk(self, chunk: str) -> Optional[InteractionsAPIStreamingResponse]:
"""Process a single chunk of data from the stream."""
if not chunk:
return None
# Handle SSE format (data: {...})
stripped_chunk = CustomStreamWrapper._strip_sse_data_from_chunk(chunk)
if stripped_chunk is None:
return None
# Handle "[DONE]" marker
if stripped_chunk == STREAM_SSE_DONE_STRING:
self.finished = True
return None
try:
# Parse the JSON chunk
parsed_chunk = json.loads(stripped_chunk)
# Format as InteractionsAPIStreamingResponse
if isinstance(parsed_chunk, dict):
streaming_response = (
self.interactions_api_config.transform_streaming_response(
model=self.model,
parsed_chunk=parsed_chunk,
logging_obj=self.logging_obj,
)
)
# Store the completed response.
# Legacy schema signals completion via status="completed".
# New schema (Api-Revision: 2026-05-20) uses event_type="interaction.completed".
# Remove the legacy check after June 8, 2026.
if streaming_response and (
getattr(streaming_response, "status", None) == "completed"
or getattr(streaming_response, "event_type", None)
== "interaction.completed"
):
self.completed_response = streaming_response
self._handle_logging_completed_response()
return streaming_response
return None
except json.JSONDecodeError:
# If we can't parse the chunk, continue
verbose_logger.debug(
f"Failed to parse streaming chunk: {stripped_chunk[:200]}..."
)
return None
def _handle_logging_completed_response(self):
"""Base implementation - should be overridden by subclasses."""
pass
class InteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator):
"""
Async iterator for processing streaming responses from the Interactions API.
"""
def __init__(
self,
response: httpx.Response,
model: Optional[str],
interactions_api_config: BaseInteractionsAPIConfig,
logging_obj: LiteLLMLoggingObj,
litellm_metadata: Optional[Dict[str, Any]] = None,
custom_llm_provider: Optional[str] = None,
):
super().__init__(
response=response,
model=model,
interactions_api_config=interactions_api_config,
logging_obj=logging_obj,
litellm_metadata=litellm_metadata,
custom_llm_provider=custom_llm_provider,
)
self.stream_iterator = response.aiter_lines()
def __aiter__(self):
return self
async def __anext__(self) -> InteractionsAPIStreamingResponse:
try:
while True:
# Get the next chunk from the stream
try:
chunk = await self.stream_iterator.__anext__()
except StopAsyncIteration:
self.finished = True
raise StopAsyncIteration
result = self._process_chunk(chunk)
if self.finished:
raise StopAsyncIteration
elif result is not None:
return result
# If result is None, continue the loop to get the next chunk
except httpx.HTTPError as e:
# Handle HTTP errors
self.finished = True
raise e
def _handle_logging_completed_response(self):
"""Handle logging for completed responses in async context."""
import copy
logging_response = copy.deepcopy(self.completed_response)
asyncio.create_task(
self.logging_obj.async_success_handler(
result=logging_response,
start_time=self.start_time,
end_time=datetime.now(),
cache_hit=None,
)
)
executor.submit(
self.logging_obj.success_handler,
result=logging_response,
cache_hit=None,
start_time=self.start_time,
end_time=datetime.now(),
)
class SyncInteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator):
"""
Synchronous iterator for processing streaming responses from the Interactions API.
"""
def __init__(
self,
response: httpx.Response,
model: Optional[str],
interactions_api_config: BaseInteractionsAPIConfig,
logging_obj: LiteLLMLoggingObj,
litellm_metadata: Optional[Dict[str, Any]] = None,
custom_llm_provider: Optional[str] = None,
):
super().__init__(
response=response,
model=model,
interactions_api_config=interactions_api_config,
logging_obj=logging_obj,
litellm_metadata=litellm_metadata,
custom_llm_provider=custom_llm_provider,
)
self.stream_iterator = response.iter_lines()
def __iter__(self):
return self
def __next__(self) -> InteractionsAPIStreamingResponse:
try:
while True:
# Get the next chunk from the stream
try:
chunk = next(self.stream_iterator)
except StopIteration:
self.finished = True
raise StopIteration
result = self._process_chunk(chunk)
if self.finished:
raise StopIteration
elif result is not None:
return result
# If result is None, continue the loop to get the next chunk
except httpx.HTTPError as e:
# Handle HTTP errors
self.finished = True
raise e
def _handle_logging_completed_response(self):
"""Handle logging for completed responses in sync context."""
import copy
logging_response = copy.deepcopy(self.completed_response)
run_async_function(
async_function=self.logging_obj.async_success_handler,
result=logging_response,
start_time=self.start_time,
end_time=datetime.now(),
cache_hit=None,
)
executor.submit(
self.logging_obj.success_handler,
result=logging_response,
cache_hit=None,
start_time=self.start_time,
end_time=datetime.now(),
)