litellm/tests/e2e/llm_translation/realtime/realtime_client.py
Yuneng Jiang a215ecaf3d
fix(e2e): move the vertex realtime suite off the retired Live preview model
Google withdrew gemini-live-2.5-flash-preview-native-audio-09-2025 from the
Vertex Live API. Every session dies at setup:

  received 1007 (invalid frame payload data)
  gemini-live-2.5-flash-preview-native-audio-09-2025 is not supported in the live api.

The client sees session.created (the proxy synthesizes it on connect) and then
nothing, so both vertex_ai realtime tests time out waiting for session.updated.

Confirmed by probing the Vertex Live endpoint directly with the e2e stack's own
credentials:

  gemini-live-2.5-flash-preview-native-audio-09-2025 -> 1007, not supported
  gemini-live-2.5-flash-native-audio                 -> setupComplete

so this swaps to the non-preview sibling, which is the same native-audio class
and is what the cost map already carries for vertex_ai.

Not a litellm regression. The suspicion fell on #38395 because it removed the
native-audio speechConfig strip, but the setup payload this suite sends is
byte-identical either side of that change: the strip only fires when a client
sends a voice, and the e2e SessionConfig has no voice field. Google's rejection
names the model, not a field.

The gemini (Google AI Studio) provider keeps the -09-2025 id, which still works
there; only the Vertex endpoint dropped it.
2026-08-26 20:58:38 -07:00

352 lines
10 KiB
Python

