Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b3ccc7d
feat(ui): add Interactions API support to playground with streaming
Sameerlite May 18, 2026
6e87ca1
fix(interactions): remove forced gemini provider so all providers wor…
Sameerlite May 18, 2026
d23bed1
fix(interactions): fix streaming for non-gemini providers via bridge
Sameerlite May 18, 2026
b8f4008
undo unrelated changes
Sameerlite May 18, 2026
6381a92
fix(ui): extract model from top-level field in interactions bridge ev…
cursoragent May 18, 2026
bbed05d
test(interactions): remove tautological gemini-provider assertion
cursoragent May 18, 2026
0537af7
fix(interactions): use ContentPartAddedEvent and guard interaction.st…
cursoragent May 18, 2026
3d82735
test(interactions): cover ContentPartAddedEvent ordering and no-op paths
mateo-berri May 18, 2026
b6d3f4a
fix(tests): treat corrupt VCR cassette payloads as cache miss + use g…
mateo-berri May 18, 2026
4602eed
fix(tests): migrate realtime + nvidia_nim rerank tests off shut-down …
mateo-berri May 18, 2026
3a95e68
test(callbacks): harden flaky proxy callback-leak detector
mateo-berri May 18, 2026
b717c70
Merge branch 'litellm_internal_staging' into litellm_interactions_ui_…
mateo-berri May 18, 2026
7e9b615
Merge branch 'litellm_interactions_ui_streaming' of https://github.co…
mateo-berri May 18, 2026
f54bd7d
fix(interactions): preserve first text token when both start events a…
mateo-berri May 19, 2026
6cdf840
chore(ui): remove unused InteractionOutput/InteractionResponse interf…
cursoragent May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ResponseCompletedEvent,
ResponseCreatedEvent,
ResponseInProgressEvent,
ResponsePartAddedEvent,
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
ResponsesAPIStreamingResponse,
)

Expand Down Expand Up @@ -80,7 +81,7 @@ def _transform_responses_chunk_to_interactions_chunk(
)
self.collected_text += delta_text

# Send interaction.start if not sent
# Fallback: emit interaction.start if ResponseCreatedEvent never arrived
if not self.sent_interaction_start:
self.sent_interaction_start = True
return InteractionsAPIStreamingResponse(
Expand All @@ -92,24 +93,36 @@ def _transform_responses_chunk_to_interactions_chunk(
model=self.model,
)

# Send content.start if not sent
# Fallback: emit content.start if ResponsePartAddedEvent never arrived
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": ""},
delta={"type": "text", "text": delta_text},
)

# Send content.delta
# Normal path: emit content.delta with type field
return InteractionsAPIStreamingResponse(
event_type="content.delta",
id=getattr(responses_chunk, "item_id", None),
object="content",
delta={"text": delta_text},
delta={"type": "text", "text": delta_text},
)

# Handle ResponsePartAddedEvent -> content.start (arrives before text deltas)
if isinstance(responses_chunk, ResponsePartAddedEvent):
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
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
Sameerlite marked this conversation as resolved.
Resolved

# Handle ResponseCreatedEvent or ResponseInProgressEvent -> interaction.start
if isinstance(responses_chunk, (ResponseCreatedEvent, ResponseInProgressEvent)):
if not self.sent_interaction_start:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@

import os
import sys
from unittest.mock import patch
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, os.path.abspath("../../.."))

from litellm.interactions.litellm_responses_transformation.streaming_iterator import (
LiteLLMResponsesInteractionsStreamingIterator,
)
from litellm.llms.gemini.interactions.transformation import (
GoogleAIStudioInteractionsConfig,
)
from litellm.types.llms.openai import (
OutputTextDeltaEvent,
ResponseCompletedEvent,
ResponseCreatedEvent,
ResponsePartAddedEvent,
)
from litellm.types.router import GenericLiteLLMParams

