mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-01 16:21:12 +00:00
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>
This commit is contained in:
co-authored by
Cursor
Yassin Kortam
Claude
parent
68efe6970c
commit
f3a669fc5d
@@ -225,6 +225,10 @@ use_chat_completions_url_for_anthropic_messages: bool = bool(
|
||||
route_all_chat_openai_to_responses: bool = (
|
||||
os.getenv("LITELLM_ROUTE_ALL_CHAT_OPENAI_TO_RESPONSES", "false").lower() == "true"
|
||||
) # When True, routes all OpenAI /chat/completions requests through the Responses API bridge
|
||||
use_legacy_interactions_schema: bool = (
|
||||
os.getenv("LITELLM_USE_LEGACY_INTERACTIONS_SCHEMA", "false").lower() == "true"
|
||||
) # When True, sends Api-Revision: 2026-05-07 to Google so responses use the legacy `outputs`
|
||||
# schema instead of the new `steps` schema. Remove this flag after June 8, 2026.
|
||||
retry = True
|
||||
### AUTH ###
|
||||
api_key: Optional[str] = None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Streaming iterator for transforming Responses API stream to Interactions API stream.
|
||||
"""
|
||||
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, cast
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, Optional, cast
|
||||
|
||||
from litellm.responses.streaming_iterator import (
|
||||
BaseResponsesAPIStreamingIterator,
|
||||
@@ -15,7 +15,6 @@ from litellm.types.interactions import (
|
||||
InteractionsAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
ContentPartAddedEvent,
|
||||
OutputTextDeltaEvent,
|
||||
ResponseCompletedEvent,
|
||||
ResponseCreatedEvent,
|
||||
@@ -30,7 +29,13 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
|
||||
This class handles both sync and async iteration, transforming Responses API
|
||||
streaming events (output.text.delta, response.completed, etc.) to Interactions
|
||||
API streaming events (content.delta, interaction.complete, etc.).
|
||||
API streaming events.
|
||||
|
||||
Schema selection:
|
||||
- New schema (default, use_legacy_interactions_schema=False):
|
||||
interaction.created → step.start → step.delta … → step.stop → interaction.completed
|
||||
- Legacy schema (use_legacy_interactions_schema=True, remove after June 8 2026):
|
||||
interaction.start → content.start → content.delta … → content.stop → interaction.complete
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -42,6 +47,8 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
litellm_metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
import litellm
|
||||
|
||||
self.model = model
|
||||
self.responses_stream_iterator = litellm_custom_stream_wrapper
|
||||
self.request_input = request_input
|
||||
@@ -52,7 +59,10 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
self.collected_text = ""
|
||||
self.sent_interaction_start = False
|
||||
self.sent_content_start = False
|
||||
self._pending_events: List[InteractionsAPIStreamingResponse] = []
|
||||
# Capture the schema flag once at construction time so all events
|
||||
# emitted by this stream use a consistent schema, even if the global
|
||||
# flag is mutated mid-stream (e.g. by a config reload).
|
||||
self._use_legacy: bool = litellm.use_legacy_interactions_schema
|
||||
|
||||
def _transform_responses_chunk_to_interactions_chunk(
|
||||
self,
|
||||
@@ -61,91 +71,78 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
"""
|
||||
Transform a Responses API streaming chunk to an Interactions API streaming chunk.
|
||||
|
||||
Responses API events:
|
||||
- output.text.delta -> content.delta
|
||||
- response.completed -> interaction.complete
|
||||
|
||||
Interactions API events:
|
||||
- interaction.start
|
||||
- content.start
|
||||
- content.delta
|
||||
- content.stop
|
||||
- interaction.complete
|
||||
Emits new-schema events by default; falls back to legacy events when
|
||||
``litellm.use_legacy_interactions_schema`` is True.
|
||||
Remove legacy branch after June 8, 2026.
|
||||
"""
|
||||
if not responses_chunk:
|
||||
return None
|
||||
|
||||
# Handle OutputTextDeltaEvent -> content.delta
|
||||
use_legacy = self._use_legacy
|
||||
|
||||
# Handle OutputTextDeltaEvent
|
||||
if isinstance(responses_chunk, OutputTextDeltaEvent):
|
||||
delta_text = (
|
||||
responses_chunk.delta if isinstance(responses_chunk.delta, str) else ""
|
||||
)
|
||||
self.collected_text += delta_text
|
||||
item_id = (
|
||||
getattr(responses_chunk, "item_id", None) or f"interaction_{id(self)}"
|
||||
)
|
||||
|
||||
# Fallback: emit interaction.start, and queue content.start carrying this
|
||||
# delta so the first token is preserved in the stream.
|
||||
# Send the "interaction started" event on the first delta
|
||||
if not self.sent_interaction_start:
|
||||
self.sent_interaction_start = True
|
||||
self.sent_content_start = True
|
||||
self._pending_events.append(
|
||||
InteractionsAPIStreamingResponse(
|
||||
event_type="content.start",
|
||||
id=getattr(responses_chunk, "item_id", None),
|
||||
object="content",
|
||||
delta={"type": "text", "text": delta_text},
|
||||
if use_legacy:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.start",
|
||||
id=item_id,
|
||||
object="interaction",
|
||||
status="in_progress",
|
||||
model=self.model,
|
||||
)
|
||||
else:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.created",
|
||||
id=item_id,
|
||||
object="interaction",
|
||||
status="in_progress",
|
||||
model=self.model,
|
||||
)
|
||||
)
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.start",
|
||||
id=getattr(responses_chunk, "item_id", None)
|
||||
or f"interaction_{id(self)}",
|
||||
object="interaction",
|
||||
status="in_progress",
|
||||
model=self.model,
|
||||
)
|
||||
|
||||
# Fallback: emit content.start if ContentPartAddedEvent never arrived
|
||||
# Send the "content/step started" event on the second delta
|
||||
if not self.sent_content_start:
|
||||
self.sent_content_start = True
|
||||
if use_legacy:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="content.start",
|
||||
id=item_id,
|
||||
object="content",
|
||||
delta={"type": "text", "text": ""},
|
||||
)
|
||||
else:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="step.start",
|
||||
index=0,
|
||||
step={"type": "model_output", "content": []},
|
||||
)
|
||||
|
||||
# Emit the delta itself
|
||||
if use_legacy:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="content.start",
|
||||
id=getattr(responses_chunk, "item_id", None),
|
||||
event_type="content.delta",
|
||||
id=item_id,
|
||||
object="content",
|
||||
delta={"type": "text", "text": delta_text},
|
||||
)
|
||||
|
||||
# Normal path: emit content.delta with type field
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="content.delta",
|
||||
id=getattr(responses_chunk, "item_id", None),
|
||||
object="content",
|
||||
delta={"type": "text", "text": delta_text},
|
||||
)
|
||||
|
||||
# Handle ContentPartAddedEvent -> content.start (arrives before text deltas)
|
||||
if isinstance(responses_chunk, ContentPartAddedEvent):
|
||||
# Fallback: emit interaction.start if ResponseCreatedEvent never arrived
|
||||
if not self.sent_interaction_start:
|
||||
self.sent_interaction_start = True
|
||||
else:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.start",
|
||||
id=getattr(responses_chunk, "item_id", None)
|
||||
or f"interaction_{id(self)}",
|
||||
object="interaction",
|
||||
status="in_progress",
|
||||
model=self.model,
|
||||
event_type="step.delta",
|
||||
index=0,
|
||||
delta={"type": "text", "text": delta_text},
|
||||
)
|
||||
if not self.sent_content_start:
|
||||
self.sent_content_start = True
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="content.start",
|
||||
id=getattr(responses_chunk, "item_id", None),
|
||||
object="content",
|
||||
delta={"type": "text", "text": ""},
|
||||
)
|
||||
return None
|
||||
|
||||
# Handle ResponseCreatedEvent or ResponseInProgressEvent -> interaction.start
|
||||
# Handle ResponseCreatedEvent or ResponseInProgressEvent
|
||||
if isinstance(responses_chunk, (ResponseCreatedEvent, ResponseInProgressEvent)):
|
||||
if not self.sent_interaction_start:
|
||||
self.sent_interaction_start = True
|
||||
@@ -153,39 +150,47 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
getattr(responses_chunk.response, "id", None)
|
||||
if hasattr(responses_chunk, "response")
|
||||
else None
|
||||
) or f"interaction_{id(self)}"
|
||||
event_type = (
|
||||
"interaction.start" if use_legacy else "interaction.created"
|
||||
)
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.start",
|
||||
id=response_id or f"interaction_{id(self)}",
|
||||
event_type=event_type,
|
||||
id=response_id,
|
||||
object="interaction",
|
||||
status="in_progress",
|
||||
model=self.model,
|
||||
)
|
||||
|
||||
# Handle ResponseCompletedEvent -> interaction.complete
|
||||
# Handle ResponseCompletedEvent
|
||||
if isinstance(responses_chunk, ResponseCompletedEvent):
|
||||
self.finished = True
|
||||
response = responses_chunk.response
|
||||
response_id = getattr(response, "id", None) or f"interaction_{id(self)}"
|
||||
|
||||
# Send content.stop first if content was started
|
||||
if self.sent_content_start:
|
||||
# Note: We'll send this in the iterator, not here
|
||||
pass
|
||||
|
||||
# Send interaction.complete
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.complete",
|
||||
id=getattr(response, "id", None) or f"interaction_{id(self)}",
|
||||
object="interaction",
|
||||
status="completed",
|
||||
model=self.model,
|
||||
outputs=[
|
||||
{
|
||||
"type": "text",
|
||||
"text": self.collected_text,
|
||||
}
|
||||
],
|
||||
)
|
||||
if use_legacy:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.complete",
|
||||
id=response_id,
|
||||
object="interaction",
|
||||
status="completed",
|
||||
model=self.model,
|
||||
outputs=[{"type": "text", "text": self.collected_text}],
|
||||
)
|
||||
else:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.completed",
|
||||
id=response_id,
|
||||
object="interaction",
|
||||
status="completed",
|
||||
model=self.model,
|
||||
steps=[
|
||||
{
|
||||
"type": "model_output",
|
||||
"content": [{"type": "text", "text": self.collected_text}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# For other event types, return None (skip)
|
||||
return None
|
||||
@@ -196,10 +201,9 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
|
||||
def __next__(self) -> InteractionsAPIStreamingResponse:
|
||||
"""Get next chunk in sync mode."""
|
||||
if self.finished:
|
||||
raise StopIteration
|
||||
|
||||
# Check if we have a pending interaction.complete to send
|
||||
# Check for a pending interaction.complete/completed event BEFORE the
|
||||
# finished check — otherwise the buffered completion event (which
|
||||
# carries the full text) would be dropped after `self.finished` is set.
|
||||
if hasattr(self, "_pending_interaction_complete"):
|
||||
pending: InteractionsAPIStreamingResponse = getattr(
|
||||
self, "_pending_interaction_complete"
|
||||
@@ -207,10 +211,9 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
delattr(self, "_pending_interaction_complete")
|
||||
return pending
|
||||
|
||||
# Drain events queued from a prior chunk (e.g. content.start emitted alongside
|
||||
# the interaction.start fallback for the first OutputTextDeltaEvent).
|
||||
if self._pending_events:
|
||||
return self._pending_events.pop(0)
|
||||
if self.finished:
|
||||
raise StopIteration
|
||||
|
||||
# Use a loop instead of recursion to avoid stack overflow
|
||||
sync_iterator = cast(
|
||||
SyncResponsesAPIStreamingIterator, self.responses_stream_iterator
|
||||
@@ -226,22 +229,34 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
)
|
||||
|
||||
if transformed:
|
||||
# If we finished and content was started, send content.stop before interaction.complete
|
||||
completion_event_type = (
|
||||
"interaction.complete"
|
||||
if self._use_legacy
|
||||
else "interaction.completed"
|
||||
)
|
||||
stop_event_type = (
|
||||
"content.stop" if self._use_legacy else "step.stop"
|
||||
)
|
||||
# If content was started, send the stop event before the completion event.
|
||||
if (
|
||||
self.finished
|
||||
and self.sent_content_start
|
||||
and transformed.event_type == "interaction.complete"
|
||||
and transformed.event_type == completion_event_type
|
||||
):
|
||||
# Send content.stop first
|
||||
content_stop = InteractionsAPIStreamingResponse(
|
||||
event_type="content.stop",
|
||||
id=transformed.id,
|
||||
object="content",
|
||||
delta={"type": "text", "text": self.collected_text},
|
||||
)
|
||||
# Store the interaction.complete to send next
|
||||
stop_kwargs: Dict[str, Any] = {
|
||||
"event_type": stop_event_type,
|
||||
"index": 0,
|
||||
}
|
||||
if self._use_legacy:
|
||||
stop_kwargs["id"] = transformed.id
|
||||
stop_kwargs["object"] = "content"
|
||||
stop_kwargs["delta"] = {
|
||||
"type": "text",
|
||||
"text": self.collected_text,
|
||||
}
|
||||
stop_chunk = InteractionsAPIStreamingResponse(**stop_kwargs)
|
||||
self._pending_interaction_complete = transformed
|
||||
return content_stop
|
||||
return stop_chunk
|
||||
return transformed
|
||||
|
||||
# If no transformation, continue to next chunk (loop continues)
|
||||
@@ -249,13 +264,22 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
except StopIteration:
|
||||
self.finished = True
|
||||
|
||||
# Send final events if needed
|
||||
# Send final stop event if content was started
|
||||
if self.sent_content_start:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="content.stop",
|
||||
object="content",
|
||||
delta={"type": "text", "text": self.collected_text},
|
||||
stop_event_type = (
|
||||
"content.stop" if self._use_legacy else "step.stop"
|
||||
)
|
||||
stop_kwargs = {
|
||||
"event_type": stop_event_type,
|
||||
"index": 0,
|
||||
}
|
||||
if self._use_legacy:
|
||||
stop_kwargs["object"] = "content"
|
||||
stop_kwargs["delta"] = {
|
||||
"type": "text",
|
||||
"text": self.collected_text,
|
||||
}
|
||||
return InteractionsAPIStreamingResponse(**stop_kwargs)
|
||||
|
||||
raise StopIteration
|
||||
|
||||
@@ -265,10 +289,9 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
|
||||
async def __anext__(self) -> InteractionsAPIStreamingResponse:
|
||||
"""Get next chunk in async mode."""
|
||||
if self.finished:
|
||||
raise StopAsyncIteration
|
||||
|
||||
# Check if we have a pending interaction.complete to send
|
||||
# Check for a pending interaction.complete/completed event BEFORE the
|
||||
# finished check — otherwise the buffered completion event (which
|
||||
# carries the full text) would be dropped after `self.finished` is set.
|
||||
if hasattr(self, "_pending_interaction_complete"):
|
||||
pending: InteractionsAPIStreamingResponse = getattr(
|
||||
self, "_pending_interaction_complete"
|
||||
@@ -276,10 +299,9 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
delattr(self, "_pending_interaction_complete")
|
||||
return pending
|
||||
|
||||
# Drain events queued from a prior chunk (e.g. content.start emitted alongside
|
||||
# the interaction.start fallback for the first OutputTextDeltaEvent).
|
||||
if self._pending_events:
|
||||
return self._pending_events.pop(0)
|
||||
if self.finished:
|
||||
raise StopAsyncIteration
|
||||
|
||||
# Use a loop instead of recursion to avoid stack overflow
|
||||
async_iterator = cast(
|
||||
ResponsesAPIStreamingIterator, self.responses_stream_iterator
|
||||
@@ -295,22 +317,36 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
)
|
||||
|
||||
if transformed:
|
||||
# If we finished and content was started, send content.stop before interaction.complete
|
||||
completion_event_type = (
|
||||
"interaction.complete"
|
||||
if self._use_legacy
|
||||
else "interaction.completed"
|
||||
)
|
||||
stop_event_type = (
|
||||
"content.stop" if self._use_legacy else "step.stop"
|
||||
)
|
||||
# If content was started, send the stop event before the completion event.
|
||||
if (
|
||||
self.finished
|
||||
and self.sent_content_start
|
||||
and transformed.event_type == "interaction.complete"
|
||||
and transformed.event_type == completion_event_type
|
||||
):
|
||||
# Send content.stop first
|
||||
content_stop = InteractionsAPIStreamingResponse(
|
||||
event_type="content.stop",
|
||||
id=transformed.id,
|
||||
object="content",
|
||||
delta={"type": "text", "text": self.collected_text},
|
||||
stop_kwargs_async: Dict[str, Any] = {
|
||||
"event_type": stop_event_type,
|
||||
"index": 0,
|
||||
}
|
||||
if self._use_legacy:
|
||||
stop_kwargs_async["id"] = transformed.id
|
||||
stop_kwargs_async["object"] = "content"
|
||||
stop_kwargs_async["delta"] = {
|
||||
"type": "text",
|
||||
"text": self.collected_text,
|
||||
}
|
||||
stop_chunk = InteractionsAPIStreamingResponse(
|
||||
**stop_kwargs_async
|
||||
)
|
||||
# Store the interaction.complete to send next
|
||||
self._pending_interaction_complete = transformed
|
||||
return content_stop
|
||||
return stop_chunk
|
||||
return transformed
|
||||
|
||||
# If no transformation, continue to next chunk (loop continues)
|
||||
@@ -318,12 +354,21 @@ class LiteLLMResponsesInteractionsStreamingIterator:
|
||||
except StopAsyncIteration:
|
||||
self.finished = True
|
||||
|
||||
# Send final events if needed
|
||||
# Send final stop event if content was started
|
||||
if self.sent_content_start:
|
||||
return InteractionsAPIStreamingResponse(
|
||||
event_type="content.stop",
|
||||
object="content",
|
||||
delta={"type": "text", "text": self.collected_text},
|
||||
stop_event_type = (
|
||||
"content.stop" if self._use_legacy else "step.stop"
|
||||
)
|
||||
stop_kwargs_async = {
|
||||
"event_type": stop_event_type,
|
||||
"index": 0,
|
||||
}
|
||||
if self._use_legacy:
|
||||
stop_kwargs_async["object"] = "content"
|
||||
stop_kwargs_async["delta"] = {
|
||||
"type": "text",
|
||||
"text": self.collected_text,
|
||||
}
|
||||
return InteractionsAPIStreamingResponse(**stop_kwargs_async)
|
||||
|
||||
raise StopAsyncIteration
|
||||
|
||||
@@ -226,29 +226,37 @@ class LiteLLMResponsesInteractionsConfig:
|
||||
- Map status
|
||||
- Extract usage
|
||||
"""
|
||||
# Extract text from outputs
|
||||
outputs = []
|
||||
# Extract text from outputs and build both `outputs` (legacy) and `steps` (new schema).
|
||||
outputs: List[Dict[str, Any]] = []
|
||||
steps: List[Dict[str, Any]] = []
|
||||
if hasattr(responses_response, "output") and responses_response.output:
|
||||
for output_item in responses_response.output:
|
||||
# Use getattr with None default to safely access content
|
||||
content = getattr(output_item, "content", None)
|
||||
if content is not None:
|
||||
content_items = content if isinstance(content, list) else [content]
|
||||
model_output_contents: List[Dict[str, Any]] = []
|
||||
for content_item in content_items:
|
||||
# Check if content_item has text attribute
|
||||
text = getattr(content_item, "text", None)
|
||||
if text is not None:
|
||||
outputs.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
# Use independent dict instances so mutations to one
|
||||
# of `outputs` / `steps` don't leak into the other.
|
||||
outputs.append({"type": "text", "text": text})
|
||||
model_output_contents.append({"type": "text", "text": text})
|
||||
elif (
|
||||
isinstance(content_item, dict)
|
||||
and content_item.get("type") == "text"
|
||||
):
|
||||
outputs.append(content_item)
|
||||
outputs.append({**content_item})
|
||||
model_output_contents.append({**content_item})
|
||||
if model_output_contents:
|
||||
steps.append(
|
||||
{
|
||||
"type": "model_output",
|
||||
"content": model_output_contents,
|
||||
}
|
||||
)
|
||||
|
||||
# Convert created_at to ISO string
|
||||
created_at = getattr(responses_response, "created_at", None)
|
||||
@@ -270,12 +278,14 @@ class LiteLLMResponsesInteractionsConfig:
|
||||
else:
|
||||
interactions_status = status
|
||||
|
||||
# Build interactions response
|
||||
# Build interactions response — populate both `outputs` (legacy schema) and
|
||||
# `steps` (new schema) so callers work regardless of which schema they expect.
|
||||
interactions_response_dict: Dict[str, Any] = {
|
||||
"id": getattr(responses_response, "id", ""),
|
||||
"object": "interaction",
|
||||
"status": interactions_status,
|
||||
"outputs": outputs,
|
||||
"steps": steps,
|
||||
"model": model or getattr(responses_response, "model", ""),
|
||||
"created": created,
|
||||
}
|
||||
|
||||
@@ -101,10 +101,14 @@ class BaseInteractionsAPIStreamingIterator:
|
||||
)
|
||||
)
|
||||
|
||||
# Store the completed response (check for status=completed)
|
||||
if (
|
||||
streaming_response
|
||||
and getattr(streaming_response, "status", None) == "completed"
|
||||
# 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()
|
||||
|
||||
@@ -6,13 +6,18 @@ Per OpenAPI spec (https://ai.google.dev/static/api/interactions.openapi.json):
|
||||
- Get: GET https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id}
|
||||
- Delete: DELETE https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id}
|
||||
|
||||
This is a thin wrapper - no transformation needed since we follow the spec directly.
|
||||
Schema versioning:
|
||||
- Default (Api-Revision: 2026-05-20): new `steps` schema.
|
||||
- Legacy (Api-Revision: 2026-05-07): old `outputs` schema, controlled via
|
||||
litellm.use_legacy_interactions_schema = True. Remove flag after June 8, 2026.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.core_helpers import process_response_headers
|
||||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
@@ -84,6 +89,15 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
|
||||
api_key = GeminiModelInfo.get_api_key(litellm_params.get("api_key"))
|
||||
if api_key:
|
||||
headers["x-goog-api-key"] = api_key
|
||||
|
||||
# Inject the Api-Revision header to select the response schema.
|
||||
# Default to the new `steps` schema unless the operator has opted out.
|
||||
# Remove this conditional after June 8, 2026 and always use 2026-05-20.
|
||||
if litellm.use_legacy_interactions_schema:
|
||||
headers["Api-Revision"] = "2026-05-07"
|
||||
else:
|
||||
headers["Api-Revision"] = "2026-05-20"
|
||||
|
||||
return headers
|
||||
|
||||
def get_complete_url(
|
||||
@@ -119,8 +133,19 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
|
||||
headers: dict,
|
||||
) -> Dict:
|
||||
"""
|
||||
Build request body per OpenAPI spec - minimal transformation.
|
||||
Build request body per OpenAPI spec.
|
||||
|
||||
When on the new schema (use_legacy_interactions_schema=False, the default):
|
||||
- ``response_mime_type`` is folded into ``response_format`` and stripped from
|
||||
the body (the field was removed in Api-Revision 2026-05-20).
|
||||
- ``generation_config.image_config`` is moved to a ``response_format`` entry
|
||||
with ``"type": "image"`` (also removed from generation_config in 2026-05-20).
|
||||
|
||||
When on the legacy schema (use_legacy_interactions_schema=True):
|
||||
- All fields are forwarded as-is.
|
||||
"""
|
||||
use_legacy: bool = litellm.use_legacy_interactions_schema
|
||||
|
||||
request_body: Dict[str, Any] = {}
|
||||
|
||||
# Model or Agent (one required)
|
||||
@@ -135,24 +160,81 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
|
||||
if input is not None:
|
||||
request_body["input"] = input
|
||||
|
||||
# Pass through optional params directly (they match the spec)
|
||||
# Pass through optional params — legacy schema keeps all fields as-is.
|
||||
optional_keys = [
|
||||
"tools",
|
||||
"system_instruction",
|
||||
"generation_config",
|
||||
"stream",
|
||||
"store",
|
||||
"background",
|
||||
"environment",
|
||||
"response_modalities",
|
||||
"response_format",
|
||||
"response_mime_type",
|
||||
"previous_interaction_id",
|
||||
]
|
||||
for key in optional_keys:
|
||||
if optional_params.get(key) is not None:
|
||||
request_body[key] = optional_params[key]
|
||||
|
||||
if use_legacy:
|
||||
# Legacy schema: forward response_mime_type and response_format as-is.
|
||||
for key in ("response_format", "response_mime_type", "generation_config"):
|
||||
if optional_params.get(key) is not None:
|
||||
request_body[key] = optional_params[key]
|
||||
else:
|
||||
# New schema (Api-Revision: 2026-05-20):
|
||||
# response_mime_type is removed — fold it into response_format.
|
||||
response_format = optional_params.get("response_format")
|
||||
response_mime_type = optional_params.get("response_mime_type")
|
||||
|
||||
if (
|
||||
response_mime_type
|
||||
and not isinstance(response_format, list)
|
||||
and (
|
||||
not isinstance(response_format, dict)
|
||||
or "mime_type" not in response_format
|
||||
)
|
||||
):
|
||||
# Wrap the legacy schema into the new polymorphic format.
|
||||
new_rf: Dict[str, Any] = {
|
||||
"type": "text",
|
||||
"mime_type": response_mime_type,
|
||||
}
|
||||
if response_format is not None:
|
||||
new_rf["schema"] = response_format
|
||||
response_format = new_rf
|
||||
|
||||
if response_format is not None:
|
||||
request_body["response_format"] = response_format
|
||||
|
||||
# image_config moves out of generation_config into response_format.
|
||||
generation_config: Optional[Dict[str, Any]] = optional_params.get(
|
||||
"generation_config"
|
||||
)
|
||||
if generation_config is not None:
|
||||
image_config = None
|
||||
if isinstance(generation_config, dict):
|
||||
generation_config = dict(
|
||||
generation_config
|
||||
) # avoid mutating the caller's dict
|
||||
image_config = generation_config.pop("image_config", None)
|
||||
if not generation_config:
|
||||
generation_config = None
|
||||
|
||||
if generation_config is not None:
|
||||
request_body["generation_config"] = generation_config
|
||||
|
||||
if image_config is not None:
|
||||
# Move image_config to response_format with type=image.
|
||||
image_rf: Dict[str, Any] = {"type": "image", **image_config}
|
||||
existing_rf = request_body.get("response_format")
|
||||
if existing_rf is None:
|
||||
request_body["response_format"] = image_rf
|
||||
elif isinstance(existing_rf, list):
|
||||
request_body["response_format"] = [*existing_rf, image_rf]
|
||||
else:
|
||||
# Convert single entry to array for multimodal output.
|
||||
request_body["response_format"] = [existing_rf, image_rf]
|
||||
|
||||
return request_body
|
||||
|
||||
def transform_response(
|
||||
|
||||
@@ -4328,6 +4328,19 @@ class ProxyConfig:
|
||||
"health_check_concurrency", None
|
||||
)
|
||||
health_check_details = general_settings.get("health_check_details", True)
|
||||
### INTERACTIONS API SCHEMA ###
|
||||
_use_legacy_interactions_schema = general_settings.get(
|
||||
"use_legacy_interactions_schema"
|
||||
)
|
||||
if _use_legacy_interactions_schema is not None:
|
||||
if isinstance(_use_legacy_interactions_schema, str):
|
||||
litellm.use_legacy_interactions_schema = (
|
||||
_use_legacy_interactions_schema.lower() == "true"
|
||||
)
|
||||
else:
|
||||
litellm.use_legacy_interactions_schema = bool(
|
||||
_use_legacy_interactions_schema
|
||||
)
|
||||
# Health-check-driven routing (opt-in, passes through to Router later)
|
||||
_enable_hc_routing = general_settings.get(
|
||||
"enable_health_check_routing", False
|
||||
|
||||
@@ -36,9 +36,13 @@ from litellm.types.interactions.generated import (
|
||||
GoogleSearchResultContent,
|
||||
ImageContent,
|
||||
Interaction,
|
||||
InteractionCompleted,
|
||||
InteractionCreated,
|
||||
InteractionEvent,
|
||||
InteractionEnvironment,
|
||||
InteractionInProgress,
|
||||
InteractionInput,
|
||||
InteractionRequiresAction,
|
||||
InteractionsAPIOptionalRequestParams,
|
||||
InteractionsAPIResponse,
|
||||
InteractionsAPIStreamingResponse,
|
||||
@@ -50,6 +54,9 @@ from litellm.types.interactions.generated import (
|
||||
McpServerToolResultContent,
|
||||
ModelOption,
|
||||
ResponseModality,
|
||||
StepDelta,
|
||||
StepStart,
|
||||
StepStop,
|
||||
)
|
||||
from litellm.types.interactions.generated import (
|
||||
Status3 as InteractionStatus, # Main request/response types; Content types; Turn for multi-turn conversations; Tool types; Config types; Usage; Status enum; Events for streaming; Agent configs; Model/Agent options; Response modality; Annotation; LiteLLM types; Backwards compat aliases
|
||||
@@ -115,6 +122,14 @@ __all__ = [
|
||||
"AgentOption",
|
||||
"ResponseModality",
|
||||
"Annotation",
|
||||
# New schema SSE event types (Api-Revision: 2026-05-20)
|
||||
"StepStart",
|
||||
"StepDelta",
|
||||
"StepStop",
|
||||
"InteractionCreated",
|
||||
"InteractionInProgress",
|
||||
"InteractionCompleted",
|
||||
"InteractionRequiresAction",
|
||||
# LiteLLM types
|
||||
"InteractionEnvironment",
|
||||
"InteractionInput",
|
||||
|
||||
@@ -1151,9 +1151,114 @@ class InteractionEvent(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# New schema SSE event types (Api-Revision: 2026-05-20)
|
||||
# These replace the legacy content.* / interaction.start|complete
|
||||
# events and will become the only events after June 8, 2026.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
|
||||
class StepStart(BaseModel):
|
||||
"""Emitted when a new step begins (replaces content.start)."""
|
||||
|
||||
event_type: Literal["step.start"] = "step.start"
|
||||
index: Optional[int] = None
|
||||
step: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="The initial step data (type, content, signature, etc.).",
|
||||
)
|
||||
event_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The event_id token to be used to resume the interaction stream.",
|
||||
)
|
||||
|
||||
|
||||
class StepDelta(BaseModel):
|
||||
"""Emitted for incremental step content (replaces content.delta)."""
|
||||
|
||||
event_type: Literal["step.delta"] = "step.delta"
|
||||
index: Optional[int] = None
|
||||
delta: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Incremental content delta (e.g. text, arguments_delta for function calls).",
|
||||
)
|
||||
event_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The event_id token to be used to resume the interaction stream.",
|
||||
)
|
||||
|
||||
|
||||
class StepStop(BaseModel):
|
||||
"""Emitted when a step finishes (replaces content.stop)."""
|
||||
|
||||
event_type: Literal["step.stop"] = "step.stop"
|
||||
index: Optional[int] = None
|
||||
status: Optional[str] = Field(
|
||||
None,
|
||||
description="Step completion status (e.g. 'done').",
|
||||
)
|
||||
event_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The event_id token to be used to resume the interaction stream.",
|
||||
)
|
||||
|
||||
|
||||
class InteractionCreated(BaseModel):
|
||||
"""Emitted when the interaction is first created (replaces interaction.start)."""
|
||||
|
||||
event_type: Literal["interaction.created"] = "interaction.created"
|
||||
interaction: Optional[Dict[str, Any]] = None
|
||||
event_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The event_id token to be used to resume the interaction stream.",
|
||||
)
|
||||
|
||||
|
||||
class InteractionInProgress(BaseModel):
|
||||
"""Emitted while the interaction is running."""
|
||||
|
||||
event_type: Literal["interaction.in_progress"] = "interaction.in_progress"
|
||||
interaction_id: Optional[str] = None
|
||||
event_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The event_id token to be used to resume the interaction stream.",
|
||||
)
|
||||
|
||||
|
||||
class InteractionCompleted(BaseModel):
|
||||
"""Emitted when the interaction finishes (replaces interaction.complete)."""
|
||||
|
||||
event_type: Literal["interaction.completed"] = "interaction.completed"
|
||||
interaction: Optional[Dict[str, Any]] = None
|
||||
event_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The event_id token to be used to resume the interaction stream.",
|
||||
)
|
||||
|
||||
|
||||
class InteractionRequiresAction(BaseModel):
|
||||
"""Emitted when the interaction is paused waiting for a tool result."""
|
||||
|
||||
event_type: Literal["interaction.requires_action"] = "interaction.requires_action"
|
||||
interaction_id: Optional[str] = None
|
||||
event_id: Optional[str] = Field(
|
||||
None,
|
||||
description="The event_id token to be used to resume the interaction stream.",
|
||||
)
|
||||
|
||||
|
||||
class InteractionSseEvent(
|
||||
RootModel[
|
||||
Union[
|
||||
# New schema events (Api-Revision: 2026-05-20)
|
||||
StepStart,
|
||||
StepDelta,
|
||||
StepStop,
|
||||
InteractionCreated,
|
||||
InteractionInProgress,
|
||||
InteractionCompleted,
|
||||
InteractionRequiresAction,
|
||||
# Legacy schema events (Api-Revision: 2026-05-07, removed June 8 2026)
|
||||
InteractionEvent,
|
||||
InteractionStatusUpdate,
|
||||
ContentStart,
|
||||
@@ -1164,6 +1269,15 @@ class InteractionSseEvent(
|
||||
]
|
||||
):
|
||||
root: Union[
|
||||
# New schema events (Api-Revision: 2026-05-20)
|
||||
StepStart,
|
||||
StepDelta,
|
||||
StepStop,
|
||||
InteractionCreated,
|
||||
InteractionInProgress,
|
||||
InteractionCompleted,
|
||||
InteractionRequiresAction,
|
||||
# Legacy schema events (Api-Revision: 2026-05-07, removed June 8 2026)
|
||||
InteractionEvent,
|
||||
InteractionStatusUpdate,
|
||||
ContentStart,
|
||||
@@ -1193,6 +1307,11 @@ class InteractionsAPIResponse(BaseLiteLLMOpenAIResponseObject):
|
||||
Response from the Interactions API.
|
||||
|
||||
Wraps the API response with LiteLLM-specific hidden params.
|
||||
|
||||
Schema notes:
|
||||
- New schema (Api-Revision: 2026-05-20, default): response contains ``steps``.
|
||||
- Legacy schema (Api-Revision: 2026-05-07, removed June 8 2026): response contains ``outputs``.
|
||||
Both fields are kept here so callers work with either schema.
|
||||
"""
|
||||
|
||||
id: Optional[str] = None
|
||||
@@ -1203,7 +1322,10 @@ class InteractionsAPIResponse(BaseLiteLLMOpenAIResponseObject):
|
||||
created: Optional[str] = None
|
||||
updated: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
# Legacy schema field (Api-Revision: 2026-05-07). Remove after June 8, 2026.
|
||||
outputs: Optional[List[Dict[str, Any]]] = None
|
||||
# New schema field (Api-Revision: 2026-05-20).
|
||||
steps: Optional[List[Dict[str, Any]]] = None
|
||||
usage: Optional[Dict[str, Any]] = None
|
||||
|
||||
_hidden_params: dict = PrivateAttr(default_factory=dict)
|
||||
@@ -1213,7 +1335,12 @@ class InteractionsAPIStreamingResponse(BaseLiteLLMOpenAIResponseObject):
|
||||
"""
|
||||
Streaming response chunk from the Interactions API.
|
||||
|
||||
Event types per OpenAPI spec:
|
||||
New schema event types (Api-Revision: 2026-05-20):
|
||||
- interaction.created, interaction.in_progress, interaction.completed,
|
||||
interaction.requires_action
|
||||
- step.start, step.delta, step.stop
|
||||
|
||||
Legacy event types (Api-Revision: 2026-05-07, removed June 8 2026):
|
||||
- interaction.start, interaction.status_update, interaction.complete
|
||||
- content.start, content.delta, content.stop
|
||||
- error
|
||||
@@ -1228,9 +1355,17 @@ class InteractionsAPIStreamingResponse(BaseLiteLLMOpenAIResponseObject):
|
||||
created: Optional[str] = None
|
||||
updated: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
# Legacy schema field (Api-Revision: 2026-05-07). Remove after June 8, 2026.
|
||||
outputs: Optional[List[Dict[str, Any]]] = None
|
||||
# New schema field (Api-Revision: 2026-05-20).
|
||||
steps: Optional[List[Dict[str, Any]]] = None
|
||||
usage: Optional[Dict[str, Any]] = None
|
||||
delta: Optional[Dict[str, Any]] = None
|
||||
# New schema streaming fields
|
||||
index: Optional[int] = None
|
||||
step: Optional[Dict[str, Any]] = None
|
||||
interaction_id: Optional[str] = None
|
||||
interaction: Optional[Dict[str, Any]] = None
|
||||
|
||||
_hidden_params: dict = PrivateAttr(default_factory=dict)
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
Tests for Gemini Interactions API transformation.
|
||||
|
||||
Covers credential leak prevention changes:
|
||||
- validate_environment sets x-goog-api-key header
|
||||
- get_complete_url excludes API key from URL
|
||||
- get/delete/cancel interaction request URLs exclude API key
|
||||
Covers:
|
||||
- validate_environment: x-goog-api-key header, Api-Revision schema selection
|
||||
- get_complete_url: API key excluded from URL
|
||||
- get/delete/cancel interaction request URLs
|
||||
- transform_request: response_mime_type coalescing, image_config migration
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -15,6 +16,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../.."))
|
||||
|
||||
import litellm
|
||||
from litellm.interactions.litellm_responses_transformation.streaming_iterator import (
|
||||
LiteLLMResponsesInteractionsStreamingIterator,
|
||||
)
|
||||
@@ -22,9 +24,7 @@ from litellm.llms.gemini.interactions.transformation import (
|
||||
GoogleAIStudioInteractionsConfig,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
ContentPartAddedEvent,
|
||||
OutputTextDeltaEvent,
|
||||
ResponseCompletedEvent,
|
||||
ResponseCreatedEvent,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
@@ -85,6 +85,30 @@ class TestValidateEnvironment:
|
||||
assert headers["X-Custom"] == "value"
|
||||
assert headers["x-goog-api-key"] == "test-key"
|
||||
|
||||
def test_api_revision_new_schema_by_default(self, config):
|
||||
# Default: use_legacy_interactions_schema=False → new steps schema
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gemini-2.5-flash", litellm_params=None
|
||||
)
|
||||
assert headers["Api-Revision"] == "2026-05-20"
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
def test_api_revision_legacy_schema_when_flag_set(self, config):
|
||||
# Flag on → legacy outputs schema until June 8, 2026
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = True
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gemini-2.5-flash", litellm_params=None
|
||||
)
|
||||
assert headers["Api-Revision"] == "2026-05-07"
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
|
||||
class TestGetCompleteUrl:
|
||||
def test_url_excludes_api_key(self, config):
|
||||
@@ -172,158 +196,7 @@ class TestTransformRequest:
|
||||
)
|
||||
|
||||
assert request_body["environment"] == env_id
|
||||
class TestStreamingIterator:
|
||||
def _make_iterator(self) -> LiteLLMResponsesInteractionsStreamingIterator:
|
||||
return LiteLLMResponsesInteractionsStreamingIterator(
|
||||
model="gpt-5.4",
|
||||
litellm_custom_stream_wrapper=MagicMock(),
|
||||
request_input="hi",
|
||||
optional_params={},
|
||||
)
|
||||
|
||||
def _make_text_delta(
|
||||
self, text: str, item_id: str = "item_1"
|
||||
) -> OutputTextDeltaEvent:
|
||||
event = MagicMock(spec=OutputTextDeltaEvent)
|
||||
event.delta = text
|
||||
event.item_id = item_id
|
||||
return event
|
||||
|
||||
def _make_part_added(self, item_id: str = "item_1") -> ContentPartAddedEvent:
|
||||
event = MagicMock(spec=ContentPartAddedEvent)
|
||||
event.item_id = item_id
|
||||
return event
|
||||
|
||||
def _make_response_created(self) -> ResponseCreatedEvent:
|
||||
event = MagicMock(spec=ResponseCreatedEvent)
|
||||
event.response = MagicMock(id="resp_123")
|
||||
return event
|
||||
|
||||
def test_content_delta_includes_type_field(self):
|
||||
"""content.delta events must carry delta.type='text' so the UI can display them."""
|
||||
it = self._make_iterator()
|
||||
it.sent_interaction_start = True
|
||||
it.sent_content_start = True
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("Hello")
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "content.delta"
|
||||
assert chunk.delta == {"type": "text", "text": "Hello"}
|
||||
|
||||
def test_response_part_added_emits_content_start(self):
|
||||
"""ContentPartAddedEvent (arrives before text deltas) should emit content.start
|
||||
so the first OutputTextDeltaEvent immediately emits content.delta without dropping text.
|
||||
"""
|
||||
it = self._make_iterator()
|
||||
it.sent_interaction_start = True
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_part_added()
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "content.start"
|
||||
assert it.sent_content_start is True
|
||||
|
||||
def test_first_text_delta_not_dropped_when_part_added_seen(self):
|
||||
"""After ContentPartAddedEvent, the first text delta must yield content.delta
|
||||
(not content.start), preserving the token text."""
|
||||
it = self._make_iterator()
|
||||
it.sent_interaction_start = True
|
||||
it._transform_responses_chunk_to_interactions_chunk(self._make_part_added())
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("Hello")
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "content.delta"
|
||||
assert chunk.delta is not None
|
||||
assert chunk.delta.get("text") == "Hello"
|
||||
|
||||
def test_part_added_emits_interaction_start_fallback_when_not_sent(self):
|
||||
"""If ContentPartAddedEvent arrives before any ResponseCreatedEvent,
|
||||
the iterator must emit interaction.start before content.start to honor
|
||||
the documented event ordering contract."""
|
||||
it = self._make_iterator()
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_part_added(item_id="item_42")
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "interaction.start"
|
||||
assert chunk.id == "item_42"
|
||||
assert chunk.status == "in_progress"
|
||||
assert chunk.model == "gpt-5.4"
|
||||
assert it.sent_interaction_start is True
|
||||
assert it.sent_content_start is False
|
||||
|
||||
def test_part_added_returns_none_when_already_started(self):
|
||||
"""A second ContentPartAddedEvent (after content.start was already emitted)
|
||||
should be a no-op so we don't re-emit content.start."""
|
||||
it = self._make_iterator()
|
||||
it.sent_interaction_start = True
|
||||
it.sent_content_start = True
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_part_added()
|
||||
)
|
||||
|
||||
assert chunk is None
|
||||
|
||||
def test_part_added_without_item_id_falls_back_to_self_id(self):
|
||||
"""When ContentPartAddedEvent has no item_id and we emit the interaction.start
|
||||
fallback, the id must default to an interaction_<id(self)> string."""
|
||||
it = self._make_iterator()
|
||||
event = MagicMock(spec=ContentPartAddedEvent)
|
||||
event.item_id = None
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(event)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "interaction.start"
|
||||
assert chunk.id == f"interaction_{id(it)}"
|
||||
|
||||
def test_first_text_delta_not_dropped_when_no_prior_start_events(self):
|
||||
"""When OutputTextDeltaEvent arrives before any ResponseCreatedEvent or
|
||||
ContentPartAddedEvent, the iterator must emit interaction.start *and*
|
||||
immediately follow with a content.start that carries this delta's text,
|
||||
so the first token is never silently dropped from the stream."""
|
||||
events = [
|
||||
self._make_text_delta("Hello"),
|
||||
self._make_text_delta(" World"),
|
||||
]
|
||||
wrapper = MagicMock()
|
||||
wrapper.__iter__ = lambda self: iter(events)
|
||||
wrapper.__next__ = lambda self, _it=iter(events): next(_it)
|
||||
it = LiteLLMResponsesInteractionsStreamingIterator(
|
||||
model="gpt-5.4",
|
||||
litellm_custom_stream_wrapper=wrapper,
|
||||
request_input="hi",
|
||||
optional_params={},
|
||||
)
|
||||
|
||||
first = it._transform_responses_chunk_to_interactions_chunk(events[0])
|
||||
assert first is not None
|
||||
assert first.event_type == "interaction.start"
|
||||
assert it.sent_interaction_start is True
|
||||
assert it.sent_content_start is True
|
||||
assert len(it._pending_events) == 1
|
||||
pending = it._pending_events[0]
|
||||
assert pending.event_type == "content.start"
|
||||
assert pending.delta == {"type": "text", "text": "Hello"}
|
||||
|
||||
second = it._transform_responses_chunk_to_interactions_chunk(events[1])
|
||||
assert second is not None
|
||||
assert second.event_type == "content.delta"
|
||||
assert second.delta == {"type": "text", "text": " World"}
|
||||
|
||||
|
||||
class TestTransformRequest:
|
||||
def test_stream_param_included_in_request_body(self, config):
|
||||
"""When stream=True is in optional_params, the request body must include it
|
||||
so the proxy forwards the SSE streaming flag to Google's backend."""
|
||||
@@ -352,6 +225,148 @@ class TestTransformRequest:
|
||||
assert "stream" not in body
|
||||
|
||||
|
||||
class TestStreamingIterator:
|
||||
def _make_iterator(
|
||||
self, use_legacy: bool = False
|
||||
) -> LiteLLMResponsesInteractionsStreamingIterator:
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
litellm.use_legacy_interactions_schema = use_legacy
|
||||
try:
|
||||
return LiteLLMResponsesInteractionsStreamingIterator(
|
||||
model="gpt-5.4",
|
||||
litellm_custom_stream_wrapper=MagicMock(),
|
||||
request_input="hi",
|
||||
optional_params={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
def _make_text_delta(
|
||||
self, text: str, item_id: str = "item_1"
|
||||
) -> OutputTextDeltaEvent:
|
||||
event = MagicMock(spec=OutputTextDeltaEvent)
|
||||
event.delta = text
|
||||
event.item_id = item_id
|
||||
return event
|
||||
|
||||
def _make_response_created(self) -> ResponseCreatedEvent:
|
||||
event = MagicMock(spec=ResponseCreatedEvent)
|
||||
event.response = MagicMock(id="resp_123")
|
||||
return event
|
||||
|
||||
def test_step_delta_includes_type_field(self):
|
||||
"""step.delta events must carry delta.type='text' so the UI can display them."""
|
||||
it = self._make_iterator(use_legacy=False)
|
||||
it.sent_interaction_start = True
|
||||
it.sent_content_start = True
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("Hello")
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "step.delta"
|
||||
assert chunk.delta == {"type": "text", "text": "Hello"}
|
||||
|
||||
def test_content_delta_legacy_schema(self):
|
||||
"""Legacy schema emits content.delta with type and text fields."""
|
||||
it = self._make_iterator(use_legacy=True)
|
||||
it.sent_interaction_start = True
|
||||
it.sent_content_start = True
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("Hello")
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "content.delta"
|
||||
assert chunk.delta == {"type": "text", "text": "Hello"}
|
||||
|
||||
def test_response_created_emits_interaction_created(self):
|
||||
it = self._make_iterator(use_legacy=False)
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_response_created()
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "interaction.created"
|
||||
assert chunk.id == "resp_123"
|
||||
assert it.sent_interaction_start is True
|
||||
|
||||
def test_response_created_emits_interaction_start_legacy(self):
|
||||
it = self._make_iterator(use_legacy=True)
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_response_created()
|
||||
)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "interaction.start"
|
||||
assert chunk.id == "resp_123"
|
||||
|
||||
def test_text_delta_sequence_new_schema(self):
|
||||
"""First two OutputTextDeltaEvents emit created + step.start; third emits step.delta."""
|
||||
it = self._make_iterator(use_legacy=False)
|
||||
|
||||
first = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("Hello")
|
||||
)
|
||||
assert first is not None
|
||||
assert first.event_type == "interaction.created"
|
||||
assert it.sent_interaction_start is True
|
||||
assert it.sent_content_start is False
|
||||
|
||||
second = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta(" World")
|
||||
)
|
||||
assert second is not None
|
||||
assert second.event_type == "step.start"
|
||||
assert it.sent_content_start is True
|
||||
|
||||
third = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("!")
|
||||
)
|
||||
assert third is not None
|
||||
assert third.event_type == "step.delta"
|
||||
assert third.delta == {"type": "text", "text": "!"}
|
||||
|
||||
def test_text_delta_sequence_legacy_schema(self):
|
||||
"""Legacy: interaction.start → content.start → content.delta."""
|
||||
it = self._make_iterator(use_legacy=True)
|
||||
|
||||
first = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("Hello")
|
||||
)
|
||||
assert first is not None
|
||||
assert first.event_type == "interaction.start"
|
||||
|
||||
second = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta(" World")
|
||||
)
|
||||
assert second is not None
|
||||
assert second.event_type == "content.start"
|
||||
assert second.delta == {"type": "text", "text": ""}
|
||||
|
||||
third = it._transform_responses_chunk_to_interactions_chunk(
|
||||
self._make_text_delta("!")
|
||||
)
|
||||
assert third is not None
|
||||
assert third.event_type == "content.delta"
|
||||
assert third.delta == {"type": "text", "text": "!"}
|
||||
|
||||
def test_first_text_delta_without_item_id_uses_fallback_id(self):
|
||||
it = self._make_iterator(use_legacy=False)
|
||||
event = self._make_text_delta("Hi")
|
||||
event.item_id = None
|
||||
|
||||
chunk = it._transform_responses_chunk_to_interactions_chunk(event)
|
||||
|
||||
assert chunk is not None
|
||||
assert chunk.event_type == "interaction.created"
|
||||
assert chunk.id == f"interaction_{id(it)}"
|
||||
|
||||
|
||||
class TestInteractionOperationUrls:
|
||||
"""Test that get/delete/cancel interaction URLs exclude API key."""
|
||||
|
||||
@@ -410,3 +425,152 @@ class TestInteractionOperationUrls:
|
||||
litellm_params=GenericLiteLLMParams(api_key=None),
|
||||
headers={},
|
||||
)
|
||||
|
||||
|
||||
class TestTransformRequestSchemaCoalescing:
|
||||
"""Test new-schema request coalescing (Api-Revision: 2026-05-20)."""
|
||||
|
||||
def test_response_mime_type_folded_into_response_format(self, config):
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="summarise",
|
||||
optional_params={
|
||||
"response_mime_type": "application/json",
|
||||
"response_format": {"type": "object", "properties": {}},
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
# response_mime_type must not appear as a top-level body key
|
||||
assert "response_mime_type" not in body
|
||||
rf = body["response_format"]
|
||||
assert rf["type"] == "text"
|
||||
assert rf["mime_type"] == "application/json"
|
||||
assert "schema" in rf
|
||||
|
||||
def test_image_config_moved_to_response_format(self, config):
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw a sunset",
|
||||
optional_params={
|
||||
"generation_config": {
|
||||
"temperature": 0.7,
|
||||
"image_config": {"aspect_ratio": "1:1", "image_size": "1K"},
|
||||
}
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
# image_config removed from generation_config
|
||||
assert "image_config" not in body.get("generation_config", {})
|
||||
# moved into response_format with type=image
|
||||
rf = body["response_format"]
|
||||
assert rf["type"] == "image"
|
||||
assert rf["aspect_ratio"] == "1:1"
|
||||
|
||||
def test_response_mime_type_skipped_when_response_format_is_list(self, config):
|
||||
"""Lists are already polymorphic; do not wrap them into schema."""
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
rf_list = [
|
||||
{"type": "text", "mime_type": "application/json"},
|
||||
{"type": "image", "aspect_ratio": "1:1"},
|
||||
]
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="multimodal",
|
||||
optional_params={
|
||||
"response_format": rf_list,
|
||||
"response_mime_type": "application/json",
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
assert body["response_format"] == rf_list
|
||||
assert "response_mime_type" not in body
|
||||
|
||||
def test_image_config_appended_to_response_format_list_without_mutating_input(
|
||||
self, config
|
||||
):
|
||||
"""When response_format is already a list, image_config must not mutate optional_params."""
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
text_rf = {"type": "text", "mime_type": "application/json"}
|
||||
optional_params = {
|
||||
"response_format": [text_rf],
|
||||
"generation_config": {
|
||||
"image_config": {"aspect_ratio": "16:9", "image_size": "2K"},
|
||||
},
|
||||
}
|
||||
original_rf = optional_params["response_format"]
|
||||
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw and summarise",
|
||||
optional_params=optional_params,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert optional_params["response_format"] is original_rf
|
||||
assert len(optional_params["response_format"]) == 1
|
||||
assert body["response_format"] == [
|
||||
text_rf,
|
||||
{"type": "image", "aspect_ratio": "16:9", "image_size": "2K"},
|
||||
]
|
||||
|
||||
# Retry must not append a second image entry into the caller's list.
|
||||
body_retry = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw and summarise",
|
||||
optional_params=optional_params,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
assert len(optional_params["response_format"]) == 1
|
||||
assert body_retry["response_format"] == body["response_format"]
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
def test_legacy_schema_passes_fields_unchanged(self, config):
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = True
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="hello",
|
||||
optional_params={
|
||||
"response_mime_type": "application/json",
|
||||
"generation_config": {"image_config": {"aspect_ratio": "16:9"}},
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
|
||||
assert body["response_mime_type"] == "application/json"
|
||||
assert body["generation_config"]["image_config"]["aspect_ratio"] == "16:9"
|
||||
|
||||
Reference in New Issue
Block a user