"""Client for realtime e2e tests over the proxy's /v1/realtime websocket.
The proxy normalizes every provider's realtime stream into the OpenAI GA event
schema toward the client, so one GA-speaking websocket client validates every
provider and only the model alias changes. Every other e2e suite is HTTP-only;
this is the one suite that opens a websocket, using websockets.sync so it stays
synchronous like the rest of tests/e2e. Sent and received events are pydantic
models, matching the suite's no-raw-dicts rule.
"""
from __future__ import annotations
import time
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TypeVar
from urllib.parse import urlencode
from pydantic import BaseModel, ConfigDict
from websockets.sync.client import connect
from websockets.sync.connection import Connection
from e2e_config import unique_marker, ws_base_url
from proxy_client import ProxyClient
from models import LiteLLMParamsBody
_M = TypeVar("_M", bound=BaseModel)
def realtime_ws_url(model: str) -> str:
return f"{ws_base_url()}/v1/realtime?{urlencode({'model': model})}"
@dataclass(frozen=True, slots=True)
class RealtimeProvider:
"""A realtime provider the suite exercises. `litellm_params` is the deployment
the suite registers through /model/new (the gateway resolves the os.environ/*
credential refs), so the suite is self-contained and never depends on a static
gateway model_list. Every provider here is provisioned and asserted: per
tests/e2e/CLAUDE.md the suite never skips a provider, so a provider whose
credentials or upstream realtime model are missing on the gateway is a hard
failure, not a skip."""
id: str
alias: str
litellm_params: LiteLLMParamsBody
PROVIDERS = (
RealtimeProvider(
"openai",
"openai-realtime",
LiteLLMParamsBody(
model="openai/gpt-realtime-2",
api_key="os.environ/OPENAI_API_KEY",
),
),
RealtimeProvider(
"azure",
"azure-realtime",
LiteLLMParamsBody(
model="azure/gpt-realtime",
api_key="os.environ/AZURE_API_KEY",
api_version="2025-08-28",
realtime_protocol="GA",
),
),
RealtimeProvider(
"gemini",
"gemini-realtime",
LiteLLMParamsBody(
model="gemini/gemini-3.1-flash-live-preview",
api_key="os.environ/GEMINI_API_KEY",
),
),
RealtimeProvider(
"vertex_ai",
"vertex-realtime",
LiteLLMParamsBody(
model="vertex_ai/gemini-live-2.5-flash-native-audio",
vertex_location="us-central1",
vertex_credentials="os.environ/VERTEXAI_CREDENTIALS",
),
),
# RealtimeProvider("bedrock", "bedrock-realtime", ...) # TODO: Enable when Bedrock is passing
# RealtimeProvider(
# "xai",
# "xai-realtime",
# LiteLLMParamsBody(
# model="xai/grok-4-1-fast",
# api_key="os.environ/XAI_API_KEY",
# ),
# ), # TODO: Enable once xai Grok Voice realtime is passing end-to-end here
)
def realtime_model(provider: RealtimeProvider, provisioned: Mapping[str, str]) -> str:
"""Return the provisioned deployment name for this provider. Every provider in
PROVIDERS is provisioned at session start, so a missing entry is a harness bug,
never an environment skip - the suite hard-fails instead (see tests/e2e/CLAUDE.md)."""
model = provisioned.get(provider.id)
assert model is not None, (
f"{provider.id} was not provisioned; the realtime_models fixture is broken"
)
return model
# ---- sent events -------------------------------------------------------
class JsonSchemaProperty(BaseModel):
type: str
class JsonSchema(BaseModel):
type: str = "object"
properties: dict[str, JsonSchemaProperty]
required: list[str]
class FunctionTool(BaseModel):
type: str = "function"
name: str
description: str
parameters: JsonSchema
class SessionConfig(BaseModel):
modalities: list[str] = ["text"]
instructions: str | None = None
tools: list[FunctionTool] | None = None
tool_choice: str | None = None
class SessionUpdate(BaseModel):
type: str = "session.update"
session: SessionConfig
class InputTextContent(BaseModel):
type: str = "input_text"
text: str
class MessageItem(BaseModel):
type: str = "message"
role: str = "user"
content: list[InputTextContent]
class FunctionCallOutputItem(BaseModel):
type: str = "function_call_output"
call_id: str
output: str
class ConversationItemCreate(BaseModel):
type: str = "conversation.item.create"
item: MessageItem | FunctionCallOutputItem
class ResponseCreate(BaseModel):
type: str = "response.create"
def user_message(text: str) -> ConversationItemCreate:
return ConversationItemCreate(
item=MessageItem(content=[InputTextContent(text=text)])
)
# ---- received events ---------------------------------------------------
class ServerEnvelope(BaseModel):
model_config = ConfigDict(extra="allow")
type: str = ""
class DeltaEvent(BaseModel):
type: str
delta: str = ""
class FunctionCallArgumentsDone(BaseModel):
type: str
call_id: str
arguments: str
class ContentPart(BaseModel):
model_config = ConfigDict(extra="allow")
text: str | None = None
transcript: str | None = None
class OutputItem(BaseModel):
model_config = ConfigDict(extra="allow")
type: str | None = None
name: str | None = None
call_id: str | None = None
content: list[ContentPart] | None = None
class OutputItemDone(BaseModel):
type: str
item: OutputItem
class ResponsePayload(BaseModel):
model_config = ConfigDict(extra="allow")
usage: dict[str, object] | None = None
output: list[OutputItem] | None = None
class ResponseDone(BaseModel):
type: str
response: ResponsePayload
@dataclass(frozen=True, slots=True)
class ReceivedEvent:
type: str
payload: str
def events_of_type(
events: tuple[ReceivedEvent, ...], event_type: str
) -> tuple[ReceivedEvent, ...]:
return tuple(e for e in events if e.type == event_type)
def parse_last(
events: tuple[ReceivedEvent, ...], event_type: str, model: type[_M]
) -> _M | None:
matches = events_of_type(events, event_type)
return model.model_validate_json(matches[-1].payload) if matches else None
_TEXT_DELTA_TYPES = (
# beta protocol (OpenAI-Beta: realtime=v1)
"response.text.delta",
"response.audio_transcript.delta",
# GA protocol (default toward the proxy)
"response.output_text.delta",
"response.output_audio_transcript.delta",
)
def _text_from_response_done(events: tuple[ReceivedEvent, ...]) -> str:
done = parse_last(events, "response.done", ResponseDone)
if done is None or not done.response.output:
return ""
parts: list[str] = []
for item in done.response.output:
if item.type != "message":
continue
for content in item.content or []:
if content.text:
parts.append(content.text)
elif content.transcript:
parts.append(content.transcript)
return "".join(parts)
def transcript(events: tuple[ReceivedEvent, ...]) -> str:
for delta_type in _TEXT_DELTA_TYPES:
text = "".join(
DeltaEvent.model_validate_json(e.payload).delta
for e in events_of_type(events, delta_type)
)
if text:
return text
return _text_from_response_done(events)
def function_call_item(events: tuple[ReceivedEvent, ...]) -> OutputItem | None:
for event in events_of_type(events, "response.output_item.done"):
item = OutputItemDone.model_validate_json(event.payload).item
if item.type == "function_call":
return item
return None
# ---- session + client --------------------------------------------------
def _as_text(message: str | bytes) -> str:
return message.decode("utf-8") if isinstance(message, bytes) else message
@dataclass(frozen=True, slots=True)
class RealtimeSession:
connection: Connection
def send(self, event: BaseModel) -> None:
self.connection.send(event.model_dump_json(by_alias=True, exclude_none=True))
def collect_until(
self, stop_type: str, *, timeout: float
) -> tuple[ReceivedEvent, ...]:
deadline = time.monotonic() + timeout
collected: list[ReceivedEvent] = []
while time.monotonic() < deadline:
try:
text = _as_text(
self.connection.recv(timeout=deadline - time.monotonic())
)
except TimeoutError:
break
event = ReceivedEvent(
type=ServerEnvelope.model_validate_json(text).type, payload=text
)
collected.append(event)
if event.type == stop_type:
return tuple(collected)
raise TimeoutError(
f"no {stop_type!r} within {timeout}s; got {[e.type for e in collected]}"
)
@dataclass(frozen=True, slots=True)
class RealtimeClient:
proxy: ProxyClient
def provision(self, provider: RealtimeProvider) -> tuple[str, str]:
"""Register this provider's realtime deployment through /model/new and return
(model_name, model_id). The name is marker-unique so it never collides with a
same-named deployment already on the shared proxy, and mode=realtime makes it
show up as a realtime model on /model/info. add_deployment runs synchronously,
so the deployment is connectable as soon as this returns."""
model_name = f"{provider.alias}-{unique_marker()}"
model_id = self.proxy.create_model(
model_name, provider.litellm_params, mode="realtime"
)
return model_name, model_id
@contextmanager
def connect(
self, *, key: str, model: str, timeout: float = 15.0
) -> Generator[RealtimeSession, None, None]:
with connect(
realtime_ws_url(model),
additional_headers={"Authorization": f"Bearer {key}"},
open_timeout=timeout,
) as connection:
yield RealtimeSession(connection=connection)
def build_client(proxy: ProxyClient) -> RealtimeClient:
return RealtimeClient(proxy=proxy)