_PATCH_GET_API_KEY = "litellm.llms.gemini.common_utils.GeminiModelInfo.get_api_key"
Expand Down Expand Up @@ -113,6 +122,105 @@ def test_raises_without_api_key(self, config):
)


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") -> ResponsePartAddedEvent:
event = MagicMock(spec=ResponsePartAddedEvent)
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):
"""ResponsePartAddedEvent (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 ResponsePartAddedEvent, 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"


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."""
body = config.transform_request(
model="gemini-2.5-flash",
agent=None,
input="Hello",
optional_params={"stream": True},
litellm_params=GenericLiteLLMParams(api_key="test-key"),
headers={},
)

assert body.get("stream") is True
assert body.get("input") == "Hello"

def test_stream_false_not_included_when_absent(self, config):
body = config.transform_request(
model="gemini-2.5-flash",
agent=None,
input="Hello",
optional_params={},
litellm_params=GenericLiteLLMParams(api_key="test-key"),
headers={},
)

assert "stream" not in body


class TestInteractionOperationUrls:
"""Test that get/delete/cancel interaction URLs exclude API key."""

Expand Down
20 changes: 17 additions & 3 deletions ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { fetchAvailableModels, ModelGroup } from "../llm_calls/fetch_models";
import { makeOpenAIImageEditsRequest } from "../llm_calls/image_edits";
import { makeOpenAIImageGenerationRequest } from "../llm_calls/image_generation";
import { makeOpenAIResponsesRequest } from "../llm_calls/responses_api";
import { makeInteractionsRequest } from "../llm_calls/interactions_api";
import A2AMetrics from "./A2AMetrics";
import AdditionalModelSettings from "./AdditionalModelSettings";
import AudioRenderer from "./AudioRenderer";
Expand Down Expand Up @@ -649,6 +650,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
EndpointType.ANTHROPIC_MESSAGES,
EndpointType.EMBEDDINGS,
EndpointType.TRANSCRIPTION,
EndpointType.INTERACTIONS,
];

if (modelRequiredEndpoints.includes(endpointType as EndpointType) && !selectedModel) {
Expand Down Expand Up @@ -914,6 +916,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
customProxyBaseUrl || undefined,
);
}
} else if (endpointType === EndpointType.INTERACTIONS) {
await makeInteractionsRequest(
inputMessage,
(text, model) => updateTextUI("assistant", text, model),
selectedModel,
effectiveApiKey,
selectedTags,
signal,
customProxyBaseUrl || undefined,
);
}
}

Expand Down Expand Up @@ -1241,10 +1253,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
return true;
}
const optionEndpoint = getEndpointType(option.mode);
// Show chat models for responses/anthropic_messages endpoints as they are compatible
// Show chat models for responses/anthropic_messages/interactions endpoints as they are compatible
if (
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES
endpointType === EndpointType.ANTHROPIC_MESSAGES ||
endpointType === EndpointType.INTERACTIONS
) {
return optionEndpoint === endpointType || optionEndpoint === EndpointType.CHAT;
}
Expand Down Expand Up @@ -2089,7 +2102,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
endpointType === EndpointType.CHAT ||
endpointType === EndpointType.EMBEDDINGS ||
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES
endpointType === EndpointType.ANTHROPIC_MESSAGES ||
endpointType === EndpointType.INTERACTIONS
? "Type your message... (Shift+Enter for new line)"
: endpointType === EndpointType.A2A_AGENTS
? "Send a message to the A2A agent..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ export const ENDPOINT_OPTIONS = [
{ value: EndpointType.A2A_AGENTS, label: "/v1/a2a/message/send" },
{ value: EndpointType.MCP, label: "/mcp-rest/tools/call" },
{ value: EndpointType.REALTIME, label: "/v1/realtime" },
{ value: EndpointType.INTERACTIONS, label: "/v1beta/interactions" },
];
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export enum EndpointType {
A2A_AGENTS = "a2a_agents",
MCP = "mcp",
REALTIME = "realtime",
INTERACTIONS = "interactions",
Comment thread
Sameerlite marked this conversation as resolved.
Resolved
}

// Create a mapping between the model mode and the corresponding endpoint type
Expand Down
Loading
Loading