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:
Sameer Kankute
2026-05-20 13:32:12 -07:00
committed by GitHub
co-authored by Cursor Yassin Kortam Claude
parent 68efe6970c
commit f3a669fc5d
9 changed files with 785 additions and 313 deletions
+4
View File
@@ -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,
}
+8 -4
View File
@@ -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(
+13
View File
@@ -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
+15
View File
@@ -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",
+136 -1
View File
@@ -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"