This commit is contained in:
Jordi Ibáñez 2026-09-13 00:17:32 +08:00 committed by GitHub
commit d1571ddade
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 6473 additions and 872 deletions

View file

@ -68,6 +68,16 @@ jobs:
pull-requests: read
outputs:
decision: ${{ steps.changes.outputs.decision }}
services:
redis:
image: ${{ inputs.artifact-name == 'proxy-auth' && 'redis:8.2.9-alpine@sha256:30abb90e62f14b737010746def3ba99cc79fe19dcdb3d37b41f21fc62e7da19d' || '' }}
ports:
- '127.0.0.1::6379'
options: >-
--health-cmd "redis-cli ping"
--health-interval 2s
--health-timeout 2s
--health-retries 15
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
@ -137,6 +147,7 @@ jobs:
RERUNS: ${{ inputs.reruns }}
DIST: ${{ inputs.dist }}
COVERAGE_CORE: sysmon
LITELLM_TEST_REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
run: |
if [ "${WORKERS}" = "0" ]; then
uv run --no-sync pytest ${TEST_PATH:?} \

View file

@ -133,6 +133,7 @@ jobs:
tests/test_litellm/proxy/hooks
tests/test_litellm/proxy/policy_engine
tests/test_litellm/proxy/client
tests/local_testing/test_realtime_call_redis.py
workers: 2
reruns: 2
timeout-minutes: 20

View file

@ -105,6 +105,8 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
"/{provider}/",
"/toolset/",
# Realtime / streaming
"/v1/live",
"/live",
"/v1/realtime",
"/realtime",
# Health & ops

View file

@ -8,7 +8,7 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, cast
from httpx import Response
from pydantic import BaseModel
from pydantic import BaseModel, Field, ValidationError
import litellm
import litellm._logging
@ -2563,7 +2563,12 @@ def handle_realtime_stream_cost_calculation(
if any(r.get("type") == _TRANSCRIPTION_COMPLETED_EVENT_TYPE for r in results)
else 0.0
)
total_cost: Final = input_cost_per_token + output_cost_per_token + transcription_cost
live_audio_cost: Final = handle_live_session_duration_cost(
results=results,
custom_llm_provider=custom_llm_provider,
litellm_model_name=litellm_model_name,
)
total_cost: Final = input_cost_per_token + output_cost_per_token + transcription_cost + live_audio_cost
_store_cost_breakdown_in_logging_obj(
litellm_logging_obj=litellm_logging_obj,
@ -2571,13 +2576,45 @@ def handle_realtime_stream_cost_calculation(
completion_tokens_cost_usd_dollar=output_cost_per_token,
cost_for_built_in_tools_cost_usd_dollar=0.0,
total_cost_usd_dollar=total_cost,
additional_costs={"transcription_cost": transcription_cost} if transcription_cost > 0 else None,
additional_costs={ # mutable-ok: logging cost breakdown requires a concrete dict
name: cost
for name, cost in (("transcription_cost", transcription_cost), ("live_audio_cost", live_audio_cost))
if cost > 0
}
or None,
data_residency=data_residency,
)
return total_cost
class _LiveSessionDurationUsage(BaseModel):
audio_duration_ms: float = Field(strict=True, ge=0, allow_inf_nan=False)
class _LiveSessionClosedEvent(BaseModel):
usage: _LiveSessionDurationUsage
def handle_live_session_duration_cost(
results: OpenAIRealtimeStreamList,
custom_llm_provider: str,
litellm_model_name: str,
) -> float:
terminal: Final = next((event for event in reversed(results) if event.get("type") == "session.closed"), None)
if terminal is None:
return 0.0
try:
usage: Final = _LiveSessionClosedEvent.model_validate(terminal).usage
except ValidationError:
return 0.0
try:
model_info: Final = litellm.get_model_info(model=litellm_model_name, custom_llm_provider=custom_llm_provider)
except Exception:
return 0.0
return usage.audio_duration_ms / 1000 * (model_info.get("input_cost_per_second") or 0.0)
def handle_realtime_transcription_cost_calculation(
results: OpenAIRealtimeStreamList,
custom_llm_provider: str,

View file

@ -377,6 +377,7 @@ def image_generation(
# Providers using llm_http_handler
#########################################################
elif custom_llm_provider in (
litellm.LlmProviders.CHATGPT,
litellm.LlmProviders.RECRAFT,
litellm.LlmProviders.AIML,
litellm.LlmProviders.GEMINI,
@ -401,6 +402,7 @@ def image_generation(
model=model,
prompt=prompt,
image_generation_provider_config=image_generation_config,
extra_headers=extra_headers,
image_generation_optional_request_params=optional_params,
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params_dict,
@ -965,9 +967,9 @@ def image_edit(
@client
async def aimage_edit(
image: FileTypes | list[FileTypes],
model: str,
prompt: str,
image: FileTypes | list[FileTypes] | None = None,
model: str = "",
prompt: str = "",
mask: str | None = None,
n: int | None = None,
quality: str | ImageGenerationRequestQuality | None = None,
@ -1005,11 +1007,9 @@ async def aimage_edit(
model=model, api_base=local_vars.get("base_url", None)
)
images: Final = image if isinstance(image, list) else [image]
func: Final = partial(
image_edit,
image=images,
image=image,
prompt=prompt,
mask=mask,
model=model,

View file

@ -1,11 +1,13 @@
import asyncio
import json
import traceback
from collections.abc import Coroutine, Mapping, Sequence
from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence
from contextvars import ContextVar
from dataclasses import dataclass
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypedDict, cast
from pydantic import TypeAdapter
from typing_extensions import ReadOnly
import litellm
@ -16,6 +18,7 @@ from litellm.types.llms.openai import (
OpenAIRealtimeEvents,
OpenAIRealtimeOutputItemDone,
OpenAIRealtimeResponseDelta,
OpenAIRealtimeSessionClosed,
OpenAIRealtimeStreamResponseBaseObject,
OpenAIRealtimeStreamSessionEvents,
)
@ -24,6 +27,10 @@ from litellm.types.realtime import ALL_DELTA_TYPES
from .litellm_logging import Logging as LiteLLMLogging
from .realtime_errors import client_close_code, realtime_error_event, websocket_close_reason
realtime_attachment_cleanup: Final[ContextVar[Callable[[], Awaitable[None]] | None]] = ContextVar(
"realtime_attachment_cleanup", default=None
)
if TYPE_CHECKING:
from websockets.asyncio.client import ClientConnection
from websockets.exceptions import ConnectionClosed
@ -139,11 +146,14 @@ class RealTimeStreaming:
force_transcription_model: str | None = None,
event_normalizer: RealtimeEventNormalizer | None = None,
logging_worker: _LoggingWorker = GLOBAL_LOGGING_WORKER,
*,
account_usage: bool = True,
):
self.websocket: _ClientWebSocket = websocket
self.backend_ws = backend_ws
self.logging_obj = logging_obj
self._logging_worker = logging_worker
self._account_usage = account_usage
self.messages: list[OpenAIRealtimeEvents] = []
self._backend_sent_frames: bool = False
self.input_message: dict = {}
@ -256,6 +266,9 @@ class RealTimeStreaming:
else:
message_obj = cast(dict[str, Any], json.loads(cast(str, message)))
self._collect_tool_calls_from_response_done(cast(dict, message_obj))
if message_obj.get("type") == "session.closed" and isinstance(message_obj.get("usage"), dict):
self.messages.append(TypeAdapter(OpenAIRealtimeSessionClosed).validate_python(message_obj))
return
if not self._should_store_message(message_obj):
return
try:
@ -410,8 +423,10 @@ class RealTimeStreaming:
if self.logging_obj:
self.logging_obj.pre_call(input=message, api_key="")
async def log_messages(self):
async def log_messages(self, *, wait_for_dispatch: bool = False):
"""Log messages in list"""
if not self._account_usage:
return
if self.logging_obj:
if self.input_messages:
self.logging_obj.model_call_details["messages"] = self.input_messages
@ -421,9 +436,12 @@ class RealTimeStreaming:
# Route through the bounded logging worker (per-coroutine timeout +
# concurrency cap) instead of a bare create_task, so a slow callback
# can't leave suspended tasks pinning each call's response in memory.
self._logging_worker.ensure_initialized_and_enqueue(
self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True)
)
if wait_for_dispatch:
await self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True)
else:
self._logging_worker.ensure_initialized_and_enqueue(
self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True)
)
self.logging_obj.model_call_details[REALTIME_SESSION_SUCCESS_LOGGED_KEY] = True
async def _send_to_backend(self, message: str) -> bool:
@ -1568,7 +1586,12 @@ class RealTimeStreaming:
finally:
forward_task.cancel()
client_task.cancel()
await asyncio.gather(forward_task, client_task, return_exceptions=True)
try:
await asyncio.gather(forward_task, client_task, return_exceptions=True)
finally:
cleanup: Final = realtime_attachment_cleanup.get()
if not self._account_usage and cleanup is not None:
await cleanup()
async def _close_client(self, close: BackendClose) -> None:
redacted_message: Final = redact_internal_details_from_client_message(close.message)

View file

@ -7,6 +7,7 @@ These are HTTP (not WebSocket) endpoints used by the WebRTC flow:
"""
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import Final
import httpx
@ -36,6 +37,14 @@ class BaseRealtimeHTTPConfig(ABC):
explicit api_base litellm.api_base env var hard-coded default
"""
def resolve_api_base(self, api_base: str | None, dynamic_api_base: str | None) -> str:
return self.get_api_base(dynamic_api_base or api_base)
def get_realtime_calls_extra_headers(
self, headers: dict[str, object] | None
) -> dict[str, object] | None: # mutable-ok: shared HTTP handler accepts a mutable header dictionary
return headers
@abstractmethod
def get_api_key(
self,
@ -97,6 +106,11 @@ class BaseRealtimeHTTPConfig(ABC):
"Authorization": f"Bearer {ephemeral_key}",
}
def transform_realtime_calls_response(
self, response: httpx.Response, model: str, model_id: str | None, headers: Mapping[str, object] | None
) -> httpx.Response:
return response
# ------------------------------------------------------------------ #
# Error handling #
# ------------------------------------------------------------------ #

View file

@ -49,8 +49,9 @@ class Authenticator:
self.auth_file = os.path.join(self.token_dir, os.getenv("CHATGPT_AUTH_FILE", "auth.json"))
self._ensure_token_dir()
def get_api_base(self) -> str:
return os.getenv("CHATGPT_API_BASE") or os.getenv("OPENAI_CHATGPT_API_BASE") or CHATGPT_API_BASE
@staticmethod
def get_api_base(default_base: str = CHATGPT_API_BASE) -> str:
return os.getenv("CHATGPT_API_BASE") or os.getenv("OPENAI_CHATGPT_API_BASE") or default_base
def get_access_token(self) -> str:
auth_data: Final = self._read_auth_file()

View file

@ -30,7 +30,7 @@ class ChatGPTConfig(OpenAIConfig):
api_key: str | None,
custom_llm_provider: str,
) -> tuple[str | None, str | None, str]:
dynamic_api_base: Final = self.authenticator.get_api_base()
dynamic_api_base: Final = api_base or self.authenticator.get_api_base()
try:
dynamic_api_key: Final = self.authenticator.get_access_token()
except GetAccessTokenError as e:

View file

@ -0,0 +1,94 @@
from collections.abc import Mapping
from typing import Final
from urllib.parse import urlsplit
import httpx
from pydantic import BaseModel, Field
from typing_extensions import ReadOnly, TypedDict
from litellm.types.realtime import RealtimeQueryParams, RealtimeSessionConfig
class CodexRealtimeOffer(BaseModel):
sdp: str = Field(min_length=1)
session: RealtimeSessionConfig
class CodexRealtimeCall(BaseModel):
call_id: str = Field(pattern=r"^rtc_[A-Za-z0-9_-]+$")
model: str
model_id: str | None = None
alias: str
api_base: str | None = None
extra_headers: Mapping[str, str] | None = None
extra_query: Mapping[str, str | tuple[str, ...]] | None = None
usage_supervised: bool = False
parallel_reserved: bool = False
owner: str
expires_at: float
class ChatGPTCallRouting(BaseModel):
model: str
model_id: str | None = None
api_base: str | None = None
extra_headers: Mapping[str, str] | None = None
extra_query: Mapping[str, str | tuple[str, ...]] | None = None
class CodexSidebandRequest(TypedDict):
api_base: ReadOnly[str | None]
model: ReadOnly[str]
chatgpt_realtime_call_id: ReadOnly[str]
query_params: ReadOnly[RealtimeQueryParams]
extra_headers: ReadOnly[Mapping[str, str] | None]
extra_query: ReadOnly[Mapping[str, str | tuple[str, ...]] | None]
def build_call_request(
offer: CodexRealtimeOffer, query: Mapping[str, str], headers: Mapping[str, str]
) -> dict[str, object]: # mutable-ok: proxy processor enriches the request dictionary
return { # mutable-ok: proxy processor enriches the request dictionary
"model": offer.session.model,
"sdp_body": offer.sdp.encode(),
"session": offer.session.model_dump(exclude_none=True),
"openai_ephemeral_key": "",
"chatgpt_realtime_client_query": { # mutable-ok: router request parameters
key: value for key, value in query.items() if key in ("intent", "architecture")
},
"chatgpt_realtime_client_headers": { # mutable-ok: router request headers
key: value
for key, value in headers.items()
if key in ("openai-alpha", "openai-beta", "x-session-id", "x-oai-attestation")
},
}
def parse_call_response(response: httpx.Response, alias: str, owner: str, expires_at: float) -> CodexRealtimeCall:
routing_data: Final = response.extensions.get("chatgpt_realtime")
if not routing_data:
raise ValueError("Direct call signaling requires a ChatGPT deployment")
routing: Final = ChatGPTCallRouting.model_validate(routing_data)
call_id: Final = urlsplit(response.headers.get("location", "")).path.rstrip("/").rsplit("/", 1)[-1]
return CodexRealtimeCall(
call_id=call_id,
model=routing.model,
model_id=routing.model_id,
alias=alias,
owner=owner,
expires_at=expires_at,
api_base=routing.api_base,
extra_headers=routing.extra_headers,
extra_query=routing.extra_query,
)
def build_sideband_request(call: CodexRealtimeCall) -> CodexSidebandRequest:
return CodexSidebandRequest(
api_base=call.api_base,
model=f"chatgpt/{call.model}",
chatgpt_realtime_call_id=call.call_id,
query_params=RealtimeQueryParams(model=call.model),
extra_headers=call.extra_headers,
extra_query=call.extra_query,
)

View file

@ -4,6 +4,8 @@ Constants and helpers for ChatGPT subscription OAuth.
import os
import platform
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Final
from uuid import uuid4
@ -105,6 +107,12 @@ You are producing plain text that will later be styled by the CLI. Follow these
"""
def without_oauth_identity_headers(headers: Mapping[str, object]) -> Mapping[str, object]:
return MappingProxyType(
{key: value for key, value in headers.items() if key.lower() not in ("authorization", "chatgpt-account-id")}
)
class ChatGPTAuthError(BaseLLMException):
def __init__(
self,

View file

@ -0,0 +1,154 @@
import base64
import os
from collections.abc import Mapping, Sequence
from pathlib import Path
from types import MappingProxyType
from typing import Final
from httpx._types import FileTypes as HTTPFileTypes
from httpx._types import RequestFiles
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
from litellm.llms.openai.image_generation.gpt_transformation import GPTImageGenerationConfig
from litellm.types.llms.openai import AllMessageValues, FileTypes
from litellm.types.router import GenericLiteLLMParams
from .authenticator import Authenticator
from .common_utils import without_oauth_identity_headers
from .responses.transformation import ChatGPTResponsesAPIConfig
class ReferenceImage(BaseModel):
model_config = ConfigDict(extra="forbid")
image_url: str = Field(pattern=r"^(data:image/(png|jpeg|webp);base64,|https://)")
def encode_reference(
file: HTTPFileTypes | FileTypes,
) -> dict[str, str]: # mutable-ok: image handler requires dictionaries
content: Final = file[1] if isinstance(file, tuple) else file
raw: Final = (
Path(os.fsdecode(content)).read_bytes()
if isinstance(content, os.PathLike)
else content.encode()
if isinstance(content, str)
else content
if isinstance(content, bytes)
else content.read()
)
content_type: Final = (
file[2]
if isinstance(file, tuple) and len(file) >= 3 and file[2]
else ImageEditRequestUtils.get_image_content_type(raw)
)
if content_type not in ("image/png", "image/jpeg", "image/webp"):
raise ValueError("Reference images must be PNG, JPEG, or WEBP")
return { # mutable-ok: JSON request serialization
"image_url": f"data:{content_type};base64," + base64.b64encode(raw).decode("ascii")
}
def image_headers(
headers: Mapping[str, object], model: str, params: Mapping[str, object]
) -> dict[str, object]: # mutable-ok: image handler requires dictionaries
auth_headers: Final = ChatGPTResponsesAPIConfig().validate_environment(
headers={}, # mutable-ok: Responses adapter header contract
model=model,
litellm_params=GenericLiteLLMParams.model_validate(params),
)
return { # mutable-ok: image handler requires dictionaries
**without_oauth_identity_headers(headers),
**auth_headers,
"accept": "application/json",
}
class ChatGPTImageGenerationConfig(GPTImageGenerationConfig):
def validate_environment(
self,
headers: Mapping[str, object],
model: str,
messages: Sequence[AllMessageValues],
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
api_key: str | None = None,
api_base: str | None = None,
) -> dict[str, object]: # mutable-ok: image handler requires dictionaries
return image_headers(headers, model, litellm_params)
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
stream: bool | None = None,
) -> str:
return f"{(api_base or Authenticator.get_api_base()).rstrip('/')}/images/generations"
def transform_image_generation_request(
self,
model: str,
prompt: str,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
headers: Mapping[str, object],
) -> dict[str, object]: # mutable-ok: image handler requires dictionaries
return {"model": model, "prompt": prompt, **optional_params} # mutable-ok: JSON request serialization
class ChatGPTImageEditConfig(OpenAIImageEditConfig):
def validate_environment(
self,
headers: Mapping[str, object],
model: str,
api_key: str | None = None,
litellm_params: Mapping[str, object] | None = None,
api_base: str | None = None,
) -> dict[str, object]: # mutable-ok: image handler requires dictionaries
return image_headers(headers, model, litellm_params or MappingProxyType({}))
def get_complete_url(self, model: str, api_base: str | None, litellm_params: Mapping[str, object]) -> str:
return f"{(api_base or Authenticator.get_api_base()).rstrip('/')}/images/edits"
def use_multipart_form_data(self) -> bool:
return False
def transform_image_edit_request(
self,
model: str,
prompt: str | None,
image: FileTypes | Sequence[FileTypes] | None,
image_edit_optional_request_params: Mapping[str, object],
litellm_params: GenericLiteLLMParams,
headers: Mapping[str, object],
) -> tuple[dict[str, object], RequestFiles]: # mutable-ok: image handler requires dictionaries
if image_edit_optional_request_params.get("mask") is not None:
raise ValueError("ChatGPT image editing does not support masks")
references: Final = getattr(litellm_params, "images", None)
if references is not None:
if image:
raise ValueError("Specify only one of image or images")
validated: Final = TypeAdapter(tuple[ReferenceImage, ...]).validate_python(references)
if not 1 <= len(validated) <= 5:
raise ValueError("images must contain between 1 and 5 reference images")
return { # mutable-ok: JSON request serialization
"model": model,
"prompt": prompt,
**image_edit_optional_request_params,
"images": tuple(item.model_dump() for item in validated),
}, ()
inputs: Final = tuple(image) if isinstance(image, list) else (image,) if image is not None else ()
encoded: Final = tuple(encode_reference(file) for file in inputs)
if not 1 <= len(encoded) <= 5:
raise ValueError("images must contain between 1 and 5 reference images")
return { # mutable-ok: JSON request serialization
"model": model,
"prompt": prompt,
**image_edit_optional_request_params,
"images": encoded,
}, ()

View file

@ -0,0 +1,285 @@
from collections.abc import Mapping
from enum import Enum, auto
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from httpx import URL, QueryParams, Response
from pydantic import TypeAdapter
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.llms.openai.realtime.handler import OpenAIRealtime
from litellm.llms.openai.realtime.http_transformation import OpenAIRealtimeHTTPConfig
from litellm.types.realtime import RealtimeQueryParams
from litellm.types.router import GenericLiteLLMParams
from litellm.utils import get_model_info
from .authenticator import Authenticator
from .common_utils import without_oauth_identity_headers
from .responses.transformation import ChatGPTResponsesAPIConfig
if TYPE_CHECKING:
from websockets.asyncio.client import ClientConnection
class CallAccounting(Enum):
SUPERVISED = auto()
def accounts_for_call_usage(params: GenericLiteLLMParams) -> bool:
return getattr(params, "chatgpt_call_accounting", None) is not CallAccounting.SUPERVISED
def configured_realtime_headers(headers: Mapping[str, object] | None) -> Mapping[str, str]:
validated: Final = TypeAdapter(Mapping[str, str]).validate_python(
without_oauth_identity_headers(headers or MappingProxyType({}))
)
return MappingProxyType({key.lower(): value for key, value in validated.items()})
def configured_realtime_query(params: GenericLiteLLMParams) -> Mapping[str, str | tuple[str, ...]]:
inbound: Final = TypeAdapter(Mapping[str, str]).validate_python(
getattr(params, "chatgpt_realtime_client_query", None) or MappingProxyType({})
)
configured: Final = TypeAdapter(
Mapping[str, str | int | float | bool | None | tuple[str | int | float | bool | None, ...]]
).validate_python(getattr(params, "extra_query", None) or MappingProxyType({}))
merged: Final = QueryParams(
tuple((key, value) for key, value in inbound.items() if key in ("intent", "architecture"))
).merge(configured)
return MappingProxyType(
{key: merged[key] if len(merged.get_list(key)) == 1 else tuple(merged.get_list(key)) for key in merged}
)
def realtime_call_headers(params: GenericLiteLLMParams) -> dict[str, str]: # mutable-ok: HTTP handler header contract
inbound: Final = TypeAdapter(Mapping[str, str]).validate_python(
getattr(params, "chatgpt_realtime_client_headers", None) or MappingProxyType({})
)
configured: Final = TypeAdapter(Mapping[str, object]).validate_python(
getattr(params, "extra_headers", None) or MappingProxyType({})
)
return { # mutable-ok: HTTP handler header contract
**MappingProxyType(
{
key.lower(): value
for key, value in inbound.items()
if key.lower() in ("openai-alpha", "openai-beta", "x-session-id", "x-oai-attestation")
}
),
**configured_realtime_headers(configured),
}
def realtime_headers(
params: GenericLiteLLMParams, headers: Mapping[str, str], extra_headers: Mapping[str, object] | None = None
) -> dict[str, str]: # mutable-ok: HTTP handler header contract
forwarded: Final = MappingProxyType(
{
key.lower(): value
for key, value in headers.items()
if key.lower() in ("openai-alpha", "openai-beta", "x-session-id", "x-oai-attestation")
}
)
return { # mutable-ok: HTTP handler updates headers
**ChatGPTResponsesAPIConfig().validate_environment(
headers={}, # mutable-ok: Responses adapter header contract
model="",
litellm_params=params,
),
**forwarded,
**configured_realtime_headers(extra_headers),
}
def realtime_endpoint(model: str) -> str:
try:
model_info: Final = get_model_info(model, custom_llm_provider="chatgpt")
except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models
return "realtime"
return "live" if "/v1/live" in (model_info.get("supported_endpoints") or ()) else "realtime"
class ChatGPTRealtime(OpenAIRealtime):
async def open_call_connection(self, model: str, api_base: str) -> "ClientConnection":
import websockets
url: Final = self._construct_url(api_base, RealtimeQueryParams(model=model))
return await websockets.connect(
url,
additional_headers=self._profile_headers,
max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES,
ssl=self._get_ssl_config(url),
open_timeout=20,
)
async def close_call(self, connection: "ClientConnection", model: str, api_base: str) -> None:
from websockets.exceptions import ConnectionClosed
if realtime_endpoint(model) == "live":
try:
await connection.send('{"type":"session.close"}')
return
except (ConnectionClosed, OSError):
await self.hangup_call(api_base)
return
await self.hangup_call(api_base)
async def hangup_call(self, api_base: str) -> None:
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.utils import LlmProviders
base: Final = URL(api_base)
url: Final = base.copy_with(
scheme="https" if base.scheme in ("https", "wss") else "http",
path=f"{base.path.rstrip('/')}/realtime/calls/{self._call_id}/hangup",
params=tuple(
(key, value)
for key, value in QueryParams(self._extra_query).multi_items()
if key not in ("model", "call_id")
),
)
client: Final = get_async_httpx_client(llm_provider=LlmProviders.CHATGPT)
response: Final = await client.post(str(url), headers=self._profile_headers, data=b"", timeout=10)
response.raise_for_status()
@staticmethod
def get_api_base(api_base: str | None = None) -> str:
return api_base or Authenticator.get_api_base(default_base="https://api.openai.com/v1")
def __init__(
self,
params: GenericLiteLLMParams,
headers: Mapping[str, str],
extra_headers: Mapping[str, object] | None = None,
) -> None:
super().__init__()
self._profile_headers = realtime_headers(params, headers, extra_headers)
self._call_id = TypeAdapter(str | None).validate_python(getattr(params, "chatgpt_realtime_call_id", None))
self._extra_query = configured_realtime_query(params)
self._account_usage = accounts_for_call_usage(params)
def _get_default_api_base(self) -> str:
return self.get_api_base()
def _resolve_api_key(self, api_key: str | None) -> str:
return "chatgpt-oauth"
def _accounts_for_call_usage(self) -> bool:
return self._account_usage
def _get_additional_headers(
self, api_key: str, *, openai_beta_realtime: bool = False
) -> dict[str, str]: # mutable-ok: HTTP handler header contract
return { # mutable-ok: HTTP handler updates headers
**(MappingProxyType({"OpenAI-Beta": "realtime=v1"}) if openai_beta_realtime else MappingProxyType({})),
**self._profile_headers,
}
def _construct_url(self, api_base: str, query_params: RealtimeQueryParams) -> str:
base: Final = URL(api_base)
endpoint: Final = realtime_endpoint(query_params.get("model", ""))
if self._call_id:
gateway_query: Final = tuple(
(key, value)
for key, value in QueryParams(self._extra_query).multi_items()
if key not in ("model", "call_id")
)
return str(
base.copy_with(
scheme="wss" if base.scheme in ("https", "wss") else "ws",
path=f"{base.path.rstrip('/')}/{endpoint}/{self._call_id}"
if endpoint == "live"
else f"{base.path.rstrip('/')}/realtime",
params=gateway_query + (() if endpoint == "live" else (("call_id", self._call_id),)),
)
)
return str(
base.copy_with(
scheme="wss" if base.scheme in ("https", "wss") else "ws",
path=f"{base.path.rstrip('/')}/{endpoint}",
params=QueryParams(TypeAdapter(Mapping[str, str | None]).validate_python(query_params)).merge(
tuple(
(key, value)
for key, value in QueryParams(self._extra_query).multi_items()
if key not in ("model", "call_id")
)
),
)
)
class ChatGPTRealtimeHTTPConfig(OpenAIRealtimeHTTPConfig):
realtime_calls_json: Final = True
def __init__(self, params: GenericLiteLLMParams, use_codex_backend: bool = True) -> None:
self._params = params
self._use_codex_backend = use_codex_backend
def get_api_base(
self,
api_base: str | None,
**kwargs: object, # kwargs-ok: provider interface accepts optional credentials
) -> str:
return api_base or (Authenticator.get_api_base() if self._use_codex_backend else ChatGPTRealtime.get_api_base())
def resolve_api_base(self, api_base: str | None, dynamic_api_base: str | None) -> str:
return self.get_api_base(api_base)
def get_realtime_calls_extra_headers(
self, headers: dict[str, object] | None
) -> dict[str, object]: # mutable-ok: shared HTTP handler accepts a mutable header dictionary
return {**realtime_call_headers(self._params)} # mutable-ok: shared HTTP header contract
def get_api_key(
self,
api_key: str | None,
**kwargs: object, # kwargs-ok: provider interface accepts optional credentials
) -> str:
return "chatgpt-oauth"
def get_realtime_calls_url(self, api_base: str | None, model: str, api_version: str | None = None) -> str:
query: Final = configured_realtime_query(self._params)
return str(URL(f"{self.get_api_base(api_base).rstrip('/')}/realtime/calls", params=query))
def transform_realtime_calls_response(
self, response: Response, model: str, model_id: str | None, headers: Mapping[str, object] | None
) -> Response:
response.extensions["chatgpt_realtime"] = (
MappingProxyType( # rebind-ok: HTTPX response extensions carry provider routing metadata
{
"model": model,
"model_id": model_id,
"api_base": ChatGPTRealtime.get_api_base(self._params.api_base),
"extra_headers": configured_realtime_headers(headers),
"extra_query": configured_realtime_query(self._params),
}
)
)
return response
def get_realtime_calls_headers(
self, ephemeral_key: str
) -> dict[str, str]: # mutable-ok: HTTP handler header contract
return realtime_headers(self._params, MappingProxyType({}))
def validate_environment(
self,
headers: Mapping[str, str],
model: str,
api_key: str | None = None,
) -> dict[str, str]: # mutable-ok: HTTP handler header contract
return { # mutable-ok: HTTP handler updates headers
**realtime_headers(self._params, headers),
"Content-Type": "application/json",
}
def get_complete_url(self, api_base: str | None, model: str, api_version: str | None = None) -> str:
return f"{self.get_api_base(api_base).rstrip('/')}/realtime/client_secrets"
def get_transcription_session_url(
self,
api_base: str | None,
model: str,
api_version: str | None = None,
) -> str:
return f"{self.get_api_base(api_base).rstrip('/')}/realtime/transcription_sessions"

View file

@ -102,6 +102,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
"reasoning",
"previous_response_id",
"truncation",
"text",
}
return {k: v for k, v in request.items() if k in allowed_keys}

View file

@ -6336,6 +6336,9 @@ class BaseLLMHTTPHandler:
Uses provider_config (BaseRealtimeHTTPConfig) for URL construction and
header auth when available; falls back to the legacy OpenAI-style defaults.
"""
from litellm.llms.chatgpt.common_utils import without_oauth_identity_headers
from litellm.llms.chatgpt.realtime import ChatGPTRealtimeHTTPConfig
if client is None or not isinstance(client, AsyncHTTPHandler):
async_httpx_client = get_async_httpx_client(
llm_provider=litellm.LlmProviders.OPENAI,
@ -6361,7 +6364,11 @@ class BaseLLMHTTPHandler:
}
if extra_headers:
headers.update(extra_headers)
headers.update(
without_oauth_identity_headers(extra_headers)
if isinstance(provider_config, ChatGPTRealtimeHTTPConfig)
else extra_headers
)
logging_obj.pre_call(
input=request_data,
@ -6412,6 +6419,9 @@ class BaseLLMHTTPHandler:
- sdp: the SDP offer (text)
- session: JSON string with {"type": "realtime", "model": "...", ...}
"""
from litellm.llms.chatgpt.common_utils import without_oauth_identity_headers
from litellm.llms.chatgpt.realtime import ChatGPTRealtimeHTTPConfig
if client is None or not isinstance(client, AsyncHTTPHandler):
async_httpx_client = get_async_httpx_client(
llm_provider=litellm.LlmProviders.OPENAI,
@ -6429,11 +6439,15 @@ class BaseLLMHTTPHandler:
}
if extra_headers:
headers.update(extra_headers)
headers.update(
without_oauth_identity_headers(extra_headers)
if isinstance(provider_config, ChatGPTRealtimeHTTPConfig)
else extra_headers
)
# Build multipart form data: sdp + session JSON
session_data: Final = session_config or {}
if "type" not in session_data:
if "type" not in session_data and not getattr(provider_config, "realtime_calls_json", False):
session_data["type"] = "realtime"
if "model" not in session_data and model:
session_data["model"] = model
@ -6456,6 +6470,13 @@ class BaseLLMHTTPHandler:
)
try:
if getattr(provider_config, "realtime_calls_json", False):
return await async_httpx_client.post(
url=url,
headers=headers,
json={"sdp": sdp_text, "session": session_data}, # mutable-ok: JSON signaling payload
timeout=timeout,
)
return await async_httpx_client.post(
url=url,
headers=headers,
@ -6653,6 +6674,14 @@ class BaseLLMHTTPHandler:
else:
raise Exception(f"Unexpected error while closing WebSocket: {close_error}")
@staticmethod
def _image_extra_headers(custom_llm_provider: str, headers: Mapping[str, object]) -> Mapping[str, object]:
if custom_llm_provider == "chatgpt":
from litellm.llms.chatgpt.common_utils import without_oauth_identity_headers
return without_oauth_identity_headers(headers)
return headers
def image_edit_handler(
self,
model: str,
@ -6709,7 +6738,7 @@ class BaseLLMHTTPHandler:
)
if extra_headers:
headers.update(extra_headers)
headers.update(self._image_extra_headers(custom_llm_provider, extra_headers))
api_base: Final = image_edit_provider_config.get_complete_url(
model=model,
@ -6808,7 +6837,7 @@ class BaseLLMHTTPHandler:
)
if extra_headers:
headers.update(extra_headers)
headers.update(self._image_extra_headers(custom_llm_provider, extra_headers))
api_base: Final = image_edit_provider_config.get_complete_url(
model=model,
@ -6925,7 +6954,7 @@ class BaseLLMHTTPHandler:
)
if extra_headers:
headers.update(extra_headers)
headers.update(self._image_extra_headers(custom_llm_provider, extra_headers))
api_base: Final = image_generation_provider_config.get_complete_url(
model=model,
@ -7032,7 +7061,7 @@ class BaseLLMHTTPHandler:
)
if extra_headers:
headers.update(extra_headers)
headers.update(self._image_extra_headers(custom_llm_provider, extra_headers))
api_base: Final = image_generation_provider_config.get_complete_url(
model=model,

View file

@ -38,6 +38,14 @@ class OpenAIRealtime(OpenAIChatCompletion):
"""
return "https://api.openai.com/"
def _resolve_api_key(self, api_key: str | None) -> str:
if api_key is None:
raise ValueError("api_key is required for OpenAI realtime calls")
return api_key
def _accounts_for_call_usage(self) -> bool:
return True
def _get_additional_headers(
self,
api_key: str,
@ -117,6 +125,7 @@ class OpenAIRealtime(OpenAIChatCompletion):
query_params: RealtimeQueryParams | None = None,
user_api_key_dict: object | None = None,
litellm_metadata: dict | None = None,
account_usage: bool = True,
**kwargs: object,
):
import websockets
@ -124,8 +133,7 @@ class OpenAIRealtime(OpenAIChatCompletion):
if api_base is None:
api_base = self._get_default_api_base()
if api_key is None:
raise ValueError("api_key is required for OpenAI realtime calls")
resolved_api_key: Final = self._resolve_api_key(api_key)
# Use all query params if provided, else fallback to just model
if query_params is None:
@ -143,12 +151,12 @@ class OpenAIRealtime(OpenAIChatCompletion):
"If your client expects beta event names, add 'OpenAI-Beta: realtime=v1' "
"to the WebSocket headers sent to the LiteLLM proxy."
)
headers: Final = self._get_additional_headers(api_key, openai_beta_realtime=openai_beta_realtime)
headers: Final = self._get_additional_headers(resolved_api_key, openai_beta_realtime=openai_beta_realtime)
# Log a masked request preview consistent with other endpoints.
logging_obj.pre_call(
input=None,
api_key=api_key,
api_key=resolved_api_key,
additional_args={
"api_base": url,
"headers": headers,
@ -172,6 +180,7 @@ class OpenAIRealtime(OpenAIChatCompletion):
model if (query_params or {}).get("intent") == "transcription" else None
),
event_normalizer=self._make_event_normalizer(),
account_usage=account_usage and self._accounts_for_call_usage(),
)
await realtime_streaming.bidirectional_forward()

View file

@ -27932,6 +27932,16 @@
"max_tokens": 8191,
"mode": "embedding"
},
"chatgpt/gpt-live-1-codex": {
"litellm_provider": "chatgpt",
"mode": "realtime",
"supported_endpoints": [
"/v1/realtime/calls",
"/v1/live"
],
"supports_audio_input": true,
"supports_audio_output": true
},
"chatgpt/gpt-5.5": {
"litellm_provider": "chatgpt",
"source": "https://platform.openai.com/docs/models/gpt-5.5",

View file

@ -102,6 +102,10 @@ class ReconcileOutcome(NamedTuple):
live_after: frozenset[str] | None
class InternalRequestOrigin(enum.Enum):
REALTIME_OBSERVER = enum.auto()
class SupportedDBObjectType(str, enum.Enum):
"""
Supported database object types for fine-grained DB storage control.
@ -405,6 +409,9 @@ class LiteLLMRoutes(enum.Enum):
"/realtime?{model}",
"/v1/realtime?{model}",
"/openai/v1/realtime?{model}",
"/live",
"/v1/live",
"/v1/live/{call_id}",
# realtime (GA WebRTC HTTP routes)
"/realtime/client_secrets",
"/v1/realtime/client_secrets",

View file

@ -1866,15 +1866,27 @@ def _extract_model_candidates_from_request(
uses_completion_model_sources: Final = _route_matches_any_marker(
route=route, markers=_MODEL_ROUTING_COMPLETION_MODEL_ROUTE_MARKERS
)
session: Final[object] = (
request_data.get("session")
if _route_matches_any_marker(route=route, markers=_MODEL_ROUTING_SESSION_MODEL_ROUTE_MARKERS)
else None
)
parsed_session: Final[object] = safe_json_loads(session) if isinstance(session, str) else session
session_model: Final[object] = parsed_session.get("model") if isinstance(parsed_session, dict) else None
if (
_route_matches_any_marker(route=route, markers=("/realtime/calls",))
and isinstance(session_model, str)
and session_model
):
candidates.append(session_model)
return candidates
body_model: Final = request_data.get("model")
_append_model_candidates(candidates, body_model)
if uses_body_target_model_sources or not body_model:
_append_model_candidates(candidates, request_data.get("target_model_names"))
if _route_matches_any_marker(route=route, markers=_MODEL_ROUTING_SESSION_MODEL_ROUTE_MARKERS):
session: Final = request_data.get("session")
if isinstance(session, dict):
_append_model_candidates(candidates, session.get("model"))
_append_model_candidates(candidates, session_model)
if uses_completion_model_sources and isinstance(request_data.get("completion"), dict):
_append_model_candidates(candidates, request_data["completion"].get("model"))

View file

@ -350,7 +350,7 @@ async def _check_key_model_budget_with_fallback(
model=model_name,
)
except litellm.BudgetExceededError as e:
if request_data.get("model") != model_name:
if request_data.get("model") != model_name or request.scope.get("litellm_pinned_realtime_model") == model_name:
raise e
fallback_model: Final = await model_max_budget_limiter.get_fallback_model_within_budget(
user_api_key_dict=valid_token,
@ -535,6 +535,38 @@ def _apply_budget_limits_to_end_user_params(
verbose_proxy_logger.debug("Applied budget limits to end user %s", end_user_id)
def get_websocket_api_key(websocket: WebSocket) -> str | None:
from litellm.proxy.proxy_server import general_settings
custom_header: Final = general_settings.get("litellm_key_header_name")
if isinstance(custom_header, str):
if not websocket.headers.get(custom_header):
return None
request: Final = Request(
{"type": "http", "headers": websocket.scope.get("headers", [])} # mutable-ok: ASGI request scope
)
return get_api_key_from_custom_header(request, custom_header)
custom_key: Final = websocket.headers.get("x-litellm-api-key")
if custom_key is not None:
return _get_bearer_token_or_received_api_key(custom_key)
authorization: Final = websocket.headers.get("authorization")
if authorization:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=403, detail="Invalid Authorization header format")
return authorization[len("Bearer ") :].strip()
api_key: Final = websocket.headers.get("api-key")
if api_key:
return api_key
return next(
(
protocol.strip().removeprefix("openai-insecure-api-key.")
for protocol in websocket.headers.get("sec-websocket-protocol", "").split(",")
if protocol.strip().startswith("openai-insecure-api-key.")
),
None,
)
async def user_api_key_auth_websocket(websocket: WebSocket):
# Accept the WebSocket connection
@ -557,40 +589,33 @@ async def user_api_key_auth_websocket(websocket: WebSocket):
request._url = websocket.url
query_params: Final = websocket.query_params
model: Final = query_params.get("model")
async def return_body():
return _realtime_request_body(model)
request.body = return_body
authorization: Final = websocket.headers.get("authorization")
# If no Authorization header, try the api-key header
if not authorization:
api_key = websocket.headers.get("api-key")
if not api_key:
# Try extracting from WebSocket subprotocol (browser clients)
for protocol in websocket.headers.get("sec-websocket-protocol", "").split(","):
protocol = protocol.strip()
if protocol.startswith("openai-insecure-api-key."):
api_key = protocol[len("openai-insecure-api-key.") :]
break
if not api_key:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
raise HTTPException(status_code=403, detail="No API key provided")
else:
# Extract the API key from the Bearer token
if not authorization.startswith("Bearer "):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
raise HTTPException(status_code=403, detail="Invalid Authorization header format")
api_key = authorization[len("Bearer ") :].strip()
try:
api_key: Final = get_websocket_api_key(websocket)
except HTTPException:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
raise
if not api_key:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
raise HTTPException(status_code=403, detail="No API key provided")
# Call user_api_key_auth with the extracted API key
# Note: You'll need to modify this to work with WebSocket context if needed
try:
from litellm.proxy.realtime_endpoints.call_sessions import decode_call
call_token: Final = websocket.path_params.get("call_id") or websocket.query_params.get("call_id")
model: Final = (
decode_call(call_token, f"Bearer {api_key}").alias
if call_token is not None
else websocket.query_params.get("model")
)
if call_token is not None:
request.scope["litellm_pinned_realtime_model"] = model
async def return_body():
return _realtime_request_body(model)
request.body = return_body
return await user_api_key_auth(request=request, api_key=f"Bearer {api_key}")
except Exception as e:
if is_invalid_virtual_key_error(e):

View file

@ -1832,6 +1832,8 @@ class ProxyBaseLLMRequestProcessing:
user_api_base: str | None = None,
model: str | None = None,
llm_router: Router | None = None,
*,
internal_realtime_observer: bool = False,
) -> tuple[dict, LiteLLMLoggingObj]:
start_time: Final = datetime.now() # start before calling guardrail hooks
@ -1996,6 +1998,11 @@ class ProxyBaseLLMRequestProcessing:
user_api_key_dict=user_api_key_dict,
data=self.data,
call_type=route_type,
**(
MappingProxyType({"internal_realtime_observer": True})
if internal_realtime_observer
else MappingProxyType({})
),
)
if route_type == "aget_responses":
attach_post_call_pipelines_to_retrieval(

View file

@ -1,9 +1,10 @@
import asyncio
import sys
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
from typing_extensions import TypedDict
import litellm
@ -12,7 +13,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.exceptions import RateLimitType
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import _get_parent_otel_span_from_kwargs
from litellm.proxy._types import CommonProxyErrors, CurrentItemRateLimit, UserAPIKeyAuth
from litellm.proxy._types import CommonProxyErrors, CurrentItemRateLimit, InternalRequestOrigin, UserAPIKeyAuth
from litellm.proxy.auth.auth_utils import (
get_key_model_rpm_limit,
get_key_model_tpm_limit,
@ -50,11 +51,81 @@ class CacheObject(TypedDict):
request_count_end_user_id: dict | None
class _RealtimeAttachmentReservations(BaseModel):
cache_keys: tuple[str, ...] = ()
global_acquired: bool = False
def acquire(self, key: str) -> None:
self.cache_keys = tuple(dict.fromkeys((*self.cache_keys, key)))
def acquire_global(self) -> None:
self.global_acquired = True
def take(self) -> tuple[tuple[str, ...], bool]:
owned: Final = (self.cache_keys, self.global_acquired)
self.cache_keys = ()
self.global_acquired = False
return owned
_RELEASE_REALTIME_COUNTER_LUA: Final = """
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
local value = cjson.decode(raw)
value.current_requests = math.max(value.current_requests - 1, 0)
redis.call('SET', KEYS[1], cjson.encode(value), 'KEEPTTL')
return 1
"""
class _PROXY_MaxParallelRequestsHandler(CustomLogger):
# Class variables or attributes
def __init__(self, internal_usage_cache: InternalUsageCache):
self.internal_usage_cache = internal_usage_cache
def begin_realtime_attachment(self, request_data: dict[str, object]) -> None:
request_data["_legacy_realtime_attachment_reservations"] = ( # rebind-ok: request-scoped cleanup receipt
_RealtimeAttachmentReservations()
)
async def async_release_realtime_attachment(
self, request_data: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth
) -> None:
receipt: Final = request_data.get("_legacy_realtime_attachment_reservations")
if not isinstance(receipt, _RealtimeAttachmentReservations):
return
keys, global_acquired = receipt.take()
if global_acquired:
await self.internal_usage_cache.async_increment_cache(
key="global_max_parallel_requests",
value=-1,
local_only=True,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
)
for key in keys:
await self._release_realtime_counter(key)
async def _release_realtime_counter(self, key: str) -> None:
local: Final = self.internal_usage_cache.dual_cache.in_memory_cache
remote: Final = self.internal_usage_cache.dual_cache.redis_cache
raw: Final[object] = local.get_cache(key)
current: Final = TypeAdapter(Mapping[str, int] | None).validate_python(raw)
updated: Final = (
{ # mutable-ok: shared cache counter dict
**current,
"current_requests": max(current["current_requests"] - 1, 0),
}
if current is not None
else None
)
if updated is not None:
local.set_cache(key, updated, ttl=60)
if remote is not None:
release: Final = remote.async_register_script(_RELEASE_REALTIME_COUNTER_LUA)
await release(keys=(key,), args=())
if local.get_cache(key) is updated:
local.delete_cache(key)
def print_verbose(self, print_statement):
try:
verbose_proxy_logger.debug(print_statement)
@ -142,6 +213,9 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
local_only=True,
)
receipt: Final = data.get("_legacy_realtime_attachment_reservations")
if isinstance(receipt, _RealtimeAttachmentReservations):
receipt.acquire(request_count_api_key)
return new_val
def time_to_next_minute(self) -> float:
@ -299,6 +373,9 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
local_only=True,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
)
receipt: Final = data.get("_legacy_realtime_attachment_reservations")
if isinstance(receipt, _RealtimeAttachmentReservations):
receipt.acquire_global()
_model = data.get("model", None)
current_date: Final = datetime.now().strftime("%Y-%m-%d")
@ -480,6 +557,13 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
values_to_update_in_cache=values_to_update_in_cache,
)
if isinstance(data.get("_legacy_realtime_attachment_reservations"), _RealtimeAttachmentReservations):
await self.internal_usage_cache.async_batch_set_cache(
cache_list=values_to_update_in_cache,
ttl=60,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
)
return
asyncio.create_task(
self.internal_usage_cache.async_batch_set_cache(
cache_list=values_to_update_in_cache,
@ -489,6 +573,7 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
)
async def async_log_success_event(self, kwargs, response_obj: object, start_time, end_time):
releases_slot: Final = kwargs.get("internal_request_origin") is not InternalRequestOrigin.REALTIME_OBSERVER
from litellm.proxy.common_utils.callback_utils import (
get_model_group_from_litellm_kwargs,
)
@ -521,7 +606,7 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
# Setup values
# ------------
if global_max_parallel_requests is not None:
if releases_slot and global_max_parallel_requests is not None:
# get value from cache
_key: Final = "global_max_parallel_requests"
# decrement
@ -552,13 +637,13 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
key=request_count_api_key,
litellm_parent_otel_span=litellm_parent_otel_span,
) or {
"current_requests": 1,
"current_requests": int(releases_slot),
"current_tpm": 0,
"current_rpm": 0,
}
new_val = {
"current_requests": max(current["current_requests"] - 1, 0),
"current_requests": max(current["current_requests"] - int(releases_slot), 0),
"current_tpm": current["current_tpm"] + total_tokens,
"current_rpm": current["current_rpm"],
}
@ -593,13 +678,13 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
key=request_count_api_key,
litellm_parent_otel_span=litellm_parent_otel_span,
) or {
"current_requests": 1,
"current_requests": int(releases_slot),
"current_tpm": 0,
"current_rpm": 0,
}
new_val = {
"current_requests": max(current["current_requests"] - 1, 0),
"current_requests": max(current["current_requests"] - int(releases_slot), 0),
"current_tpm": current["current_tpm"] + total_tokens,
"current_rpm": current["current_rpm"],
}
@ -619,13 +704,13 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
key=request_count_api_key,
litellm_parent_otel_span=litellm_parent_otel_span,
) or {
"current_requests": 1,
"current_tpm": total_tokens,
"current_rpm": 1,
"current_requests": int(releases_slot),
"current_tpm": total_tokens if releases_slot else 0,
"current_rpm": int(releases_slot),
}
new_val = {
"current_requests": max(current["current_requests"] - 1, 0),
"current_requests": max(current["current_requests"] - int(releases_slot), 0),
"current_tpm": current["current_tpm"] + total_tokens,
"current_rpm": current["current_rpm"],
}
@ -645,13 +730,13 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
key=request_count_api_key,
litellm_parent_otel_span=litellm_parent_otel_span,
) or {
"current_requests": 1,
"current_tpm": total_tokens,
"current_rpm": 1,
"current_requests": int(releases_slot),
"current_tpm": total_tokens if releases_slot else 0,
"current_rpm": int(releases_slot),
}
new_val = {
"current_requests": max(current["current_requests"] - 1, 0),
"current_requests": max(current["current_requests"] - int(releases_slot), 0),
"current_tpm": current["current_tpm"] + total_tokens,
"current_rpm": current["current_rpm"],
}
@ -671,13 +756,13 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
key=request_count_api_key,
litellm_parent_otel_span=litellm_parent_otel_span,
) or {
"current_requests": 1,
"current_tpm": total_tokens,
"current_rpm": 1,
"current_requests": int(releases_slot),
"current_tpm": total_tokens if releases_slot else 0,
"current_rpm": int(releases_slot),
}
new_val = {
"current_requests": max(current["current_requests"] - 1, 0),
"current_requests": max(current["current_requests"] - int(releases_slot), 0),
"current_tpm": current["current_tpm"] + total_tokens,
"current_rpm": current["current_rpm"],
}
@ -694,6 +779,8 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
self.print_verbose(e)
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
if kwargs.get("internal_request_origin") is InternalRequestOrigin.REALTIME_OBSERVER:
return
try:
self.print_verbose("Inside Max Parallel Request Failure Hook")
litellm_parent_otel_span: Final[Span | None] = _get_parent_otel_span_from_kwargs(kwargs=kwargs)

View file

@ -9,10 +9,12 @@ import binascii
import logging
import os
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence, Set
from collections.abc import Awaitable, Callable, Generator, Mapping, Sequence, Set
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
Any,
@ -55,6 +57,7 @@ from litellm.proxy.hooks.batch_enqueued_tokens import (
canonical_provider_batch_id,
)
from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit
from litellm.proxy.hooks.realtime_call_lease import RealtimeCallLease, is_realtime_call_attachment
from litellm.router_utils.add_retry_fallback_headers import (
ensure_response_additional_headers,
response_has_hidden_params,
@ -306,6 +309,23 @@ end
return results
"""
PARALLEL_RENEW_SCRIPT: Final = """
local clock = redis.call('TIME')
local now = tonumber(clock[1])
local ttl = tonumber(ARGV[2])
for i = 1, #KEYS do
local score = redis.call('ZSCORE', KEYS[i], ARGV[1])
if not score or tonumber(score) <= now - ttl then
return {0}
end
end
for i = 1, #KEYS do
redis.call('ZADD', KEYS[i], 'XX', now, ARGV[1])
redis.call('EXPIRE', KEYS[i], ttl)
end
return {1}
"""
TOKEN_INCREMENT_SCRIPT: Final = """
local results = {}
@ -418,9 +438,17 @@ class ParallelRequestGauge(TypedDict):
descriptor_key: str
def _without_parallel_limit(descriptor: RateLimitDescriptor) -> RateLimitDescriptor:
rate_limit: Final[RateLimitDescriptorRateLimitObject] = {
**(descriptor.get("rate_limit") or MappingProxyType({})),
"max_parallel_requests": None,
}
return RateLimitDescriptor(key=descriptor["key"], value=descriptor["value"], rate_limit=rate_limit)
class ParallelSlotAcquisition(TypedDict):
slot_id: str
counter_keys: list[str]
counter_keys: Sequence[str]
class RateLimitStatus(TypedDict):
@ -548,6 +576,15 @@ def get_request_stash() -> RequestRateLimiterStash | None:
return _request_stash.get()
@contextmanager
def isolated_request_stash() -> Generator[None]:
token: Final = _request_stash.set(None)
try:
yield
finally:
_request_stash.reset(token)
def get_or_create_request_stash() -> RequestRateLimiterStash:
stash = _request_stash.get()
if stash is None:
@ -597,6 +634,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
parallel_acquire_script: _AsyncLuaScript | None
parallel_release_script: _AsyncLuaScript | None
parallel_count_script: _AsyncLuaScript | None
parallel_renew_script: _AsyncLuaScript | None
def __init__(
self,
@ -629,6 +667,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self.parallel_count_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
PARALLEL_COUNT_SCRIPT
)
self.parallel_renew_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
PARALLEL_RENEW_SCRIPT
)
else:
self.batch_rate_limiter_script = None
self.token_increment_script = None
@ -637,6 +678,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self.parallel_acquire_script = None
self.parallel_release_script = None
self.parallel_count_script = None
self.parallel_renew_script = None
self.window_size = int(os.getenv("LITELLM_RATE_LIMIT_WINDOW_SIZE", 60))
@ -998,7 +1040,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def in_memory_cache_sliding_window(
self,
keys: list[str],
keys: Sequence[str],
now_int: int,
window_size: int,
) -> CacheCounterValues:
@ -1152,7 +1194,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
crc: Final = binascii.crc_hqx(key.encode("utf-8"), 0)
return crc % REDIS_CLUSTER_SLOTS
def _group_keys_by_hash_tag(self, keys: list[str]) -> dict[str, list[str]]:
def _group_keys_by_hash_tag(self, keys: Sequence[str]) -> Mapping[str, Sequence[str]]:
"""
Group keys by their Redis hash tag to ensure cluster compatibility.
@ -1172,7 +1214,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
groups[slot_key].append(key)
else:
# For regular Redis, no grouping needed - process all keys together
groups[REDIS_NODE_HASHTAG_NAME] = keys
return MappingProxyType({REDIS_NODE_HASHTAG_NAME: keys})
return groups
@ -1470,12 +1512,15 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
"""
gauge_keys: Final = [gauge["counter_key"] for gauge in gauges]
if self._is_redis_cluster() and self.parallel_acquire_script is not None:
return await self._check_cluster_parallel_gauges(gauges, slot_id, parent_otel_span, read_only)
if read_only:
if self.parallel_count_script is not None:
try:
raw_counts: Final[list[CacheCounterValue]] = await self.parallel_count_script(
keys=gauge_keys,
args=[PARALLEL_REQUEST_SLOT_TTL_SECONDS for _ in gauges],
args=tuple(PARALLEL_REQUEST_SLOT_TTL_SECONDS for _ in gauges),
)
counts = [max(0, int(value)) for value in raw_counts]
except Exception as e: # noqa: BLE001 - any Redis/Lua failure degrades to the local mirror, never a 500
@ -1540,6 +1585,135 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async with self._check_and_increment_lock:
return await self._acquire_parallel_slots_in_memory(gauges, slot_id, parent_otel_span)
async def _check_cluster_parallel_gauges(
self,
gauges: Sequence[ParallelRequestGauge],
slot_id: str,
parent_otel_span: Span | None,
read_only: bool,
) -> RateLimitResponse:
by_key: Final = MappingProxyType(
{
gauge["counter_key"]: min(
(candidate for candidate in gauges if candidate["counter_key"] == gauge["counter_key"]),
key=lambda candidate: candidate["limit"],
)
for gauge in gauges
}
)
groups: Final = self._group_keys_by_hash_tag(tuple(by_key))
counts: Final[dict[str, int]] = {} # mutable-ok: gather independent Redis-slot results
attempted: Final[list[str]] = [] # mutable-ok: rollback includes requests whose responses were lost
try:
for keys in groups.values():
if read_only:
if self.parallel_count_script is None:
raise RuntimeError("Redis cluster parallel count script is unavailable")
counts.update(
(key, max(0, int(count)))
for key, count in zip(
keys,
await self.parallel_count_script(
keys=keys, args=tuple(PARALLEL_REQUEST_SLOT_TTL_SECONDS for _ in keys)
),
strict=True,
)
)
continue
if self.parallel_acquire_script is None:
raise RuntimeError("Redis cluster parallel acquire script is unavailable")
attempted.extend(keys)
(raw,) = (
await self.parallel_acquire_script(
keys=keys,
args=tuple(
arg
for key in keys
for arg in (by_key[key]["limit"], PARALLEL_REQUEST_SLOT_TTL_SECONDS, slot_id)
),
),
)
if int(raw[0]) == 1:
await self._rollback_cluster_parallel_slots(tuple(attempted), slot_id, parent_otel_span)
return RateLimitResponse(
overall_code="OVER_LIMIT",
statuses=[ # mutable-ok: response contract requires a list
self._gauge_status(by_key[keys[int(raw[1]) - 1]], int(raw[2]), "OVER_LIMIT")
],
)
counts.update((key, int(count)) for key, count in zip(keys, raw[1:], strict=True))
for key in keys:
await self.internal_usage_cache.async_set_cache(
key=key,
value=counts[key],
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
litellm_parent_otel_span=parent_otel_span,
local_only=True,
)
except BaseException:
if attempted:
await self._rollback_cluster_parallel_slots(tuple(attempted), slot_id, parent_otel_span)
raise
statuses: Final = tuple(
self._gauge_status(
gauge,
counts[gauge["counter_key"]],
"OVER_LIMIT" if read_only and counts[gauge["counter_key"]] >= gauge["limit"] else "OK",
)
for gauge in gauges
)
return RateLimitResponse(
overall_code="OVER_LIMIT" if any(item["code"] == "OVER_LIMIT" for item in statuses) else "OK",
statuses=list(statuses), # mutable-ok: RateLimitResponse contract requires a list
)
async def _rollback_cluster_parallel_slots(
self, counter_keys: tuple[str, ...], slot_id: str, parent_otel_span: Span | None
) -> None:
rollback: Final = asyncio.create_task(
self._release_cluster_parallel_slots(counter_keys, slot_id, parent_otel_span)
)
cancelled = False # rebind-ok: defer repeated caller cancellation until compensation finishes
while not rollback.done():
try:
await asyncio.shield(rollback)
except asyncio.CancelledError:
cancelled = True
except Exception: # noqa: BLE001 # retrieve and report the completed task's exception below
break
try:
rollback.result()
except Exception: # noqa: BLE001 # preserve admission failure; unreachable Redis slots expire by TTL
verbose_proxy_logger.error("Could not roll back all Redis cluster parallel request slots")
if cancelled:
raise asyncio.CancelledError
async def _release_cluster_parallel_slots(
self, counter_keys: tuple[str, ...], slot_id: str, parent_otel_span: Span | None
) -> None:
first_error: Exception | None = None # rebind-ok: finish every shard before reporting the first failure
for keys in self._group_keys_by_hash_tag(counter_keys).values():
try:
if self.parallel_release_script is None:
raise RuntimeError("Redis cluster parallel release script is unavailable")
for key, count in zip(
keys,
await self.parallel_release_script(keys=keys, args=tuple(slot_id for _ in keys)),
strict=True,
):
await self.internal_usage_cache.async_set_cache(
key=key,
value=max(0, int(count)),
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
litellm_parent_otel_span=parent_otel_span,
local_only=True,
)
except Exception as exc: # noqa: BLE001 # one unreachable shard must not strand the other shards
if first_error is None:
first_error = exc
if first_error is not None:
raise first_error
async def _read_local_gauge_counts(
self,
gauge_keys: list[str],
@ -1609,6 +1783,68 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
statuses.append(self._gauge_status(gauge, in_flight + 1, "OK"))
return RateLimitResponse(overall_code="OK", statuses=statuses)
def transfer_realtime_call_slot(self, request_data: Mapping[str, object]) -> RealtimeCallLease | None:
call_id: Final = request_data.get("litellm_call_id")
if not isinstance(call_id, str):
return None
stash: Final = get_request_stash_for_call(call_id)
if stash is None or stash.parallel_slot is None:
return None
slot_id: Final = stash.parallel_slot["slot_id"]
counter_keys: Final = tuple(stash.parallel_slot["counter_keys"])
stash.parallel_slot = None
async def renew() -> bool:
return await self._renew_realtime_call_slot(slot_id, counter_keys)
async def release() -> None:
await self._release_parallel_request_slots(
ParallelSlotAcquisition(slot_id=slot_id, counter_keys=counter_keys)
)
return RealtimeCallLease(renew=renew, release=release)
async def _renew_realtime_call_slot(self, slot_id: str, counter_keys: tuple[str, ...]) -> bool:
if self.parallel_renew_script is not None:
try:
for keys in self._group_keys_by_hash_tag(counter_keys).values():
if tuple(
await self.parallel_renew_script(keys=keys, args=(slot_id, PARALLEL_REQUEST_SLOT_TTL_SECONDS))
) != (1,):
return False
return True
except Exception: # noqa: BLE001 # Redis ownership cannot be established by a local count mirror
return False
async with self._check_and_increment_lock:
now: Final = self._get_current_time().timestamp()
cutoff: Final = now - PARALLEL_REQUEST_SLOT_TTL_SECONDS
values: Final[tuple[ParallelGaugeCacheValue | None, ...]] = tuple(
[
await self.internal_usage_cache.async_get_cache(
key=counter_key, local_only=True, litellm_parent_otel_span=None
)
for counter_key in counter_keys
]
)
if any(
not isinstance(value, dict)
or not isinstance(score := value.get(slot_id), (int, float))
or score <= cutoff
for value in values
):
return False
for counter_key, value in zip(counter_keys, values):
if not isinstance(value, dict):
return False
await self.internal_usage_cache.async_set_cache(
key=counter_key,
value={**value, slot_id: now}, # mutable-ok: slot registry readers require a concrete dict
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
local_only=True,
litellm_parent_otel_span=None,
)
return True
async def _release_parallel_request_slots(
self,
acquisition: ParallelSlotAcquisition,
@ -1626,11 +1862,14 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
slot_id: Final = acquisition["slot_id"]
if not counter_keys or not slot_id:
return
if self._is_redis_cluster() and self.parallel_release_script is not None:
await self._release_cluster_parallel_slots(tuple(counter_keys), slot_id, parent_otel_span)
return
if self.parallel_release_script is not None:
try:
raw: Final[list[CacheCounterValue]] = await self.parallel_release_script(
keys=counter_keys,
args=[slot_id for _ in counter_keys],
args=tuple(slot_id for _ in counter_keys),
)
for counter_key, remaining in zip(counter_keys, raw):
await self.internal_usage_cache.async_set_cache(
@ -3497,6 +3736,12 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# Org Level Rate Limits
descriptors.extend(self.create_organization_rate_limit_descriptor(user_api_key_dict, requested_model))
effective_descriptors: Final = (
tuple(_without_parallel_limit(descriptor) for descriptor in descriptors)
if call_type == "_arealtime" and is_realtime_call_attachment(data.get("websocket"))
else descriptors
)
# Only check rate limits if we have descriptors with actual limits
if descriptors:
# First pass: RPM and max_parallel_requests sliding-window check.
@ -3515,16 +3760,18 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# double-charge every request.
parallel_counter_keys: Final = [
self.create_rate_limit_keys(d["key"], d["value"], "max_parallel_requests")
for d in descriptors
for d in effective_descriptors
if (d.get("rate_limit") or {}).get("max_parallel_requests") is not None
]
parallel_slot_id: Final = uuid.uuid4().hex if parallel_counter_keys else None
first_pass_descriptors: Final = (
descriptors
effective_descriptors
if self.tpm_reservation_enabled
else tuple(
d for d in descriptors if d["key"] not in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
d
for d in effective_descriptors
if d["key"] not in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
)
)
response: Final = await self.should_rate_limit(
@ -4700,6 +4947,15 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
)
stash.parallel_slot = None
async def async_release_realtime_attachment(
self, request_data: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth
) -> None:
await self.async_post_call_failure_hook(
request_data={}, # mutable-ok: existing failure hook requires dict; attachment has no billable usage
original_exception=Exception("Realtime attachment completed"),
user_api_key_dict=user_api_key_dict,
)
async def async_post_call_success_hook(self, data: dict, user_api_key_dict: UserAPIKeyAuth, response):
"""
Post-call hook to update rate limit headers in the response.

View file

@ -0,0 +1,74 @@
import asyncio
from collections.abc import Awaitable, Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Final
_realtime_call_attachment: Final[ContextVar[object | None]] = ContextVar("realtime_call_attachment", default=None)
@contextmanager
def realtime_call_attachment(websocket: object) -> Generator[None]:
token: Final = _realtime_call_attachment.set(websocket)
try:
yield
finally:
_realtime_call_attachment.reset(token)
def is_realtime_call_attachment(websocket: object) -> bool:
bound: Final = _realtime_call_attachment.get()
return bound is not None and bound is websocket
class RealtimeCallLease:
def __init__(
self,
*,
renew: Callable[[], Awaitable[bool]],
release: Callable[[], Awaitable[None]],
interval: float = 300,
renewal_timeout: float = 10,
) -> None:
self._renew = renew
self._release = release
self._interval = interval
self._renewal_timeout = renewal_timeout
self._failed = asyncio.Event()
self._heartbeat: asyncio.Task[None] | None = None
self._closing: asyncio.Task[None] | None = None
def start(self) -> None:
if self._heartbeat is None and self._closing is None:
self._heartbeat = asyncio.create_task(self._run())
async def renew(self) -> bool:
if self._closing is not None or self._failed.is_set():
return False
try:
renewed: Final = await asyncio.wait_for(self._renew(), timeout=self._renewal_timeout)
except Exception: # noqa: BLE001 # fail closed without exposing cache credentials
self._failed.set()
return False
if not renewed:
self._failed.set()
return renewed and not self._failed.is_set() and self._closing is None
async def wait_failed(self) -> None:
await self._failed.wait()
async def _run(self) -> None:
while await self.renew():
await asyncio.sleep(self._interval)
self._failed.set()
async def close(self) -> None:
if self._closing is None:
self._closing = asyncio.create_task(self._close())
await asyncio.shield(self._closing)
async def _close(self) -> None:
if self._heartbeat is not None:
self._heartbeat.cancel()
await asyncio.gather(self._heartbeat, return_exceptions=True)
await self._release()

View file

@ -1399,6 +1399,10 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception as e:
verbose_proxy_logger.error("Error stopping DB health watchdog task: %s", e)
from litellm.proxy.realtime_endpoints.call_supervision import CALL_SUPERVISORS
await CALL_SUPERVISORS.shutdown()
await _drain_spend_event_producer_on_shutdown()
await flush_spend_counters_on_shutdown()
@ -11917,6 +11921,19 @@ async def _reject_realtime_session(
await _release_realtime_budget_reservation(user_api_key_dict)
@app.websocket("/v1/live/{call_id}")
async def codex_live_sideband_endpoint(
websocket: WebSocket,
call_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth_websocket),
) -> None:
from litellm.proxy.realtime_endpoints.call_sessions import codex_realtime_sideband
await codex_realtime_sideband(websocket, call_id, user_api_key_dict)
@app.websocket("/v1/live")
@app.websocket("/live")
@app.websocket("/openai/v1/realtime")
@app.websocket("/v1/realtime")
@app.websocket("/realtime")
@ -11924,12 +11941,18 @@ async def realtime_websocket_endpoint(
websocket: WebSocket,
model: str | None = fastapi.Query(None, description="The model to use for the websocket connection."),
intent: str | None = fastapi.Query(None, description="The intent of the websocket connection."),
call_id: str | None = None,
guardrails: str | None = fastapi.Query(
None,
description="Comma-separated list of guardrail names to apply to this request.",
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth_websocket),
):
if call_id is not None:
from litellm.proxy.realtime_endpoints.call_sessions import codex_realtime_sideband
await codex_realtime_sideband(websocket, call_id, user_api_key_dict)
return
requested_protocols: Final = [
p.strip() for p in (websocket.headers.get("sec-websocket-protocol") or "").split(",") if p.strip()
]
@ -11960,7 +11983,10 @@ async def realtime_websocket_endpoint(
await websocket.accept(**accept_kwargs)
# Only use explicit parameters, not all query params
query_params: Final = cast(RealtimeQueryParams, dict(_realtime_query_params_template(model, intent)))
query_params: Final = cast(
RealtimeQueryParams,
dict(_realtime_query_params_template(model, intent) + ((("call_id", call_id),) if call_id is not None else ())),
)
data: dict[str, object] = {
"model": route_model,

View file

@ -0,0 +1,542 @@
import asyncio
import base64
import hashlib
import json
import time
from collections.abc import Awaitable, Callable, Mapping
from contextlib import AsyncExitStack, nullcontext
from contextvars import Token
from types import MappingProxyType
from typing import Final, Literal
import httpx
from fastapi import HTTPException, Request, Response, WebSocket
from pydantic import TypeAdapter
from starlette.formparsers import MultiPartException, MultiPartParser
from starlette.types import Message, Scope
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.realtime_streaming import (
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
RealTimeStreaming,
realtime_attachment_cleanup,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.chatgpt.codex import (
CodexRealtimeCall,
CodexRealtimeOffer,
build_call_request,
build_sideband_request,
parse_call_response,
)
from litellm.llms.chatgpt.realtime import (
CallAccounting,
ChatGPTRealtime,
configured_realtime_headers,
realtime_endpoint,
)
from litellm.proxy._types import InternalRequestOrigin, ProxyException, UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import can_key_call_resolved_model
from litellm.proxy.auth.user_api_key_auth import (
get_api_key,
get_api_key_from_custom_header,
get_websocket_api_key,
user_api_key_auth,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper, encrypt_value_helper
from litellm.proxy.common_utils.http_parsing_utils import (
_normalize_media_type, # pyright: ignore[reportPrivateUsage] # reuse the shared HTTP media-type normalization contract
)
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler, # pyright: ignore[reportPrivateUsage] # existing built-in limiter has no public alias
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_PROXY_MaxParallelRequestsHandler_v3, # pyright: ignore[reportPrivateUsage] # existing built-in limiter has no public alias
isolated_request_stash,
)
from litellm.proxy.hooks.realtime_call_lease import RealtimeCallLease, realtime_call_attachment
from litellm.proxy.spend_tracking.budget_reservation import (
invalidate_budget_reservation_counters,
release_or_invalidate_budget_reservation,
)
from litellm.types.realtime import RealtimeQueryParams
from litellm.types.router import GenericLiteLLMParams
async def supervise_codex_call(
request: Request, call: CodexRealtimeCall, auth: UserAPIKeyAuth, lease: RealtimeCallLease | None = None
) -> None:
with isolated_request_stash():
await _start_codex_supervisor(request, call, auth, lease)
async def _start_codex_supervisor(
request: Request, call: CodexRealtimeCall, auth: UserAPIKeyAuth, lease: RealtimeCallLease | None
) -> None:
import litellm
from litellm.proxy.realtime_endpoints.call_supervision import CALL_SUPERVISORS, CallSupervisor
async def receive() -> Message:
body: Final[RealtimeQueryParams] = {"model": call.alias}
message: Final[Message] = {
"type": "http.request",
"body": json.dumps(body).encode(),
"more_body": False,
}
return message
async def send(_message: Message) -> None:
return None
supervision_owned = False # rebind-ok: supervisor owns cleanup after construction
effective_handler: ChatGPTRealtime | None = None # rebind-ok: reuse hook-enriched credentials for cleanup
sockets: Final = AsyncExitStack()
try:
observer_scope: Final[Scope] = {**request.scope}
observer_request: Final = Request(observer_scope, receive=receive)
processed, logger = await process_codex_request(
observer_request,
{ # mutable-ok: common request processing enriches metadata
**build_sideband_request(call),
"model": call.alias,
},
auth,
call.alias,
"_arealtime",
internal_realtime_observer=True,
)
pinned: Final = { # mutable-ok: logging and provider parameter contract
**processed,
**build_sideband_request(call),
"extra_headers": MappingProxyType(
{
**configured_realtime_headers(
TypeAdapter(Mapping[str, object] | None).validate_python(processed.get("extra_headers"))
),
**configured_realtime_headers(call.extra_headers),
}
),
"litellm_metadata": { # mutable-ok: Logging.update_from_kwargs requires a dict to retain ownership metadata
**TypeAdapter(Mapping[str, object]).validate_python(
processed.get("litellm_metadata") or MappingProxyType({})
),
**(
MappingProxyType(
{
"model_info": { # mutable-ok: logging and cost callbacks require a concrete model-info dict
**litellm.get_model_info(model=call.model_id),
"id": call.model_id,
}
}
)
if call.model_id is not None
else MappingProxyType({})
),
},
}
logger.update_from_kwargs(
kwargs=pinned,
model=call.model,
user=None,
optional_params={}, # mutable-ok: logging contract
litellm_params={ # mutable-ok: Logging.update_from_kwargs pops metadata from its argument
**logger.litellm_params,
"litellm_metadata": pinned["litellm_metadata"],
"arealtime": True,
},
custom_llm_provider="chatgpt",
)
params: Final = GenericLiteLLMParams.model_validate(pinned)
handler: Final = ChatGPTRealtime(
params, request.headers, TypeAdapter(Mapping[str, object]).validate_python(pinned["extra_headers"])
)
effective_handler = handler
api_base: Final = ChatGPTRealtime.get_api_base(call.api_base)
connection: Final = await handler.open_call_connection(call.model, api_base)
sockets.push_async_callback(connection.close)
async def close_call() -> None:
await handler.close_call(connection, call.model, api_base)
async def force_close_call() -> None:
await handler.hangup_call(api_base)
frontend_scope: Final[Scope] = {**request.scope, "type": "websocket"}
frontend: Final = WebSocket(frontend_scope, receive=receive, send=send)
stream: Final = RealTimeStreaming(frontend, connection, logger, model=call.model, user_api_key_dict=auth)
supervisor: Final = CallSupervisor(
connection,
stream,
logger,
auth,
close_call,
force_close_call=force_close_call,
terminal_usage_required=realtime_endpoint(call.model) == "live",
lease=lease,
)
supervision_owned = True
sockets.pop_all()
await CALL_SUPERVISORS.start(supervisor)
except BaseException:
if not supervision_owned:
try:
fallback_handler: Final = effective_handler or ChatGPTRealtime(
GenericLiteLLMParams.model_validate(build_sideband_request(call)),
request.headers,
call.extra_headers,
)
await fallback_handler.hangup_call(ChatGPTRealtime.get_api_base(call.api_base))
except Exception: # noqa: BLE001 # preserve original failure without logging provider credentials
verbose_proxy_logger.error("Realtime startup cleanup could not confirm upstream termination")
try:
await invalidate_budget_reservation_counters(budget_reservation=auth.budget_reservation)
except Exception: # noqa: BLE001 # cleanup errors must not replace the original startup failure
verbose_proxy_logger.error("Realtime startup cleanup could not invalidate budget counters")
else:
await release_or_invalidate_budget_reservation(budget_reservation=auth.budget_reservation)
finally:
try:
await sockets.aclose()
except Exception: # noqa: BLE001 # socket cleanup must preserve the original startup failure
verbose_proxy_logger.error("Realtime startup cleanup could not close observer socket")
raise
def encode_call(call: CodexRealtimeCall) -> str:
encrypted: Final = encrypt_value_helper(call.model_dump_json())
return "rtc_litellm_" + base64.urlsafe_b64encode(encrypted.encode()).decode().rstrip("=")
def decode_call(token: str, authorization: str) -> CodexRealtimeCall:
try:
if not token.startswith("rtc_litellm_"):
raise ValueError("Invalid call prefix")
encoded: Final = token.removeprefix("rtc_litellm_")
encrypted: Final = base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
plaintext: Final = decrypt_value_helper(encrypted.decode(), key="codex_realtime_call")
call: Final = CodexRealtimeCall.model_validate_json(plaintext or "")
except (ValueError, TypeError, UnicodeError) as exc:
raise HTTPException(403, "Invalid realtime call") from exc
if call.expires_at < time.time() or call.owner != hashlib.sha256(authorization.encode()).hexdigest():
raise HTTPException(403, "Invalid or expired realtime call")
return call
MAX_REALTIME_OFFER_BYTES: Final = 8 * 1024 * 1024
async def _cache_bounded_offer_body(request: Request) -> None:
try:
if int(request.headers.get("content-length", "")) > MAX_REALTIME_OFFER_BYTES:
raise HTTPException(413, "Realtime offer exceeds the 8 MiB limit")
except ValueError:
pass
if hasattr(request, "_body"):
if len(request._body) > MAX_REALTIME_OFFER_BYTES: # pyright: ignore[reportPrivateUsage] # validate Starlette's cached body without consuming it again
raise HTTPException(413, "Realtime offer exceeds the 8 MiB limit")
return
if request._form is not None and request._stream_consumed: # pyright: ignore[reportPrivateUsage] # a mixed-case empty form cache may leave the stream unread
return
body: Final = bytearray()
async for chunk in request.stream():
if len(body) + len(chunk) > MAX_REALTIME_OFFER_BYTES:
raise HTTPException(413, "Realtime offer exceeds the 8 MiB limit")
body.extend(chunk)
request._body = bytes(body) # pyright: ignore[reportPrivateUsage] # Starlette has no public setter for its shared body cache
async def read_codex_offer(request: Request) -> CodexRealtimeOffer:
await _cache_bounded_offer_body(request)
content_type: Final = request.headers.get("content-type", "")
if _normalize_media_type(content_type) == "multipart/form-data":
if content_type.split(";", 1)[0] != "multipart/form-data" and not await request.form():
try:
request._form = await MultiPartParser(request.headers, request.stream()).parse() # pyright: ignore[reportPrivateUsage] # Starlette exposes no setter for its shared form cache; # rebind-ok: Request.close must own and close uploaded files
request.scope.pop("parsed_body", None)
except MultiPartException as exc:
raise HTTPException(400, "Invalid realtime multipart offer") from exc
form: Final = await request.form()
return CodexRealtimeOffer.model_validate(
MappingProxyType({"sdp": form.get("sdp"), "session": json.loads(str(form.get("session", "{}")))})
)
return CodexRealtimeOffer.model_validate(await request.json())
async def process_codex_request(
request: Request,
data: dict[str, object], # mutable-ok: common request processor enriches this dictionary
auth: UserAPIKeyAuth,
model: str,
route_type: Literal["arealtime_calls", "_arealtime"],
*,
internal_realtime_observer: bool = False,
) -> tuple[dict[str, object], Logging]: # mutable-ok: common request processor returns enriched routing arguments
from litellm.proxy import proxy_server as server
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
processor: Final = ProxyBaseLLMRequestProcessing(data=data)
processed, logging_obj = await processor.common_processing_pre_call_logic(
request=request,
general_settings=server.general_settings,
user_api_key_dict=auth,
version=server.version,
proxy_logging_obj=server.proxy_logging_obj,
proxy_config=server.proxy_config,
llm_router=server.llm_router,
user_model=server.user_model,
user_temperature=server.user_temperature,
user_request_timeout=server.user_request_timeout,
user_max_tokens=server.user_max_tokens,
user_api_base=server.user_api_base,
model=model,
route_type=route_type,
**(
MappingProxyType({"internal_realtime_observer": True})
if internal_realtime_observer
else MappingProxyType({})
),
)
if internal_realtime_observer:
logging_obj.model_call_details["internal_request_origin"] = InternalRequestOrigin.REALTIME_OBSERVER
return processed, logging_obj
async def create_codex_realtime_call(request: Request) -> Response:
try:
with isolated_request_stash():
return await _create_codex_realtime_call(request)
finally:
await request.close()
async def _create_codex_realtime_call(request: Request) -> Response:
from litellm.proxy import proxy_server as server
try:
offer: Final = await read_codex_offer(request)
except ValueError as exc:
raise HTTPException(400, "Invalid realtime offer: expected sdp and session") from exc
model: Final = offer.session.model
if not model:
raise HTTPException(400, "session.model is required")
auth: Final = await user_api_key_auth(
request=request,
api_key=request.headers.get("authorization", ""),
azure_api_key_header=request.headers.get("api-key", ""),
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
custom_litellm_key_header=request.headers.get("x-litellm-api-key"),
)
selected_key, _ = get_api_key(
request=request,
api_key=request.headers.get("authorization", ""),
azure_api_key_header=request.headers.get("api-key", ""),
custom_litellm_key_header=request.headers.get("x-litellm-api-key"),
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
pass_through_endpoints=None,
route="/v1/realtime/calls",
)
custom_header: Final = server.general_settings.get("litellm_key_header_name")
owner_key: Final = (
get_api_key_from_custom_header(request, custom_header) if isinstance(custom_header, str) else selected_key
)
supervision_started = False # rebind-ok: transfer reservation ownership only after supervision is established
call_lease: RealtimeCallLease | None = None
lease_transferred = False # rebind-ok: failed startup leaves the signaling task responsible for its lease
preprocessing_started = False # rebind-ok: only refund reservations belonging to this signaling request
limiter: Final = server.proxy_logging_obj.get_proxy_hook("parallel_request_limiter")
try:
await can_key_call_resolved_model(
model=model,
llm_model_list=server.llm_model_list,
valid_token=auth,
llm_router=server.llm_router,
)
data: Final = build_call_request(offer, request.query_params, request.headers)
signaling_auth: Final = auth.model_copy(update=MappingProxyType({"budget_reservation": None}))
if isinstance(limiter, _PROXY_MaxParallelRequestsHandler) and (
auth.max_parallel_requests is not None
or server.general_settings.get("global_max_parallel_requests") is not None
):
raise HTTPException(400, "Realtime calls with parallel limits require the V3 rate limiter")
preprocessing_started = True
processed, _ = await process_codex_request(request, data, signaling_auth, model, "arealtime_calls")
if isinstance(limiter, _PROXY_MaxParallelRequestsHandler_v3):
call_lease = limiter.transfer_realtime_call_slot(processed)
if call_lease is not None:
call_lease.start()
if not await call_lease.renew():
raise HTTPException(503, "Realtime call quota reservation was lost")
with isolated_request_stash():
result: Final = await server.route_request(
data=processed,
route_type="arealtime_calls",
llm_router=server.llm_router,
user_model=server.user_model,
)
try:
response: Final = await result
except BaseLLMException as exc:
raise HTTPException(exc.status_code, str(exc)) from exc
if not isinstance(response, httpx.Response):
raise HTTPException(502, "Invalid realtime signaling response")
if response.is_error:
return Response(response.content, status_code=response.status_code, media_type="application/json")
try:
call: Final = parse_call_response(
response,
alias=model,
owner=hashlib.sha256(f"Bearer {owner_key}".encode()).hexdigest(),
expires_at=time.time() + 3600,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
supervised_call: Final = call.model_copy(
update=MappingProxyType({"usage_supervised": True, "parallel_reserved": call_lease is not None})
)
token: Final = encode_call(supervised_call)
supervision_started = True
if call_lease is None:
await supervise_codex_call(request, supervised_call, auth)
else:
await supervise_codex_call(request, supervised_call, auth, call_lease)
lease_transferred = True
return Response(
response.content,
status_code=response.status_code,
media_type="application/sdp",
headers=MappingProxyType({"Location": f"/v1/realtime/calls/{token}"}),
)
finally:
try:
if call_lease is not None and not lease_transferred:
await call_lease.close()
finally:
try:
if preprocessing_started and isinstance(limiter, _PROXY_MaxParallelRequestsHandler_v3):
await asyncio.shield(
limiter.async_post_call_failure_hook(
request_data={}, # mutable-ok: existing failure-hook contract
original_exception=Exception("Realtime signaling completed without token usage"),
user_api_key_dict=auth,
)
)
finally:
if not supervision_started:
await release_or_invalidate_budget_reservation(budget_reservation=auth.budget_reservation)
async def codex_realtime_sideband(websocket: WebSocket, token: str, auth: UserAPIKeyAuth) -> None:
import litellm
from litellm.proxy import proxy_server as server
protocols: Final = tuple(
p.strip() for p in websocket.headers.get("sec-websocket-protocol", "").split(",") if p.strip()
)
logging_obj: Logging | None = None # rebind-ok: cleanup needs the logger only after pre-call succeeds
attachment_limiter: _PROXY_MaxParallelRequestsHandler | _PROXY_MaxParallelRequestsHandler_v3 | None = None
cleanup_token: Token[Callable[[], Awaitable[None]] | None] | None = None
try:
try:
api_key: Final = get_websocket_api_key(websocket)
if not api_key:
raise HTTPException(403, "No API key provided")
call: Final = decode_call(token, f"Bearer {api_key}")
await can_key_call_resolved_model(
model=call.alias,
llm_model_list=server.llm_model_list,
valid_token=auth,
llm_router=server.llm_router,
)
except (HTTPException, ProxyException):
await websocket.close(code=1008, reason="Invalid realtime call")
return
async def receive() -> Message:
return { # mutable-ok: ASGI receive message
"type": "http.request",
"body": json.dumps({"model": call.alias}).encode(), # mutable-ok: JSON request serialization
"more_body": False,
}
request: Final = Request(
{ # mutable-ok: Starlette stores request state in the ASGI scope
**websocket.scope,
"type": "http",
"method": "POST",
"path": websocket.scope.get("path", "/v1/realtime"),
},
receive=receive,
)
data: Final = { # mutable-ok: common request processor enriches routing arguments
**build_sideband_request(call),
"model": call.alias,
"websocket": websocket,
"guardrails": [ # mutable-ok: guardrail processing expects a list
name.strip() for name in websocket.query_params.get("guardrails", "").split(",") if name.strip()
],
}
limiter: Final = server.proxy_logging_obj.get_proxy_hook("parallel_request_limiter")
if call.usage_supervised and isinstance(
limiter, (_PROXY_MaxParallelRequestsHandler, _PROXY_MaxParallelRequestsHandler_v3)
):
attachment_limiter = limiter
if isinstance(limiter, _PROXY_MaxParallelRequestsHandler):
limiter.begin_realtime_attachment(data)
try:
with realtime_call_attachment(websocket) if call.parallel_reserved else nullcontext():
processed, logging_obj = await process_codex_request(request, data, auth, call.alias, "_arealtime")
except Exception: # noqa: BLE001 # custom hook exceptions must reject the connection
verbose_proxy_logger.exception("Realtime sideband pre-call rejected")
await websocket.close(code=1008, reason="Realtime pre-call rejected")
return
await websocket.accept(
subprotocol=next((p for p in protocols if not p.startswith("openai-insecure-api-key.")), None)
)
if attachment_limiter is not None:
selected_limiter: Final = attachment_limiter
async def release_attachment() -> None:
await selected_limiter.async_release_realtime_attachment(data, auth)
cleanup_token = realtime_attachment_cleanup.set(release_attachment)
await litellm._arealtime( # pyright: ignore[reportPrivateUsage] # dispatch for an already authorized call
model=f"chatgpt/{call.model}",
websocket=websocket,
**MappingProxyType(
{
key: value
for key, value in { # mutable-ok: retain processed metadata with pinned routing
**processed,
**build_sideband_request(call),
"extra_headers": MappingProxyType(
{
**configured_realtime_headers(
TypeAdapter(Mapping[str, object] | None).validate_python(
processed.get("extra_headers")
)
),
**configured_realtime_headers(call.extra_headers),
}
),
"websocket": websocket,
"user_api_key_dict": auth,
"chatgpt_call_accounting": CallAccounting.SUPERVISED if call.usage_supervised else None,
}.items()
if key not in ("model", "websocket")
}
),
)
finally:
try:
if attachment_limiter is not None:
await attachment_limiter.async_release_realtime_attachment(data, auth)
finally:
if cleanup_token is not None:
realtime_attachment_cleanup.reset(cleanup_token)
if logging_obj is None or not logging_obj.model_call_details.get(REALTIME_SESSION_SUCCESS_LOGGED_KEY):
await release_or_invalidate_budget_reservation(budget_reservation=auth.budget_reservation)

View file

@ -0,0 +1,270 @@
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import suppress
from typing import Final, Protocol
from pydantic import BaseModel, Field, ValidationError
from websockets.exceptions import ConnectionClosedOK
from litellm._logging import verbose_proxy_logger
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.realtime_streaming import REALTIME_SESSION_SUCCESS_LOGGED_KEY
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.realtime_call_lease import RealtimeCallLease
from litellm.proxy.spend_tracking.budget_reservation import (
invalidate_budget_reservation_counters,
release_or_invalidate_budget_reservation,
)
class ObserverSocket(Protocol):
def __aiter__(self) -> AsyncIterator[str | bytes]: ...
async def close(self) -> None: ...
class UsageSink(Protocol):
def store_message(self, message: str) -> None: ...
async def log_messages(self, *, wait_for_dispatch: bool = False) -> None: ...
class _ObserverEvent(BaseModel):
type: str
class _LiveDurationUsage(BaseModel):
audio_duration_ms: float = Field(strict=True, ge=0, allow_inf_nan=False)
class _LiveTerminalEvent(BaseModel):
usage: _LiveDurationUsage
class CallSupervisor:
def __init__(
self,
upstream: ObserverSocket,
stream: UsageSink,
logging_obj: Logging,
auth: UserAPIKeyAuth,
close_call: Callable[[], Awaitable[None]],
*,
ready_timeout: float = 20,
lifetime: float = 3600,
drain_timeout: float = 5,
termination_timeout: float = 60,
logging_timeout: float = LOGGING_WORKER_MAX_TIME_PER_COROUTINE,
terminal_usage_required: bool = True,
force_close_call: Callable[[], Awaitable[None]] | None = None,
lease: RealtimeCallLease | None = None,
) -> None:
self._upstream = upstream
self._stream = stream
self._logging = logging_obj
self._auth = auth
self._close_call = close_call
self._force_close_call = force_close_call
self._lease = lease
self._ready_timeout = ready_timeout
self._lifetime = lifetime
self._drain_timeout = drain_timeout
self._termination_timeout = termination_timeout
self._logging_timeout = logging_timeout
self._terminal_usage_required = terminal_usage_required
self._ready = asyncio.Event()
self._stop = asyncio.Event()
self._started = False
self._terminal = False
self._terminal_usage_valid = False
self._close_confirmed = False
self._accounting_complete = False
self._task: asyncio.Task[None] | None = None
async def start(self) -> None:
if self._task is not None:
raise RuntimeError("Call observer already started")
self._task = asyncio.create_task(self._run())
try:
await asyncio.wait_for(self._ready.wait(), timeout=self._ready_timeout)
if self._lease is not None and not await self._lease.renew():
raise RuntimeError("Call observer lost its quota reservation during startup")
if not self._started or self._terminal or self._task.done():
raise RuntimeError("Call observer ended before session became available")
except BaseException:
await self._close_after_failed_start()
raise
async def _close_after_failed_start(self) -> None:
cleanup: Final = asyncio.create_task(self.close())
while not cleanup.done():
with suppress(asyncio.CancelledError):
await asyncio.shield(cleanup)
cleanup.result()
async def close(self) -> None:
self._stop.set()
await self.wait()
async def wait(self) -> None:
if self._task is not None:
await asyncio.shield(self._task)
async def _read(self) -> None:
try:
await self._read_events()
except ConnectionClosedOK:
return
async def _read_events(self) -> None:
event: _ObserverEvent
async for message in self._upstream:
self._stream.store_message(message.decode("utf-8") if isinstance(message, bytes) else message)
event = _ObserverEvent.model_validate_json(message)
if event.type in ("session.started", "session.created"):
self._started = True
self._ready.set()
if event.type == "session.closed":
self._terminal = True
try:
_LiveTerminalEvent.model_validate_json(message)
except ValidationError:
self._terminal_usage_valid = False
else:
self._terminal_usage_valid = True
return
def _usage_complete(self) -> bool:
if self._terminal_usage_required:
return self._terminal and self._terminal_usage_valid
return self._terminal or self._close_confirmed
async def _run(self) -> None:
try:
await self._observe()
finally:
try:
if self._lease is not None:
await self._lease.close()
finally:
self._ready.set()
async def _observe(self) -> None:
reader: Final = asyncio.create_task(self._read())
stopped: Final = asyncio.create_task(self._stop.wait())
lease_failed: Final = asyncio.create_task(self._lease.wait_failed()) if self._lease is not None else None
try:
await asyncio.wait(
(reader, stopped, lease_failed) if lease_failed is not None else (reader, stopped),
timeout=self._lifetime,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
try:
if not self._terminal:
deadline: Final = asyncio.get_running_loop().time() + self._termination_timeout
primary_deadline: Final = (
deadline - self._termination_timeout / 2
if self._terminal_usage_required and self._force_close_call is not None
else deadline
)
try:
await asyncio.wait_for(
self._close_call(), timeout=max(0.0, primary_deadline - asyncio.get_running_loop().time())
)
self._close_confirmed = True
except Exception: # noqa: BLE001 # provider exceptions can contain credentials
verbose_proxy_logger.error("Realtime observer could not terminate upstream call")
await self._drain(reader, timeout=max(0.0, primary_deadline - asyncio.get_running_loop().time()))
if self._terminal_usage_required and not self._terminal and self._force_close_call is not None:
remaining: Final = max(0.0, deadline - asyncio.get_running_loop().time())
try:
await asyncio.wait_for(self._force_close_call(), timeout=remaining)
self._close_confirmed = True
except Exception: # noqa: BLE001 # provider exceptions can contain credentials
verbose_proxy_logger.error("Realtime observer independent hangup failed")
await self._drain(reader, timeout=max(0.0, deadline - asyncio.get_running_loop().time()))
finally:
stopped.cancel()
reader.cancel()
if lease_failed is not None:
lease_failed.cancel()
await asyncio.gather(
*((reader, stopped, lease_failed) if lease_failed is not None else (reader, stopped)),
return_exceptions=True,
)
with suppress(Exception):
await self._upstream.close()
if not self._usage_complete():
self._logging.model_call_details["realtime_usage_incomplete"] = True
verbose_proxy_logger.error(
"Realtime observer ended without terminal usage; recorded usage is partial"
)
try:
try:
await asyncio.wait_for(
self._stream.log_messages(wait_for_dispatch=True), timeout=self._logging_timeout
)
self._accounting_complete = True
except asyncio.TimeoutError:
verbose_proxy_logger.error("Realtime observer timed out dispatching usage accounting")
finally:
if not self._accounting_complete:
self._logging.model_call_details["realtime_accounting_incomplete"] = True
if self._started and (not self._usage_complete() or not self._accounting_complete):
await invalidate_budget_reservation_counters(
budget_reservation=self._auth.budget_reservation
)
elif not self._logging.model_call_details.get(REALTIME_SESSION_SUCCESS_LOGGED_KEY):
await release_or_invalidate_budget_reservation(
budget_reservation=self._auth.budget_reservation
)
finally:
self._ready.set()
async def _drain(self, reader: asyncio.Task[None], *, timeout: float | None = None) -> None:
try:
await asyncio.wait_for(
asyncio.shield(reader),
timeout=self._drain_timeout if timeout is None else min(self._drain_timeout, timeout),
)
except asyncio.TimeoutError:
if not self._usage_complete():
verbose_proxy_logger.error("Realtime observer timed out draining terminal usage")
except Exception: # noqa: BLE001 # cleanup must settle the socket even when reading or closing fails
verbose_proxy_logger.error("Realtime observer could not drain terminal usage")
return
class CallSupervisors:
def __init__(self) -> None:
self._tasks: tuple[asyncio.Task[None], ...] = ()
self._calls: tuple[CallSupervisor, ...] = ()
async def start(self, supervisor: CallSupervisor) -> None:
self._calls = (*self._calls, supervisor)
try:
await supervisor.start()
except BaseException:
self._calls = tuple(call for call in self._calls if call is not supervisor)
raise
task: Final = asyncio.create_task(self._watch(supervisor))
self._tasks = (*self._tasks, task)
async def _watch(self, supervisor: CallSupervisor) -> None:
try:
try:
await supervisor.wait()
except Exception: # noqa: BLE001 # task must be consumed without exposing provider exception payloads
verbose_proxy_logger.error("Realtime observer accounting failed")
finally:
self._calls = tuple(call for call in self._calls if call is not supervisor)
self._tasks = tuple(task for task in self._tasks if task is not asyncio.current_task())
async def shutdown(self) -> None:
await asyncio.gather(*(call.close() for call in self._calls), return_exceptions=True)
await asyncio.gather(*self._tasks, return_exceptions=True)
CALL_SUPERVISORS: Final = CallSupervisors()

View file

@ -16,7 +16,10 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
from litellm.proxy.common_utils.http_parsing_utils import (
_normalize_media_type, # pyright: ignore[reportPrivateUsage] # reuse the shared HTTP media-type normalization contract
_read_request_body,
)
from litellm.proxy.common_utils.openai_error_payload import (
error_status_code,
openai_error_param,
@ -375,6 +378,11 @@ async def proxy_realtime_calls(
request: Request,
fastapi_response: Response,
) -> Response:
if _normalize_media_type(request.headers.get("content-type", "")) in ("application/json", "multipart/form-data"):
from litellm.proxy.realtime_endpoints.call_sessions import create_codex_realtime_call
return await create_codex_realtime_call(request)
from litellm.proxy.proxy_server import (
add_litellm_data_to_request,
general_settings,

View file

@ -2061,6 +2061,8 @@ class ProxyLogging:
data: None,
call_type: CallTypesLiteral,
guardrails_only: bool = False,
*,
internal_realtime_observer: bool = False,
) -> None:
pass
@ -2071,6 +2073,8 @@ class ProxyLogging:
data: dict,
call_type: CallTypesLiteral,
guardrails_only: bool = False,
*,
internal_realtime_observer: bool = False,
) -> dict:
pass
@ -2080,6 +2084,8 @@ class ProxyLogging:
data: dict | None,
call_type: CallTypesLiteral,
guardrails_only: bool = False,
*,
internal_realtime_observer: bool = False,
) -> dict | None:
"""
Allows users to modify/reject the incoming request to the proxy, without having to deal with parsing Request body.
@ -2178,6 +2184,10 @@ class ProxyLogging:
deferred_route_exc: SensitiveDataRouteException | None = None
for _callback in caps.resolved_callbacks:
if internal_realtime_observer and isinstance(
_callback, (_PROXY_MaxParallelRequestsHandler, _PROXY_MaxParallelRequestsHandler_v3)
):
continue
start_time = time.time()
try:
if isinstance(_callback, CustomGuardrail) and data is not None:

View file

@ -76,6 +76,7 @@ def _get_realtime_http_provider_config(
dynamic_api_base: str | None,
dynamic_api_key: str | None,
litellm_params: GenericLiteLLMParams,
is_call: bool = False,
) -> tuple["BaseRealtimeHTTPConfig | None", str, str]:
"""
Return (provider_config, resolved_api_base, resolved_api_key) for the
@ -93,13 +94,15 @@ def _get_realtime_http_provider_config(
provider_config = ProviderConfigManager.get_provider_realtime_http_config(
model="",
provider=LlmProviders(custom_llm_provider),
params=litellm_params,
is_call=is_call,
)
raw_api_base: Final = dynamic_api_base or litellm_params.api_base
raw_api_key: Final = dynamic_api_key or litellm_params.api_key
if provider_config is not None:
resolved_api_base = provider_config.get_api_base(api_base=raw_api_base)
resolved_api_base = provider_config.resolve_api_base(litellm_params.api_base, dynamic_api_base)
resolved_api_key = provider_config.get_api_key(api_key=raw_api_key)
else:
# Fallback for providers without a dedicated HTTP config (treated as OpenAI-compatible).
@ -272,9 +275,15 @@ async def arealtime_calls(
dynamic_api_base=dynamic_api_base,
dynamic_api_key=dynamic_api_key,
litellm_params=litellm_params,
is_call=True,
)
if session is not None:
session = _with_resolved_session_model(session, model_name)
call_headers: Final = (
provider_config.get_realtime_calls_extra_headers(kwargs.get("extra_headers"))
if provider_config is not None
else kwargs.get("extra_headers")
)
litellm_logging_obj.update_from_kwargs(
kwargs=kwargs,
model=model_name,
@ -282,7 +291,7 @@ async def arealtime_calls(
litellm_params={"api_base": resolved_api_base},
custom_llm_provider=custom_llm_provider,
)
return await base_llm_http_handler.async_realtime_calls_handler(
response: Final = await base_llm_http_handler.async_realtime_calls_handler(
api_base=resolved_api_base,
openai_ephemeral_key=openai_ephemeral_key,
sdp_body=sdp_body,
@ -291,10 +300,17 @@ async def arealtime_calls(
provider_config=provider_config,
model=model_name,
session_config=session,
extra_headers=kwargs.get("extra_headers"),
extra_headers=call_headers,
client=kwargs.get("client"),
api_version=litellm_params.api_version,
)
return (
provider_config.transform_realtime_calls_response(
response, model_name, litellm_logging_obj.get_router_model_id(), call_headers
)
if provider_config is not None
else response
)
async def vertex_access_token_resolver(
@ -391,7 +407,26 @@ async def _arealtime(
model=model,
provider=LlmProviders(_custom_llm_provider),
)
if provider_config is not None:
provider_handler: Final = (
ProviderConfigManager.get_provider_realtime_handler(
LlmProviders(_custom_llm_provider), litellm_params, lambda: websocket.headers, headers
)
if _custom_llm_provider in LlmProviders._member_map_.values()
else None
)
if provider_handler is not None:
await provider_handler.async_realtime(
model=model,
websocket=websocket,
logging_obj=litellm_logging_obj,
api_base=api_base or None,
api_key=api_key,
timeout=timeout,
query_params=query_params,
user_api_key_dict=kwargs.get("user_api_key_dict"),
litellm_metadata=_build_litellm_metadata(kwargs),
)
elif provider_config is not None:
await base_llm_http_handler.async_realtime(
model=model,
websocket=websocket,

View file

@ -2034,6 +2034,11 @@ class OpenAIRealtimeStreamResponseBaseObject(TypedDict):
type: str
class OpenAIRealtimeSessionClosed(TypedDict):
type: ReadOnly[Literal["session.closed"]]
usage: ReadOnly[Mapping[str, object]]
class OpenAIRealtimeConversationObject(TypedDict, total=False):
id: str
object: Required[Literal["realtime.conversation"]]
@ -2239,6 +2244,7 @@ class OpenAIRealtimeEventTypes(Enum):
OpenAIRealtimeEvents = (
OpenAIRealtimeStreamResponseBaseObject
| OpenAIRealtimeSessionClosed
| OpenAIRealtimeStreamSessionEvents
| OpenAIRealtimeStreamResponseOutputItemAdded
| OpenAIRealtimeResponseContentPartAdded

View file

@ -49,6 +49,7 @@ class RealtimeModalityResponseTransformOutput(TypedDict):
class RealtimeQueryParams(TypedDict, total=False):
model: str
intent: str | None
call_id: ReadOnly[str]
# Add more fields as needed

View file

@ -420,6 +420,7 @@ if TYPE_CHECKING:
from litellm.llms.cohere.common_utils import CohereModelInfo
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.mistral.ocr.transformation import MistralOCRConfig
from litellm.llms.openai.realtime.handler import OpenAIRealtime
from litellm.proxy._types import AllowedModelRegion
from litellm.router_utils.get_retry_from_policy import (
get_num_retries_from_retry_policy,
@ -435,7 +436,7 @@ if TYPE_CHECKING:
ChatCompletionToolCallFunctionChunk,
)
from litellm.types.rerank import RerankResponse
from litellm.types.router import LiteLLM_Params
from litellm.types.router import GenericLiteLLMParams, LiteLLM_Params
from litellm.llms.base_llm.chat.transformation import BaseConfig
from litellm.llms.base_llm.completion.transformation import BaseTextCompletionConfig
@ -9119,6 +9120,10 @@ class ProviderConfigManager:
model: str,
provider: LlmProviders,
) -> BaseImageGenerationConfig | None:
if LlmProviders.CHATGPT == provider:
from litellm.llms.chatgpt.images import ChatGPTImageGenerationConfig
return ChatGPTImageGenerationConfig()
if LlmProviders.OPENAI == provider:
from litellm.llms.openai.image_generation import (
get_openai_image_generation_config,
@ -9287,16 +9292,36 @@ class ProviderConfigManager:
return GeminiRealtimeConfig()
return None
@staticmethod
def get_provider_realtime_handler(
provider: LlmProviders,
params: GenericLiteLLMParams,
get_headers: Callable[[], Mapping[str, str]],
extra_headers: Mapping[str, object] | None = None,
) -> OpenAIRealtime | None:
if provider == LlmProviders.CHATGPT:
from litellm.llms.chatgpt.realtime import ChatGPTRealtime
return ChatGPTRealtime(params, get_headers(), extra_headers)
return None
@staticmethod
def get_provider_realtime_http_config(
model: str,
provider: LlmProviders,
params: GenericLiteLLMParams | None = None,
is_call: bool = False,
) -> BaseRealtimeHTTPConfig | None:
"""
Return the HTTP transformation config for realtime HTTP endpoints
(POST /realtime/client_secrets and POST /realtime/calls).
"""
if LlmProviders.CHATGPT == provider:
from litellm.llms.chatgpt.realtime import ChatGPTRealtimeHTTPConfig
from litellm.types.router import GenericLiteLLMParams
return ChatGPTRealtimeHTTPConfig(params or GenericLiteLLMParams(), use_codex_backend=is_call)
if LlmProviders.OPENAI == provider:
from litellm.llms.openai.realtime.http_transformation import (
OpenAIRealtimeHTTPConfig,
@ -9316,6 +9341,10 @@ class ProviderConfigManager:
model: str,
provider: LlmProviders,
) -> BaseImageEditConfig | None:
if LlmProviders.CHATGPT == provider:
from litellm.llms.chatgpt.images import ChatGPTImageEditConfig
return ChatGPTImageEditConfig()
if LlmProviders.OPENAI == provider:
from litellm.llms.openai.image_edit import get_openai_image_edit_config

View file

@ -27932,6 +27932,16 @@
"max_tokens": 8191,
"mode": "embedding"
},
"chatgpt/gpt-live-1-codex": {
"litellm_provider": "chatgpt",
"mode": "realtime",
"supported_endpoints": [
"/v1/realtime/calls",
"/v1/live"
],
"supports_audio_input": true,
"supports_audio_output": true
},
"chatgpt/gpt-5.5": {
"litellm_provider": "chatgpt",
"source": "https://platform.openai.com/docs/models/gpt-5.5",

View file

@ -0,0 +1,102 @@
import asyncio
import os
from contextlib import AsyncExitStack
from datetime import datetime
from uuid import uuid4
import pytest
import pytest_asyncio
import litellm
from litellm.caching.redis_cache import RedisCache
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.parallel_request_limiter import _PROXY_MaxParallelRequestsHandler
from litellm.proxy.hooks.parallel_request_limiter_v3 import PARALLEL_REQUEST_SLOT_TTL_SECONDS
from litellm.proxy.utils import InternalUsageCache
@pytest_asyncio.fixture(loop_scope="function")
async def isolated_test_redis(monkeypatch):
raw_port = os.environ.get("LITELLM_TEST_REDIS_PORT", "")
if not raw_port.isdecimal() or not 1 <= int(raw_port) <= 65535:
pytest.fail("Set LITELLM_TEST_REDIS_PORT to an isolated Redis server's loopback port")
for name in tuple(os.environ):
if name.startswith("REDIS_"):
monkeypatch.delenv(name)
namespace = f"litellm-lua-test-{uuid4().hex}"
cache = RedisCache(
host="127.0.0.1",
port=int(raw_port),
namespace=namespace,
client_name=namespace,
socket_timeout=2,
socket_connect_timeout=2,
)
async with AsyncExitStack() as cleanup:
cleanup.callback(cache.redis_client.close)
cleanup.push_async_callback(cache.async_redis_conn_pool.disconnect)
client = cache.init_async_client()
cleanup.push_async_callback(cache.async_redis_conn_pool.disconnect)
cleanup.push_async_callback(client.aclose)
cleanup.callback(litellm.in_memory_llm_clients_cache.delete_cache, cache._get_async_client_cache_key())
try:
await client.ping()
yield cache
finally:
async for key in client.scan_iter(match=f"{namespace}:*"):
await client.delete(key)
@pytest.mark.asyncio
async def test_concurrent_realtime_releases_update_redis_without_lost_decrement(isolated_test_redis):
remote = isolated_test_redis
first_cache, second_cache = DualCache(redis_cache=remote), DualCache(redis_cache=remote)
first, second = (_PROXY_MaxParallelRequestsHandler(InternalUsageCache(c)) for c in (first_cache, second_cache))
auth = UserAPIKeyAuth(api_key="concurrent-key", max_parallel_requests=2)
first_data, second_data = {"model": "test"}, {"model": "test"}
first.begin_realtime_attachment(first_data)
second.begin_realtime_attachment(second_data)
await first.async_pre_call_hook(auth, first_cache, first_data, "_arealtime")
await second.async_pre_call_hook(auth, second_cache, second_data, "_arealtime")
key = f"concurrent-key::{datetime.now().strftime('%Y-%m-%d-%H-%M')}::request_count"
counter = {"current_requests": 2, "current_rpm": 2, "current_tpm": 17}
await first_cache.async_set_cache(key, counter)
await second_cache.async_set_cache(key, counter, local_only=True)
remote.redis_client.pexpire(remote.check_and_fix_namespace(key), 15000)
await asyncio.gather(
first.async_release_realtime_attachment(first_data, auth),
second.async_release_realtime_attachment(second_data, auth),
)
expected = {"current_requests": 0, "current_rpm": 2, "current_tpm": 17}
assert await remote.async_get_cache(key) == expected
assert 0 < remote.redis_client.pttl(remote.check_and_fix_namespace(key)) <= 15000
assert await first_cache.async_get_cache(key) == expected
assert await second_cache.async_get_cache(key) == expected
await first_cache.async_set_cache("missing", counter, local_only=True)
await first._release_realtime_counter("missing")
assert await remote.async_get_cache("missing") is None
assert await first_cache.async_get_cache("missing", local_only=True) is None
@pytest.mark.asyncio
async def test_realtime_lease_redis_renewal_is_atomic_and_does_not_resurrect(isolated_test_redis):
from litellm.proxy.hooks.parallel_request_limiter_v3 import PARALLEL_RENEW_SCRIPT
client = isolated_test_redis.init_async_client()
first_key = isolated_test_redis.check_and_fix_namespace("first")
second_key = isolated_test_redis.check_and_fix_namespace("second")
now = (await client.time())[0]
await client.zadd(first_key, {"owner": now - 10, "other": now})
await client.zadd(second_key, {"owner": now - PARALLEL_REQUEST_SLOT_TTL_SECONDS})
renew = client.register_script(PARALLEL_RENEW_SCRIPT)
assert await renew(keys=[first_key, second_key], args=["owner", PARALLEL_REQUEST_SLOT_TTL_SECONDS]) == [0]
assert await client.zscore(first_key, "owner") == now - 10
await client.zadd(second_key, {"owner": now - 10})
assert await renew(keys=[first_key, second_key], args=["owner", PARALLEL_REQUEST_SLOT_TTL_SECONDS]) == [1]
assert await client.zscore(first_key, "owner") >= now
assert await client.ttl(first_key) > PARALLEL_REQUEST_SLOT_TTL_SECONDS - 10
await client.zrem(second_key, "owner")
assert await renew(keys=[first_key, second_key], args=["owner", PARALLEL_REQUEST_SLOT_TTL_SECONDS]) == [0]
assert await client.zscore(second_key, "owner") is None
assert await client.zscore(first_key, "other") == now

View file

@ -849,6 +849,7 @@ async def test_user_api_key_auth_websocket():
# Prepare a mock WebSocket object
mock_websocket = MagicMock(spec=WebSocket)
mock_websocket.query_params = {"model": "some_model"}
mock_websocket.path_params = {}
mock_websocket.headers = {"authorization": "Bearer some_api_key"}
# Mock the scope attribute that user_api_key_auth_websocket accesses
mock_websocket.scope = {"headers": [(b"authorization", b"Bearer some_api_key")]}
@ -872,6 +873,7 @@ async def test_user_api_key_auth_websocket():
assert request_arg.headers["authorization"] == "Bearer some_api_key"
assert mock_user_api_key_auth.call_args.kwargs["api_key"] == "Bearer some_api_key"
assert await request_arg.json() == {"model": "some_model"}
@pytest.mark.asyncio
@ -885,6 +887,7 @@ async def test_user_api_key_auth_websocket_carries_asgi_path():
mock_websocket = MagicMock(spec=WebSocket)
mock_websocket.query_params = {"model": "some_model"}
mock_websocket.path_params = {}
mock_websocket.headers = {"authorization": "Bearer some_api_key"}
mock_websocket.scope = {
"type": "websocket",

View file

@ -3412,3 +3412,63 @@ async def test_refused_session_does_not_stamp_the_reservation_ownership_marker()
assert session.logging.logged_failures == (upstream_close,)
assert REALTIME_SESSION_SUCCESS_LOGGED_KEY not in session.logging.model_call_details
def test_live_terminal_usage_survives_filtered_event_logging(monkeypatch):
from litellm.cost_calculator import RealtimeAPITokenUsageProcessor
def terminal():
return {"type": "session.closed", "usage": {"audio_duration_ms": 4000, "backend_model_usage": []}}
monkeypatch.setattr(litellm, "logged_real_time_event_types", [])
stream = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
event = {**terminal(), "private_transcript": "Do not retain this text"}
stream.store_message(event)
assert stream.messages == [terminal()]
usage = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(stream.messages)
assert usage.total_tokens == 0
@pytest.mark.asyncio
async def test_live_attachment_does_not_dispatch_duplicate_usage():
worker = MagicMock()
logger = MagicMock()
stream = RealTimeStreaming(MagicMock(), MagicMock(), logger, logging_worker=worker, account_usage=False)
stream.store_message({"type": "session.closed", "usage": {"audio_duration_ms": 4000}})
await stream.log_messages()
worker.ensure_initialized_and_enqueue.assert_not_called()
logger.dispatch_success_handlers.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("account_usage", [False, True])
async def test_attachment_cleanup_runs_in_owning_context_only(account_usage):
from litellm.litellm_core_utils.realtime_streaming import realtime_attachment_cleanup
contexts = []
async def one(name):
task = asyncio.current_task()
callback = AsyncMock(side_effect=lambda: contexts.append((name, asyncio.current_task() is task)))
token = realtime_attachment_cleanup.set(callback)
try:
websocket = MagicMock()
websocket.receive_text = AsyncMock(side_effect=RuntimeError("disconnected"))
backend = MagicMock()
async def recv(**kwargs):
await asyncio.Event().wait()
backend.recv = recv
stream = RealTimeStreaming(websocket, backend, MagicMock(), account_usage=account_usage)
await stream.bidirectional_forward()
if account_usage:
callback.assert_not_awaited()
else:
callback.assert_awaited_once()
finally:
realtime_attachment_cleanup.reset(token)
await asyncio.gather(one("first"), one("second"))
assert sorted(contexts) == ([] if account_usage else [("first", True), ("second", True)])
assert realtime_attachment_cleanup.get() is None

View file

@ -0,0 +1,22 @@
import json
import time
import pytest
@pytest.fixture
def chatgpt_tokens(tmp_path, monkeypatch):
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.setenv("CHATGPT_AUTH_FILE", "auth.json")
for profile in ("default", "account2", "account3"):
name = "auth.json" if profile == "default" else profile + ".json"
(tmp_path / name).write_text(
json.dumps(
{
"access_token": "test-token-" + profile,
"account_id": "test-account-" + profile,
"expires_at": time.time() + 3600,
}
)
)
return str(tmp_path)

View file

@ -20,6 +20,29 @@ from litellm.utils import ProviderConfigManager
class TestChatGPTResponsesAPITransformation:
def test_guardian_preserves_strict_output_schema(self):
text = {
"format": {
"type": "json_schema",
"name": "review",
"strict": True,
"schema": {
"type": "object",
"properties": {"allowed": {"type": "boolean"}},
"required": ["allowed"],
"additionalProperties": False,
},
}
}
request = ChatGPTResponsesAPIConfig().transform_responses_api_request(
model="codex-auto-review",
input="Review the command pwd",
response_api_optional_request_params={"text": text},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert request["text"] == text
@pytest.mark.parametrize(
"model_name",
[

View file

@ -0,0 +1,57 @@
import hashlib
import time
import httpx
import pytest
from litellm.llms.chatgpt.codex import CodexRealtimeCall, build_sideband_request, parse_call_response
def test_encrypted_call_preserves_repeated_gateway_query(monkeypatch):
from litellm.proxy.realtime_endpoints.call_sessions import decode_call, encode_call
monkeypatch.setenv("LITELLM_SALT_KEY", "test-only-repeated-query")
authorization = "Bearer test-owner"
call = CodexRealtimeCall(
call_id="rtc_repeated",
model="gpt-live-1-codex",
alias="voice",
owner=hashlib.sha256(authorization.encode()).hexdigest(),
expires_at=time.time() + 60,
extra_query={"tag": ["alpha +/&", "beta"], "gateway": "tenant"},
)
restored = decode_call(encode_call(call), authorization)
assert restored.extra_query == {"tag": ("alpha +/&", "beta"), "gateway": "tenant"}
assert build_sideband_request(restored)["extra_query"] == restored.extra_query
@pytest.mark.parametrize("location", ["", "/v1/realtime/calls/foreign-id"])
def test_signaling_rejects_invalid_upstream_call_id(location):
response = httpx.Response(201, headers={"Location": location},
extensions={"chatgpt_realtime": {"model": "gpt-live-1-codex"}})
with pytest.raises(ValueError, match="String should match pattern"):
parse_call_response(response, "voice", "owner", 1000)
@pytest.mark.parametrize("extra_query", [None, {"gateway_token": "opaque +/& value"}])
def test_signaling_preserves_selected_model_for_sideband(extra_query):
response = httpx.Response(
201,
headers={"Location": "/v1/realtime/calls/rtc_provider"},
extensions={
"chatgpt_realtime": {
"model": "gpt-live-1-codex",
"api_base": "https://voice.example/codex",
"extra_headers": {"x-gateway-route": "voice"},
**({"extra_query": extra_query} if extra_query is not None else {}),
}
},
)
call = parse_call_response(response, "voice", "owner", 1000)
request = build_sideband_request(CodexRealtimeCall.model_validate_json(call.model_dump_json(exclude_none=True)))
assert request["api_base"] == "https://voice.example/codex"
assert request["model"] == "chatgpt/gpt-live-1-codex"
assert request["chatgpt_realtime_call_id"] == "rtc_provider"
assert request["query_params"] == {"model": "gpt-live-1-codex"}
assert request["extra_headers"] == {"x-gateway-route": "voice"}
assert request["extra_query"] == extra_query

View file

@ -0,0 +1,149 @@
import base64
import httpx
import pytest
import litellm
from litellm.llms.chatgpt.images import ChatGPTImageEditConfig, ChatGPTImageGenerationConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.types.router import GenericLiteLLMParams
@pytest.mark.parametrize("api_base", [None, "https://image-gateway.test"])
def test_generation_routes_with_chatgpt_oauth(chatgpt_tokens, api_base):
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"created": 1, "data": [{"b64_json": "aGVsbG8="}]})
client = HTTPHandler()
client.client = httpx.Client(transport=httpx.MockTransport(respond))
result = litellm.image_generation(
model="chatgpt/gpt-image-2",
prompt="blue circle",
api_base=api_base,
client=client,
quality="auto",
size="auto",
background="auto",
extra_headers={"x-gateway-route": "images", "aUtHoRiZaTiOn": "Bearer wrong", "CHATGPT-ACCOUNT-ID": "wrong"},
)
assert requests[0].headers["x-gateway-route"] == "images"
assert result.data[0].b64_json == "aGVsbG8="
assert str(requests[0].url) == (api_base or "https://chatgpt.com/backend-api/codex") + "/images/generations"
assert requests[0].headers["authorization"] == "Bearer test-token-" + "default"
assert requests[0].headers["chatgpt-account-id"] == "test-account-" + "default"
assert b'"model":"gpt-image-2"' in requests[0].content
def test_codex_json_edit_survives_sdk_dispatch(chatgpt_tokens):
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"created": 1, "data": [{"b64_json": "aGVsbG8="}]})
client = HTTPHandler()
client.client = httpx.Client(transport=httpx.MockTransport(respond))
references = [{"image_url": "data:image/png;base64,aGVsbG8="}]
result = litellm.image_edit(
model="chatgpt/gpt-image-2",
prompt="red circle",
extra_headers={"x-gateway-route": "images", "authorization": "Bearer wrong", "CHATGPT-ACCOUNT-ID": "wrong"},
images=references,
client=client,
quality="auto",
size="auto",
)
assert result.data[0].b64_json == "aGVsbG8="
assert str(requests[0].url) == "https://chatgpt.com/backend-api/codex/images/edits"
import json
assert json.loads(requests[0].content)["images"] == references
assert requests[0].headers["authorization"] == "Bearer test-token-default"
assert requests[0].headers["chatgpt-account-id"] == "test-account-default"
assert requests[0].headers["x-gateway-route"] == "images"
@pytest.mark.parametrize(
"references", [[], [{"image_url": "file:///etc/passwd"}], [{}], [{"image_url": "https://example.com/a.png"}] * 6]
)
def test_edit_rejects_invalid_references(references):
with pytest.raises(ValueError, match=r"images must contain|validation error"):
ChatGPTImageEditConfig().transform_image_edit_request(
"gpt-image-2", "edit", None, {}, GenericLiteLLMParams(images=references), {}
)
def test_edit_converts_multipart_image_bytes():
data, files = ChatGPTImageEditConfig().transform_image_edit_request(
"gpt-image-2", "edit", b"example", {}, GenericLiteLLMParams(), {}
)
assert not files
assert base64.b64decode(data["images"][0]["image_url"].split(",", 1)[1]) == b"example"
def test_image_auth_does_not_accept_inbound_override(chatgpt_tokens):
headers = ChatGPTImageGenerationConfig().validate_environment(
{"authorization": "Bearer wrong", "CHATGPT-ACCOUNT-ID": "wrong"}, "gpt-image-2", [], {}, {"chatgpt_token_dir": chatgpt_tokens}
)
assert httpx.Headers(headers)["authorization"] == "Bearer test-token-default"
assert httpx.Headers(headers)["chatgpt-account-id"] == "test-account-default"
@pytest.mark.asyncio
async def test_async_codex_edit_without_multipart_image(chatgpt_tokens):
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"created": 1, "data": [{"b64_json": "aGVsbG8="}]})
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
response = await litellm.aimage_edit(
model="chatgpt/gpt-image-2",
prompt="red circle",
extra_headers={"x-gateway-route": "images", "authorization": "Bearer wrong", "CHATGPT-ACCOUNT-ID": "wrong"},
client=client,
images=[{"image_url": "data:image/png;base64,aGVsbG8="}],
chatgpt_auth_profile="account3",
)
assert response.data[0].b64_json == "aGVsbG8="
assert str(requests[0].url).endswith("/codex/images/edits")
assert requests[0].headers["content-type"] == "application/json"
await client.client.aclose()
assert requests[0].headers["authorization"] == "Bearer test-token-default"
assert requests[0].headers["chatgpt-account-id"] == "test-account-default"
assert requests[0].headers["x-gateway-route"] == "images"
@pytest.mark.parametrize("as_tuple", [False, True])
def test_edit_accepts_filesystem_path(tmp_path, as_tuple):
image = tmp_path / "reference.png"
image.write_bytes(b"reference image bytes")
data, files = ChatGPTImageEditConfig().transform_image_edit_request(
"gpt-image-2", "edit", ("reference.png", image, "image/png") if as_tuple else image,
{}, GenericLiteLLMParams(), {}
)
assert not files
assert data["images"] == ({"image_url": "data:image/png;base64," + base64.b64encode(image.read_bytes()).decode()},)
@pytest.mark.parametrize("env_name", ["CHATGPT_API_BASE", "OPENAI_CHATGPT_API_BASE"])
@pytest.mark.parametrize("api_base", [None, "https://deployment.example/codex"])
def test_image_routes_use_configured_gateway(monkeypatch, env_name, api_base, tmp_path):
token_path = tmp_path / "unavailable-token-directory"
token_path.write_text("not a directory")
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(token_path))
monkeypatch.delenv("CHATGPT_API_BASE", raising=False)
monkeypatch.delenv("OPENAI_CHATGPT_API_BASE", raising=False)
monkeypatch.setenv(env_name, "https://gateway.example/codex/")
expected = api_base or "https://gateway.example/codex"
assert ChatGPTImageGenerationConfig().get_complete_url(api_base, None, "gpt-image-2", {}, {}) == (
expected + "/images/generations"
)
assert ChatGPTImageEditConfig().get_complete_url("gpt-image-2", api_base, {}) == expected + "/images/edits"

View file

@ -0,0 +1,477 @@
import json
import sys
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import litellm
from litellm.llms.chatgpt.realtime import ChatGPTRealtime
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.types.router import GenericLiteLLMParams
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ["closed", "network"])
@pytest.mark.parametrize("hangup_status", [200, 503])
async def test_live_closed_observer_uses_independent_hangup(failure, hangup_status, chatgpt_tokens, monkeypatch):
from websockets.exceptions import ConnectionClosedOK
from websockets.frames import Close
from litellm.caching.llm_caching_handler import LLMClientCache
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache())
handler = ChatGPTRealtime(
GenericLiteLLMParams(
chatgpt_realtime_call_id="rtc_live_closed",
chatgpt_token_dir=chatgpt_tokens,
extra_query={"gateway": "tenant", "tag": ["alpha +/&", "beta"]},
),
{},
{"x-gateway-token": "test-only"},
)
connection = SimpleNamespace(
send=AsyncMock(
side_effect=(
ConnectionClosedOK(Close(1000, ""), Close(1000, ""), True)
if failure == "closed"
else OSError("socket unavailable")
)
)
)
requests = []
def respond(request):
requests.append(request)
return httpx.Response(hangup_status)
client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
try:
with patch("httpx.AsyncClient", return_value=client) as create_client:
for _ in range(2):
with pytest.raises(httpx.HTTPStatusError) if hangup_status == 503 else nullcontext():
await handler.close_call(connection, "gpt-live-1-codex", "https://gateway.example/v1")
assert not client.is_closed
create_client.assert_called_once()
finally:
await client.aclose()
assert len(requests) == 2
assert requests[0].method == "POST"
assert requests[0].url.path == "/v1/realtime/calls/rtc_live_closed/hangup"
assert requests[0].url.params.get_list("tag") == ["alpha +/&", "beta"]
assert requests[0].url.params["gateway"] == "tenant"
assert requests[0].headers["x-gateway-token"] == "test-only"
assert requests[0].headers["Authorization"] == "Bearer test-token-default"
assert requests[0].extensions["timeout"]["read"] == 10
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ["client_secrets", "transcription_sessions"])
@pytest.mark.parametrize("source", ["default", "explicit", "CHATGPT_API_BASE", "OPENAI_CHATGPT_API_BASE"])
async def test_realtime_session_urls_honor_gateway(endpoint, source, chatgpt_tokens, monkeypatch):
monkeypatch.setenv("CHATGPT_TOKEN_DIR", chatgpt_tokens)
monkeypatch.delenv("CHATGPT_API_BASE", raising=False)
monkeypatch.delenv("OPENAI_CHATGPT_API_BASE", raising=False)
gateway = "https://voice.example/custom/v1/"
if source in ("CHATGPT_API_BASE", "OPENAI_CHATGPT_API_BASE"):
monkeypatch.setenv(source, gateway)
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"client_secret": {"value": "test-secret"}})
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
kwargs = {"model": "chatgpt/gpt-realtime-1.5", "client": client}
if source == "explicit":
kwargs["api_base"] = gateway
try:
if endpoint == "client_secrets":
await litellm.acreate_realtime_client_secret(**kwargs)
else:
await litellm.acreate_realtime_transcription_session(**kwargs)
finally:
await client.client.aclose()
base = "https://api.openai.com/v1" if source == "default" else gateway.rstrip("/")
assert len(requests) == 1
assert str(requests[0].url) == f"{base}/realtime/{endpoint}"
assert requests[0].headers["authorization"] == "Bearer test-token-default"
@pytest.mark.asyncio
@pytest.mark.parametrize("inbound_headers", [{}, {"openai-alpha": "quicksilver=v2"}])
@pytest.mark.parametrize("model, endpoint", [("gpt-live-1-codex", "live"), ("gpt-realtime-1.5", "realtime")])
async def test_routed_call_preserves_deployment_gateway_headers(
inbound_headers, model, endpoint, chatgpt_tokens, monkeypatch
):
from litellm.llms.chatgpt.codex import (
CodexRealtimeCall,
CodexRealtimeOffer,
build_call_request,
build_sideband_request,
parse_call_response,
)
monkeypatch.setenv("CHATGPT_TOKEN_DIR", chatgpt_tokens)
requests = []
def respond(request):
requests.append(request)
return httpx.Response(201, text="v=0\r\n", headers={"location": "/v1/realtime/calls/rtc_test"})
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
router = litellm.Router(
model_list=[
{
"model_name": "voice-gateway",
"litellm_params": {
"model": f"chatgpt/{model}",
"api_base": "https://voice.example/backend-api/codex",
"extra_headers": {"x-gateway-route": "configured"},
"extra_query": {
"gateway_token": "configured",
"intent": "pinned-intent",
"count": 7,
"fraction": 1.5,
"enabled": True,
"disabled": False,
"blank": None,
"tag": ["alpha +/&", "beta"],
"empty": [],
"model": "other-model",
"call_id": "rtc_wrong",
},
},
"model_info": {"id": "selected-gateway-deployment"},
}
],
num_retries=0,
)
offer = CodexRealtimeOffer(sdp="v=0\r\n", session={"model": "voice-gateway"})
try:
response = await router.arealtime_calls(
**build_call_request(offer, {"intent": "quicksilver", "architecture": "avas"}, inbound_headers),
client=client,
)
assert requests[0].headers.get("x-gateway-route") == "configured"
assert dict(requests[0].url.params) == {
"gateway_token": "configured",
"intent": "pinned-intent",
"architecture": "avas",
"count": "7",
"fraction": "1.5",
"enabled": "true",
"disabled": "false",
"blank": "",
"tag": "alpha +/&",
"model": "other-model",
"call_id": "rtc_wrong",
}
assert requests[0].url.params.get_list("tag") == ["alpha +/&", "beta"]
assert response.extensions["chatgpt_realtime"]["extra_query"] == {
**dict(requests[0].url.params),
"tag": ("alpha +/&", "beta"),
"empty": (),
}
assert response.extensions["chatgpt_realtime"]["extra_headers"]["x-gateway-route"] == "configured"
for name, value in inbound_headers.items():
assert requests[0].headers[name] == value
call = parse_call_response(response, alias="voice-gateway", owner="test-owner", expires_at=1)
restored = CodexRealtimeCall.model_validate_json(call.model_dump_json())
assert restored.model_id == "selected-gateway-deployment"
assert restored.model == model
handler = ChatGPTRealtime(GenericLiteLLMParams.model_validate(build_sideband_request(restored)), {})
sideband_url = httpx.URL(handler._construct_url(restored.api_base, {"model": restored.model}))
assert {key: value for key, value in sideband_url.params.items() if key != "call_id"} == {
key: value for key, value in requests[0].url.params.items() if key not in ("model", "call_id")
}
assert sideband_url.params.get("call_id") == ("rtc_test" if endpoint == "realtime" else None)
assert sideband_url.params.get_list("tag") == ["alpha +/&", "beta"]
assert sideband_url.path.endswith("/realtime" if endpoint == "realtime" else "/live/rtc_test")
finally:
await client.client.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ["gpt-realtime-1.5", "gpt-live-1-codex"])
@pytest.mark.parametrize("call_id", [None, "rtc_existing"])
async def test_websocket_forwards_configured_headers_without_client_identity(model, call_id, chatgpt_tokens):
websocket = SimpleNamespace(
headers={"authorization": "Bearer client", "cookie": "private-cookie", "openai-alpha": "client-value"},
scope={},
receive_text=AsyncMock(side_effect=RuntimeError("client disconnected")),
send_text=AsyncMock(),
close=AsyncMock(),
)
with patch("websockets.connect") as connect:
connect.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("stop before streaming"))
await litellm._arealtime(
model=f"chatgpt/{model}",
websocket=websocket,
api_base="https://voice.example/codex",
chatgpt_realtime_call_id=call_id,
query_params={"model": model, "intent": "client-intent"},
extra_query={"intent": "configured-intent", "tag": ["alpha +/&", "beta"]},
headers={"x-deployment-header": "configured"},
extra_headers={
"X-Gateway-Route": "voice",
"OpenAI-Alpha": "configured-value",
"aUtHoRiZaTiOn": "Bearer wrong",
"CHATGPT-ACCOUNT-ID": "wrong",
},
)
connect.assert_called_once()
headers = httpx.Headers(connect.call_args.kwargs["additional_headers"])
upstream_url = httpx.URL(connect.call_args.args[0])
assert upstream_url.params.get_list("intent") == ["configured-intent"]
assert upstream_url.params.get_list("tag") == ["alpha +/&", "beta"]
assert headers["x-deployment-header"] == "configured"
assert headers["x-gateway-route"] == "voice"
assert headers["openai-alpha"] == "configured-value"
assert headers["authorization"] == "Bearer test-token-default"
assert headers["chatgpt-account-id"] == "test-account-default"
assert "cookie" not in headers
@pytest.mark.asyncio
@pytest.mark.parametrize("api_base", [None, "https://voice.example/backend-api/codex"])
async def test_chatgpt_call_keeps_oauth_and_frameless_session(chatgpt_tokens, api_base):
requests = []
def respond(request):
requests.append(request)
return httpx.Response(201, text="v=0\r\n", headers={"location": "/v1/realtime/calls/rtc_test"})
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
response = await litellm.arealtime_calls(
model="chatgpt/gpt-live-1-codex",
api_base=api_base,
openai_ephemeral_key="",
sdp_body=b"v=0\r\n",
session={"model": "chatgpt/gpt-live-1-codex", "audio": {"output": {"voice": "sol"}}},
extra_query={"intent": "quicksilver", "architecture": "avas"},
chatgpt_realtime_client_query={"intent": "untrusted-override", "architecture": "avas", "untrusted": "bad"},
extra_headers={
"openai-alpha": "quicksilver=v2",
"x-gateway-route": "voice",
"aUtHoRiZaTiOn": "Bearer wrong",
"CHATGPT-ACCOUNT-ID": "wrong",
},
client=client,
)
assert response.extensions["chatgpt_realtime"]["api_base"] == (api_base or "https://api.openai.com/v1")
assert response.extensions["chatgpt_realtime"]["extra_headers"] == {
"openai-alpha": "quicksilver=v2",
"x-gateway-route": "voice",
}
assert requests[0].url.host == ("voice.example" if api_base else "chatgpt.com")
assert response.status_code == 201
assert response.extensions["chatgpt_realtime"]["extra_query"] == {"intent": "quicksilver", "architecture": "avas"}
assert requests[0].url.path == "/backend-api/codex/realtime/calls"
assert requests[0].url.params["architecture"] == "avas"
assert requests[0].headers["authorization"] == "Bearer test-token-" + "default"
assert requests[0].headers["chatgpt-account-id"] == "test-account-default"
assert requests[0].headers["openai-alpha"] == "quicksilver=v2"
assert requests[0].headers["x-gateway-route"] == "voice"
assert json.loads(requests[0].content) == {
"sdp": "v=0\r\n",
"session": {"model": "gpt-live-1-codex", "audio": {"output": {"voice": "sol"}}},
}
await client.client.aclose()
@pytest.mark.asyncio
async def test_openai_call_preserves_explicit_identity_headers():
requests = []
def respond(request):
requests.append(request)
return httpx.Response(201, text="v=0\r\n")
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
try:
response = await litellm.arealtime_calls(
model="openai/gpt-realtime-1.5",
openai_ephemeral_key="original-key",
sdp_body=b"v=0\r\n",
extra_headers={"Authorization": "Bearer explicit-key", "chatgpt-account-id": "custom-account"},
client=client,
)
assert response.status_code == 201
assert requests[0].headers["authorization"] == "Bearer explicit-key"
assert requests[0].headers["chatgpt-account-id"] == "custom-account"
assert requests[0].headers["content-type"].startswith("multipart/form-data")
finally:
await client.client.aclose()
@pytest.mark.parametrize("model,endpoint", [("gpt-realtime-1.5", "realtime"), ("gpt-live-1-codex", "live")])
def test_realtime_uses_platform_endpoint_with_oauth_headers(model, endpoint, chatgpt_tokens, local_model_cost_map):
handler = ChatGPTRealtime(
GenericLiteLLMParams(),
{
"authorization": "Bearer proxy-key",
"openai-alpha": "quicksilver=v2",
},
)
assert handler._construct_url("https://api.openai.com/v1", {"model": model}) == (
f"wss://api.openai.com/v1/{endpoint}?model={model}"
)
headers = handler._get_additional_headers("unused")
assert headers["Authorization"] == "Bearer test-token-default"
assert "authorization" not in headers
assert headers["openai-alpha"] == "quicksilver=v2"
@pytest.mark.parametrize("endpoint", ["live", "realtime"])
def test_new_realtime_session_preserves_gateway_query(endpoint, chatgpt_tokens, local_model_cost_map):
model = "gpt-live-1-codex" if endpoint == "live" else "gpt-realtime-1.5"
handler = ChatGPTRealtime(
GenericLiteLLMParams(
chatgpt_token_dir=chatgpt_tokens,
chatgpt_realtime_client_query={"intent": "conversation", "architecture": "client-architecture"},
extra_query={
"gateway_token": "opaque +/& value",
"intent": "gateway-intent",
"architecture": "gateway-architecture",
"model": "other-model",
"call_id": "rtc_other",
},
),
{},
)
url = httpx.URL(handler._construct_url("https://gateway.example/v1", {"model": model, "intent": "query-intent"}))
assert url.path == f"/v1/{endpoint}"
assert dict(url.params) == {
"model": model,
"gateway_token": "opaque +/& value",
"intent": "gateway-intent",
"architecture": "gateway-architecture",
}
@pytest.mark.asyncio
async def test_openai_http_call_does_not_require_websockets(monkeypatch):
monkeypatch.delitem(sys.modules, "litellm.llms.chatgpt.realtime", raising=False)
for name in tuple(sys.modules):
if name == "websockets" or name.startswith("websockets."):
monkeypatch.delitem(sys.modules, name)
monkeypatch.setitem(sys.modules, "websockets", None)
requests = []
def respond(request):
requests.append(request)
return httpx.Response(201, text="v=0\r\n")
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
try:
response = await litellm.arealtime_calls(
model="openai/gpt-realtime-1.5",
openai_ephemeral_key="test-only",
sdp_body=b"v=0\r\n",
api_key="test-only",
client=client,
)
assert response.status_code == 201
assert len(requests) == 1
assert requests[0].url.path == "/v1/realtime/calls"
finally:
await client.client.aclose()
@pytest.mark.parametrize("endpoint", ["live", "realtime"])
@pytest.mark.parametrize("call_id", [None, "rtc_metadata"])
def test_realtime_routes_new_models_using_registered_metadata(endpoint, call_id, chatgpt_tokens, local_model_cost_map):
model = "metadata-voice-model"
litellm.register_model({f"chatgpt/{model}": {
"litellm_provider": "chatgpt", "mode": "realtime", "supported_endpoints": [f"/v1/{endpoint}"]
}})
handler = ChatGPTRealtime(GenericLiteLLMParams(chatgpt_realtime_call_id=call_id), {})
expected = (
f"wss://api.openai.com/v1/{endpoint}?model={model}" if call_id is None
else f"wss://api.openai.com/v1/live/{call_id}" if endpoint == "live"
else f"wss://api.openai.com/v1/realtime?call_id={call_id}"
)
assert handler._construct_url("https://api.openai.com/v1", {"model": model}) == expected
def test_realtime_unknown_model_keeps_standard_endpoint(chatgpt_tokens, local_model_cost_map):
handler = ChatGPTRealtime(GenericLiteLLMParams(), {})
assert handler._construct_url("https://api.openai.com/v1", {"model": "unknown-voice-model"}) == (
"wss://api.openai.com/v1/realtime?model=unknown-voice-model"
)
@pytest.mark.parametrize("env_name", ["CHATGPT_API_BASE", "OPENAI_CHATGPT_API_BASE"])
@pytest.mark.parametrize("api_base", [None, "https://deployment.example/codex"])
def test_realtime_routes_use_configured_gateway(monkeypatch, env_name, api_base, chatgpt_tokens):
from litellm.llms.chatgpt.realtime import ChatGPTRealtimeHTTPConfig
monkeypatch.delenv("CHATGPT_API_BASE", raising=False)
monkeypatch.delenv("OPENAI_CHATGPT_API_BASE", raising=False)
monkeypatch.setenv(env_name, "https://gateway.example/codex/")
expected = api_base or "https://gateway.example/codex"
config = ChatGPTRealtimeHTTPConfig(GenericLiteLLMParams())
assert config.get_realtime_calls_url(api_base, "gpt-live-1-codex") == expected + "/realtime/calls"
handler = ChatGPTRealtime(GenericLiteLLMParams(), {})
assert handler._construct_url(handler.get_api_base(api_base), {"model": "gpt-realtime-1.5"}) == (
expected.replace("https://", "wss://") + "/realtime?model=gpt-realtime-1.5"
)
@pytest.mark.parametrize("model,endpoint", [("gpt-live-1-codex", "live"), ("gpt-realtime-1.5", "realtime")])
def test_sideband_restores_gateway_query_without_overriding_call(model, endpoint, chatgpt_tokens):
handler = ChatGPTRealtime(
GenericLiteLLMParams(
chatgpt_realtime_call_id="rtc_selected",
extra_query={"gateway_token": "opaque +/& value", "model": "other", "call_id": "rtc_other"},
),
{},
)
url = httpx.URL(handler._construct_url("https://gateway.example/v1", {"model": model}))
assert url.params["gateway_token"] == "opaque +/& value"
assert "model" not in url.params
if endpoint == "live":
assert url.path == "/v1/live/rtc_selected"
assert "call_id" not in url.params
else:
assert url.path == "/v1/realtime"
assert url.params["call_id"] == "rtc_selected"
def test_client_cannot_forge_supervised_call_accounting(chatgpt_tokens):
from litellm.llms.chatgpt.realtime import CallAccounting, accounts_for_call_usage
assert accounts_for_call_usage(GenericLiteLLMParams(chatgpt_call_accounting={"supervised": True}))
assert accounts_for_call_usage(GenericLiteLLMParams(chatgpt_call_accounting="supervised"))
assert not accounts_for_call_usage(GenericLiteLLMParams(chatgpt_call_accounting=CallAccounting.SUPERVISED))
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ["gpt-live-1-codex", "gpt-realtime-1.5"])
async def test_supervisor_connection_preserves_call_routing(model, chatgpt_tokens):
handler = ChatGPTRealtime(
GenericLiteLLMParams(
chatgpt_token_dir=chatgpt_tokens,
chatgpt_realtime_call_id="rtc_owner",
extra_query={"gateway_token": "a+b&c"},
),
{"openai-alpha": "quicksilver=v2"},
{"x-gateway-token": "configured"},
)
connection = AsyncMock()
with patch("websockets.connect", AsyncMock(return_value=connection)) as connect:
assert await handler.open_call_connection(model, "https://gateway.example/v1") is connection
url = httpx.URL(connect.call_args.args[0])
assert url.params["gateway_token"] == "a+b&c"
assert connect.call_args.kwargs["additional_headers"]["x-gateway-token"] == "configured"
assert url.path.endswith("/rtc_owner") if model == "gpt-live-1-codex" else url.params["call_id"] == "rtc_owner"

View file

@ -3713,3 +3713,62 @@ def test_image_edit_handler_keeps_the_sync_transform():
assert config.transform_calls == ["sync"]
assert captured["body"] == {"transformed_by": "sync"}
assert response.data[0].b64_json == "sync"
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ["client_secrets", "transcription_sessions"])
@pytest.mark.parametrize("provider", ["chatgpt", "openai"])
@pytest.mark.parametrize("authorization_header", ["Authorization", "aUtHoRiZaTiOn"])
async def test_realtime_http_sessions_preserve_provider_identity(
endpoint, provider, authorization_header, tmp_path, monkeypatch
):
import time
from litellm.llms.chatgpt.realtime import ChatGPTRealtimeHTTPConfig
from litellm.llms.openai.realtime.http_transformation import OpenAIRealtimeHTTPConfig
from litellm.types.router import GenericLiteLLMParams
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.setenv("CHATGPT_AUTH_FILE", "auth.json")
(tmp_path / "auth.json").write_text(
json.dumps({"access_token": "test-resolved", "account_id": "test-selected", "expires_at": time.time() + 3600})
)
config = ChatGPTRealtimeHTTPConfig(GenericLiteLLMParams()) if provider == "chatgpt" else OpenAIRealtimeHTTPConfig()
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"id": "session-test"})
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
try:
response = await BaseLLMHTTPHandler()._async_realtime_session_post(
endpoint=endpoint,
api_base="https://gateway.example/v1",
api_key="test-openai",
request_data={"session": {"model": "gpt-realtime-1.5"}},
logging_obj=Mock(),
timeout=5,
provider_config=config,
model="gpt-realtime-1.5",
extra_headers={
authorization_header: "Bearer test-override",
"CHATGPT-ACCOUNT-ID": "test-other-account",
"x-gateway-route": "required",
},
client=client,
)
assert response.status_code == 200
assert not client.client.is_closed
finally:
await client.client.aclose()
assert len(requests) == 1
assert requests[0].url.path == f"/v1/realtime/{endpoint}"
assert requests[0].headers["x-gateway-route"] == "required"
if provider == "chatgpt":
assert requests[0].headers.get_list("authorization") == ["Bearer test-resolved"]
assert requests[0].headers.get_list("chatgpt-account-id") == ["test-selected"]
else:
assert requests[0].headers.get_list("authorization")[-1] == "Bearer test-override"
assert requests[0].headers["chatgpt-account-id"] == "test-other-account"

View file

@ -947,7 +947,8 @@ def test_get_model_from_request_handles_managed_id_decoder_failures():
"/openai/v1/realtime/calls",
],
)
def test_get_model_from_request_extracts_realtime_session_model(route):
@pytest.mark.parametrize("encoded", [False, True])
def test_get_model_from_request_extracts_realtime_session_model(route, encoded):
"""The effective realtime model lives in ``session.model`` (not the
top-level ``model``). It must be surfaced so can_key_call_model() can
validate the model a restricted key is actually requesting.
@ -957,13 +958,36 @@ def test_get_model_from_request_extracts_realtime_session_model(route):
"""
assert (
get_model_from_request(
request_data={"session": {"type": "realtime", "model": "gpt-realtime"}},
request_data={"session": '{"model":"gpt-realtime"}' if encoded else {"model": "gpt-realtime"}},
route=route,
)
== "gpt-realtime"
)
@pytest.mark.parametrize("session", ['{"model":"actual-voice"}', {"model": "actual-voice"}])
def test_realtime_calls_auth_uses_executed_session_model_despite_decoys(session):
assert (
get_model_from_request(
request_data={"model": "body-decoy", "session": session},
route="/v1/realtime/calls",
request_query_params={"model": "query-decoy"},
request_headers={"x-litellm-model": "header-decoy"},
)
== "actual-voice"
)
@pytest.mark.parametrize("model", ["voice,alias", " voice "])
def test_realtime_calls_auth_preserves_exact_session_model(model):
assert get_model_from_request(request_data={"session": {"model": model}}, route="/v1/realtime/calls") == model
@pytest.mark.parametrize("session", ["invalid", "null", "[]", "12", '"text"', "{}"])
def test_realtime_model_extraction_ignores_invalid_serialized_session(session):
assert get_model_from_request(request_data={"session": session}, route="/v1/realtime/calls") is None
def test_get_model_from_request_realtime_includes_top_level_and_session_model():
"""When both top-level and session model are present, both are returned so
neither path can smuggle a disallowed model past the model-access check."""

View file

@ -17,6 +17,24 @@ from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
from litellm.proxy.auth.route_checks import RouteChecks
@pytest.mark.parametrize("route", ["/live", "/v1/live", "/v1/live/rtc_litellm_test"])
def test_codex_live_routes_allow_inference_keys(route: str):
from litellm.proxy.auth.auth_checks import _allowed_routes_check
assert RouteChecks.is_llm_api_route(route)
assert _allowed_routes_check(user_route=route, allowed_routes=["openai_routes"])
token = UserAPIKeyAuth(allowed_routes=["llm_api_routes"])
RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=token)
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=None,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route=route,
request=Request({"type": "http", "path": route, "query_string": b"", "headers": []}),
valid_token=token,
request_data={},
)
def test_non_admin_config_update_route_rejected():
"""Test that non-admin users are rejected when trying to call /config/update"""

View file

@ -7285,6 +7285,52 @@ def test_user_api_key_auth_opens_a_datadog_span_for_accepted_and_rejected_keys(t
assert [span for span in report["spans"] if span == auth_span] == [auth_span, auth_span]
@pytest.mark.asyncio
@pytest.mark.parametrize("attachment", ["path", "query"])
@pytest.mark.parametrize("credential", ["authorization", "api-key", "subprotocol", "x-litellm-api-key", "custom", "custom-mixed"])
@pytest.mark.parametrize("query_model", [b"", b"model=unbudgeted"])
async def test_sideband_auth_uses_encrypted_model_for_budget_checks(monkeypatch, attachment, credential, query_model):
import hashlib
import importlib
import time
from unittest.mock import AsyncMock
from fastapi import WebSocket
from litellm.llms.chatgpt.codex import CodexRealtimeCall
from litellm.proxy.realtime_endpoints.call_sessions import encode_call
auth_module = importlib.import_module("litellm.proxy.auth.user_api_key_auth")
monkeypatch.setenv("LITELLM_SALT_KEY", "test-only-sideband-budget-salt")
token = encode_call(CodexRealtimeCall(
call_id="rtc_test", model="gpt-live-1-codex", alias="budgeted-voice",
owner=hashlib.sha256(b"Bearer owner").hexdigest(), expires_at=time.time() + 300,
))
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {"litellm_key_header_name": "x-proxy-key"} if credential.startswith("custom") else {})
seen = []
async def authenticate(request, api_key):
seen.append((await request.json(), api_key))
return "authenticated-with-model"
monkeypatch.setattr(auth_module, "user_api_key_auth", authenticate)
websocket = WebSocket({
"type": "websocket", "scheme": "ws", "server": ("localhost", 4000),
"path": "/v1/live/" + token if attachment == "path" else "/v1/realtime",
"path_params": {"call_id": token} if attachment == "path" else {},
"query_string": query_model + (b"&call_id=" + token.encode() if attachment == "query" else b""),
"headers": {
"authorization": [(b"authorization", b"Bearer owner")],
"api-key": [(b"api-key", b"owner")],
"x-litellm-api-key": [(b"x-litellm-api-key", b"owner")],
"custom": [(b"x-proxy-key", b"Bearer owner")],
"custom-mixed": [(b"x-proxy-key", b"Bearer owner"), (b"authorization", b"Bearer other-owner")],
"subprotocol": [(b"sec-websocket-protocol", b"realtime, openai-insecure-api-key.owner")],
}[credential],
}, AsyncMock(), AsyncMock())
assert await auth_module.user_api_key_auth_websocket(websocket) == "authenticated-with-model"
assert seen == [({"model": "budgeted-voice"}, "Bearer owner")]
@pytest.mark.asyncio
@pytest.mark.parametrize("is_proxy_admin", [False, True], ids=["standard-return", "proxy-admin-return"])
async def test_jwt_builder_returns_every_team_grant_the_key_path_gets(is_proxy_admin):
@ -7401,6 +7447,87 @@ async def test_jwt_builder_returns_every_team_grant_the_key_path_gets(is_proxy_a
assert token.jwt_claims == {"sub": "jwt-user"}
@pytest.mark.asyncio
@pytest.mark.parametrize("attachment", ["path", "query"])
async def test_sideband_rejects_budget_fallback_before_rerouting(monkeypatch, attachment):
import hashlib
import importlib
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
from fastapi import HTTPException, WebSocket
from litellm.llms.chatgpt.codex import CodexRealtimeCall
from litellm.proxy.realtime_endpoints.call_sessions import encode_call
auth_module = importlib.import_module("litellm.proxy.auth.user_api_key_auth")
monkeypatch.setenv("LITELLM_SALT_KEY", "test-only-sideband-budget-salt")
token = encode_call(CodexRealtimeCall(
call_id="rtc_test", model="gpt-live-1-codex", alias="budgeted-voice",
owner=hashlib.sha256(b"Bearer owner").hexdigest(), expires_at=time.time() + 300,
))
limiter = SimpleNamespace(
is_key_within_model_budget=AsyncMock(side_effect=litellm.BudgetExceededError(current_cost=2, max_budget=1)),
get_fallback_model_within_budget=AsyncMock(return_value="cheap-voice"),
)
auth = UserAPIKeyAuth(models=["budgeted-voice", "cheap-voice"])
async def authenticate(request, api_key):
data = await request.json()
await auth_module._check_key_model_budget_with_fallback(auth, limiter, data["model"], data, request)
return auth
monkeypatch.setattr(auth_module, "user_api_key_auth", authenticate)
monkeypatch.setattr(auth_module, "can_key_call_model", AsyncMock())
send = AsyncMock()
websocket = WebSocket({
"type": "websocket", "scheme": "ws", "server": ("localhost", 4000),
"path": "/v1/live/" + token if attachment == "path" else "/v1/realtime",
"path_params": {"call_id": token} if attachment == "path" else {},
"query_string": b"call_id=" + token.encode() if attachment == "query" else b"",
"headers": [(b"authorization", b"Bearer owner")],
}, AsyncMock(), send)
with pytest.raises(HTTPException) as error:
await auth_module.user_api_key_auth_websocket(websocket)
assert error.value.status_code == 403
limiter.get_fallback_model_within_budget.assert_not_awaited()
send.assert_awaited_once_with({"type": "websocket.close", "code": 1008, "reason": ""})
@pytest.mark.asyncio
@pytest.mark.parametrize("custom_value", [None, b"Bearer different-owner"])
async def test_sideband_custom_header_cannot_fall_back_to_other_credentials(monkeypatch, custom_value):
import hashlib
import importlib
import time
from unittest.mock import AsyncMock
from fastapi import HTTPException, WebSocket
from litellm.proxy import proxy_server
from litellm.llms.chatgpt.codex import CodexRealtimeCall
from litellm.proxy.realtime_endpoints.call_sessions import encode_call
auth_module = importlib.import_module("litellm.proxy.auth.user_api_key_auth")
monkeypatch.setenv("LITELLM_SALT_KEY", "test-only-custom-header-salt")
monkeypatch.setattr(proxy_server, "general_settings", {"litellm_key_header_name": "x-proxy-key"})
token = encode_call(CodexRealtimeCall(
call_id="rtc_test", model="gpt-live-1-codex", alias="voice",
owner=hashlib.sha256(b"Bearer owner").hexdigest(), expires_at=time.time() + 300,
))
authenticate = AsyncMock()
monkeypatch.setattr(auth_module, "user_api_key_auth", authenticate)
send = AsyncMock()
websocket = WebSocket({
"type": "websocket", "scheme": "ws", "server": ("localhost", 4000),
"path": "/v1/live/" + token, "path_params": {"call_id": token}, "query_string": b"",
"headers": [(b"authorization", b"Bearer owner")]
+ ([(b"x-proxy-key", custom_value)] if custom_value is not None else []),
}, AsyncMock(), send)
with pytest.raises(HTTPException) as error:
await auth_module.user_api_key_auth_websocket(websocket)
assert error.value.status_code == 403
authenticate.assert_not_awaited()
send.assert_awaited_once_with({"type": "websocket.close", "code": 1008, "reason": ""})
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ["/v1/messages", "/messages", "/v1/chat/completions", "/chat/completions", "/v1/responses", "/responses"])
async def test_claude_view_normalizes_before_model_access(monkeypatch, route):

View file

@ -2,11 +2,16 @@
Unit Tests for the max parallel request limiter v1 for the proxy
"""
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.caching.caching import DualCache
from litellm.caching.redis_cache import RedisCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
@ -14,6 +19,100 @@ from litellm.proxy.utils import InternalUsageCache, hash_token
from litellm.types.utils import EmbeddingResponse, TextCompletionResponse, Usage
@pytest.mark.asyncio
async def test_realtime_release_preserves_newer_local_admission_while_redis_finishes():
started, finish = asyncio.Event(), asyncio.Event()
async def release(**kwargs):
started.set()
await finish.wait()
remote = MagicMock(spec=RedisCache)
remote.async_register_script.return_value = AsyncMock(side_effect=release)
cache = DualCache(redis_cache=remote)
handler = _PROXY_MaxParallelRequestsHandler(InternalUsageCache(cache))
await cache.async_set_cache("key", {"current_requests": 1, "current_rpm": 1, "current_tpm": 7}, local_only=True)
task = asyncio.create_task(handler._release_realtime_counter("key"))
await started.wait()
next_admission = {"current_requests": 1, "current_rpm": 2, "current_tpm": 7}
await cache.async_set_cache("key", next_admission, local_only=True)
finish.set()
await task
assert await cache.async_get_cache("key", local_only=True) == next_admission
@pytest.mark.asyncio
@pytest.mark.parametrize("reject_team", [False, True])
async def test_realtime_attachment_releases_only_acquired_legacy_slots(reject_team):
cache = DualCache()
handler = _PROXY_MaxParallelRequestsHandler(InternalUsageCache(cache))
auth = UserAPIKeyAuth(
api_key="attachment-key",
user_id="attachment-user",
team_id="attachment-team",
team_rpm_limit=0 if reject_team else 100,
max_parallel_requests=1,
end_user_id="attachment-end-user",
metadata={"model_rpm_limit": {"test-model": 100}},
)
data = {"model": "test-model", "metadata": {"global_max_parallel_requests": 10}}
minute = datetime.now().strftime("%Y-%m-%d-%H-%M")
team_key = f"attachment-team::{minute}::request_count"
await cache.async_set_cache(team_key, {"current_requests": 3, "current_tpm": 7, "current_rpm": 4})
handler.begin_realtime_attachment(data)
if reject_team:
with pytest.raises(ProxyRateLimitError, match="Rate Limit Handler"):
await handler.async_pre_call_hook(auth, cache, data, "_arealtime")
else:
await handler.async_pre_call_hook(auth, cache, data, "_arealtime")
await handler.async_release_realtime_attachment(data, auth)
await handler.async_release_realtime_attachment(data, auth)
assert await cache.async_get_cache("global_max_parallel_requests") == 0
assert await cache.async_get_cache(f"attachment-key::{minute}::request_count") == {
"current_requests": 0,
"current_tpm": 0,
"current_rpm": 1,
}
assert await cache.async_get_cache(f"attachment-user::{minute}::request_count") == {
"current_requests": 0,
"current_tpm": 0,
"current_rpm": 1,
}
assert await cache.async_get_cache(team_key) == {
"current_requests": 3,
"current_tpm": 7,
"current_rpm": 4 if reject_team else 5,
}
assert await cache.async_get_cache(f"attachment-key::test-model::{minute}::request_count") == {
"current_requests": 0,
"current_tpm": 0,
"current_rpm": 1,
}
end_user = await cache.async_get_cache(f"attachment-end-user::{minute}::request_count")
assert end_user == (None if reject_team else {"current_requests": 0, "current_tpm": 0, "current_rpm": 1})
if not reject_team:
handler.begin_realtime_attachment(data)
await handler.async_pre_call_hook(auth, cache, data, "_arealtime")
await handler.async_release_realtime_attachment(data, auth)
@pytest.mark.asyncio
async def test_realtime_attachment_rejected_before_acquisition_preserves_other_slot():
cache = DualCache()
handler = _PROXY_MaxParallelRequestsHandler(InternalUsageCache(cache))
auth = UserAPIKeyAuth(api_key="busy-key", max_parallel_requests=1)
minute = datetime.now().strftime("%Y-%m-%d-%H-%M")
key = f"busy-key::{minute}::request_count"
current = {"current_requests": 1, "current_tpm": 13, "current_rpm": 2}
await cache.async_set_cache(key, current)
data = {"model": "test-model"}
handler.begin_realtime_attachment(data)
with pytest.raises(ProxyRateLimitError, match="Rate Limit Handler"):
await handler.async_pre_call_hook(auth, cache, data, "_arealtime")
await handler.async_release_realtime_attachment(data, auth)
assert await cache.async_get_cache(key) == current
@pytest.mark.parametrize(
"response_obj",
[
@ -39,9 +138,7 @@ async def test_async_log_success_event_counts_non_chat_response_tokens(response_
team_id = "litellm-team"
end_user_id = "customer-1"
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(
internal_usage_cache=InternalUsageCache(DualCache())
)
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(DualCache()))
current_date = datetime.now().strftime("%Y-%m-%d")
current_hour = datetime.now().strftime("%H")
@ -80,7 +177,4 @@ async def test_async_log_success_event_counts_non_chat_response_tokens(response_
key=f"{scope_id}::{precise_minute}::request_count",
litellm_parent_otel_span=None,
)
assert current["current_tpm"] == 50, (
f"expected 50 tokens counted for {scope_id}, "
f"got {current['current_tpm']}"
)
assert current["current_tpm"] == 50, f"expected 50 tokens counted for {scope_id}, got {current['current_tpm']}"

View file

@ -0,0 +1,85 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
from litellm.proxy.hooks.realtime_call_lease import RealtimeCallLease
@pytest.mark.asyncio
async def test_failed_renewal_signals_owner_and_close_releases_once():
renew = AsyncMock(side_effect=[True, False, True])
release = AsyncMock()
lease = RealtimeCallLease(renew=renew, release=release, interval=0.001)
lease.start()
await asyncio.wait_for(lease.wait_failed(), timeout=1)
assert renew.await_count == 2
assert not await lease.renew()
assert renew.await_count == 2
await asyncio.gather(lease.close(), lease.close())
assert release.await_count == 1
@pytest.mark.asyncio
async def test_renewal_exception_and_close_before_start():
release = AsyncMock()
lease = RealtimeCallLease(renew=AsyncMock(side_effect=RuntimeError("backend")), release=release, interval=0.001)
lease.start()
await asyncio.wait_for(lease.wait_failed(), timeout=1)
await lease.close()
assert release.await_count == 1
unused = RealtimeCallLease(renew=AsyncMock(), release=release)
await unused.close()
assert release.await_count == 2
@pytest.mark.asyncio
async def test_renewal_timeout_signals_failure_without_start():
lease = RealtimeCallLease(renew=asyncio.Event().wait, release=AsyncMock(), renewal_timeout=0.001)
assert not await lease.renew()
await asyncio.wait_for(lease.wait_failed(), timeout=1)
await lease.close()
@pytest.mark.asyncio
async def test_cancelled_close_still_releases_exactly_once():
entered = asyncio.Event()
finish = asyncio.Event()
async def release():
entered.set()
await finish.wait()
cleanup = AsyncMock(side_effect=release)
lease = RealtimeCallLease(renew=AsyncMock(return_value=True), release=cleanup)
lease.start()
closing = asyncio.create_task(lease.close())
await asyncio.wait_for(entered.wait(), timeout=1)
closing.cancel()
with pytest.raises(asyncio.CancelledError):
await closing
finish.set()
await lease.close()
assert cleanup.await_count == 1
@pytest.mark.asyncio
async def test_concurrent_renewal_cannot_restore_a_failed_lease():
pending = asyncio.Event()
entered = asyncio.Event()
async def delayed_success():
entered.set()
await pending.wait()
return True
renew = AsyncMock(side_effect=delayed_success)
lease = RealtimeCallLease(renew=renew, release=AsyncMock())
first = asyncio.create_task(lease.renew())
await asyncio.wait_for(entered.wait(), timeout=1)
renew.side_effect = None
renew.return_value = False
assert not await lease.renew()
pending.set()
assert not await first
await lease.close()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,697 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.realtime_streaming import REALTIME_SESSION_SUCCESS_LOGGED_KEY
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.realtime_endpoints.call_supervision import CallSupervisor, CallSupervisors
class Socket:
def __init__(self):
self.messages = asyncio.Queue()
self.closed = False
def __aiter__(self):
return self
async def __anext__(self):
message = await self.messages.get()
if message is None:
raise StopAsyncIteration
if isinstance(message, Exception):
raise message
return json.dumps(message)
async def close(self):
self.closed = True
@pytest.mark.asyncio
@pytest.mark.parametrize("lease_lost", [False, True])
async def test_supervisor_holds_call_lease_until_terminal_accounting(lease_lost):
from litellm.proxy.hooks.realtime_call_lease import RealtimeCallLease
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
lost = asyncio.Event()
lease = MagicMock(spec=RealtimeCallLease)
lease.wait_failed = lost.wait
async def release():
assert socket.closed
assert sink.logs == 1
lease.close = AsyncMock(side_effect=release)
async def close():
await socket.messages.put({"type": "session.closed", "usage": {"audio_duration_ms": 1000}})
terminate = AsyncMock(side_effect=close)
supervisor = CallSupervisor(socket, sink, logger, UserAPIKeyAuth(), terminate, lease=lease)
await socket.messages.put({"type": "session.started"})
await supervisor.start()
lease.close.assert_not_awaited()
if lease_lost:
lost.set()
else:
await close()
await asyncio.wait_for(supervisor.wait(), 1)
assert terminate.await_count == int(lease_lost)
lease.close.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("stalled_step", ["close", "drain"])
async def test_live_initial_close_reserves_time_for_independent_hangup(stalled_step):
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
close_cancelled = asyncio.Event()
async def close():
if stalled_step == "close":
try:
await asyncio.Event().wait()
finally:
close_cancelled.set()
async def force_close():
await socket.messages.put({"type": "session.closed", "usage": {"audio_duration_ms": 1000}})
force = AsyncMock(side_effect=force_close)
sink = Sink(logger)
supervisor = CallSupervisor(
socket,
sink,
logger,
UserAPIKeyAuth(),
close,
force_close_call=force,
drain_timeout=1,
termination_timeout=0.08,
)
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await asyncio.wait_for(supervisor.close(), timeout=0.5)
force.assert_awaited_once()
assert close_cancelled.is_set() == (stalled_step == "close")
assert any(event["type"] == "session.closed" for event in sink.events)
assert not logger.model_call_details.get("realtime_usage_incomplete")
assert sink.logs == 1
assert socket.closed
@pytest.mark.asyncio
@pytest.mark.parametrize("fallback", ["terminal", "no_terminal", "timeout"])
async def test_live_unacknowledged_close_uses_bounded_independent_hangup(monkeypatch, fallback):
from litellm.proxy.realtime_endpoints import call_supervision
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
invalidate = AsyncMock()
monkeypatch.setattr(call_supervision, "invalidate_budget_reservation_counters", invalidate)
async def force_close():
if fallback == "terminal":
await socket.messages.put({"type": "session.closed", "usage": {"audio_duration_ms": 1000}})
elif fallback == "timeout":
await asyncio.Event().wait()
force = AsyncMock(side_effect=force_close)
close = AsyncMock()
supervisor = CallSupervisor(
socket,
sink,
logger,
UserAPIKeyAuth(),
close,
force_close_call=force,
drain_timeout=0.01,
termination_timeout=0.08,
)
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await asyncio.wait_for(supervisor.close(), timeout=0.5)
close.assert_awaited_once()
force.assert_awaited_once()
assert socket.closed
if fallback == "terminal":
invalidate.assert_not_awaited()
assert not logger.model_call_details.get("realtime_usage_incomplete")
else:
invalidate.assert_awaited_once()
assert logger.model_call_details["realtime_usage_incomplete"] is True
@pytest.mark.asyncio
async def test_live_confirmed_terminal_does_not_force_hangup():
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
async def close():
await socket.messages.put({"type": "session.closed"})
force = AsyncMock()
supervisor = CallSupervisor(socket, Sink(logger), logger, UserAPIKeyAuth(), close, force_close_call=force)
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await supervisor.close()
force.assert_not_awaited()
class Sink:
def __init__(self, logger):
self.logger = logger
self.events = []
self.logs = 0
def store_message(self, message):
self.events.append(json.loads(message))
async def log_messages(self, *, wait_for_dispatch=False):
assert wait_for_dispatch
self.logs += 1
self.logger.model_call_details[REALTIME_SESSION_SUCCESS_LOGGED_KEY] = True
@pytest.mark.asyncio
@pytest.mark.parametrize(
"duration,valid", [(0, True), (1000, True), (None, False), (-1, False), (True, False), ("1000", False)]
)
async def test_live_terminal_requires_valid_duration_for_accounting(monkeypatch, duration, valid):
from litellm.proxy.realtime_endpoints import call_supervision
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
invalidate = AsyncMock()
monkeypatch.setattr(call_supervision, "invalidate_budget_reservation_counters", invalidate)
close = AsyncMock()
force = AsyncMock()
supervisor = CallSupervisor(socket, Sink(logger), logger, UserAPIKeyAuth(), close, force_close_call=force)
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await socket.messages.put(
{"type": "session.closed", **({"usage": {"audio_duration_ms": duration}} if duration is not None else {})}
)
await supervisor.wait()
close.assert_not_awaited()
force.assert_not_awaited()
assert socket.closed
assert bool(logger.model_call_details.get("realtime_usage_incomplete")) is not valid
assert invalidate.await_count == (0 if valid else 1)
def fixture(*, ready_timeout=1, lifetime=1):
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
async def hangup():
assert not socket.closed
await socket.messages.put({"type": "session.closed", "usage": {"total_tokens": 42}})
close_call = AsyncMock(side_effect=hangup)
supervisor = CallSupervisor(
socket,
sink,
logger,
UserAPIKeyAuth(),
close_call,
ready_timeout=ready_timeout,
lifetime=lifetime,
drain_timeout=0.05,
)
return socket, sink, close_call, supervisor
@pytest.mark.asyncio
async def test_observer_logs_webrtc_usage_without_client_sideband():
socket, sink, close_call, supervisor = fixture()
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await socket.messages.put({"type": "response.done", "response": {"usage": {"total_tokens": 15}}})
await socket.messages.put({"type": "session.closed", "usage": {"total_tokens": 19}})
await supervisor.wait()
await supervisor.close()
assert sink.logs == 1
assert sink.events[-1]["usage"]["total_tokens"] == 19
assert sink.events[1]["response"]["usage"]["total_tokens"] == 15
assert socket.closed
close_call.assert_not_awaited()
@pytest.mark.asyncio
async def test_early_upstream_eof_rejects_start():
socket, sink, close_call, supervisor = fixture()
await socket.messages.put(None)
with pytest.raises(RuntimeError, match="ended before"):
await supervisor.start()
assert socket.closed
assert sink.logs == 1
close_call.assert_awaited_once()
@pytest.mark.asyncio
async def test_cancelled_start_hangs_up_and_drains_terminal_usage():
socket, sink, close_call, supervisor = fixture()
started = asyncio.create_task(supervisor.start())
await asyncio.sleep(0)
started.cancel()
with pytest.raises(asyncio.CancelledError):
await started
close_call.assert_awaited_once()
assert socket.closed
assert sink.logs == 1
assert sink.events[-1]["usage"]["total_tokens"] == 42
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel_count", [1, 2, 3])
async def test_repeated_start_cancellation_keeps_lease_until_shutdown_finishes(cancel_count):
from litellm.proxy.hooks.realtime_call_lease import RealtimeCallLease
reading = asyncio.Event()
close_entered = asyncio.Event()
allow_close = asyncio.Event()
released = asyncio.Event()
class ObservedSocket(Socket):
async def __anext__(self):
reading.set()
return await super().__anext__()
socket = ObservedSocket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
async def close_call():
close_entered.set()
await allow_close.wait()
await socket.messages.put({"type": "session.closed", "usage": {"audio_duration_ms": 1000}})
async def release():
released.set()
lease = RealtimeCallLease(renew=AsyncMock(return_value=True), release=release)
lease.start()
supervisor = CallSupervisor(
socket, sink, logger, UserAPIKeyAuth(), close_call, lease=lease, ready_timeout=10, termination_timeout=10
)
registry = CallSupervisors()
async def signaling():
transferred = False
try:
await registry.start(supervisor)
transferred = True
finally:
# The signaling endpoint retains lease ownership until registry startup succeeds.
if not transferred:
await lease.close()
started = asyncio.create_task(signaling())
shutdown = None
try:
await asyncio.wait_for(reading.wait(), timeout=1)
started.cancel()
await asyncio.wait_for(close_entered.wait(), timeout=1)
for _ in range(cancel_count - 1):
started.cancel()
done, _ = await asyncio.wait({started}, timeout=0.02)
assert not done
assert not released.is_set()
shutdown = asyncio.create_task(registry.shutdown())
done, _ = await asyncio.wait({started, shutdown}, timeout=0.02)
assert not done
assert not released.is_set()
assert not socket.closed
assert sink.logs == 0
allow_close.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(started, timeout=1)
await asyncio.wait_for(shutdown, timeout=1)
assert socket.closed
assert sink.logs == 1
assert released.is_set()
assert sink.events[-1]["usage"]["audio_duration_ms"] == 1000
finally:
allow_close.set()
await asyncio.wait_for(supervisor.wait(), timeout=1)
await asyncio.gather(started, return_exceptions=True)
if shutdown is not None:
await shutdown
await registry.shutdown()
await lease.close()
@pytest.mark.asyncio
async def test_worker_shutdown_drains_all_calls():
registry = CallSupervisors()
socket, sink, close_call, supervisor = fixture()
await socket.messages.put({"type": "session.created"})
await registry.start(supervisor)
await registry.shutdown()
await registry.shutdown()
close_call.assert_awaited_once()
assert socket.closed
assert sink.logs == 1
assert sink.events[-1]["usage"]["total_tokens"] == 42
@pytest.mark.asyncio
async def test_ready_timeout_hangs_up_before_returning_error():
socket, sink, close_call, supervisor = fixture(ready_timeout=0.01)
with pytest.raises(asyncio.TimeoutError):
await supervisor.start()
close_call.assert_awaited_once()
assert socket.closed
assert sink.logs == 1
@pytest.mark.asyncio
async def test_lifetime_limit_closes_call_and_collects_final_usage():
socket, sink, close_call, supervisor = fixture(lifetime=0.01)
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await supervisor.wait()
close_call.assert_awaited_once()
assert socket.closed
assert sink.events[-1]["usage"]["total_tokens"] == 42
@pytest.mark.asyncio
async def test_socket_eof_after_ready_still_hangs_up_provider_call(monkeypatch):
from litellm.proxy.realtime_endpoints import call_supervision
invalidate = AsyncMock()
release = AsyncMock()
monkeypatch.setattr(call_supervision, "invalidate_budget_reservation_counters", invalidate)
monkeypatch.setattr(call_supervision, "release_or_invalidate_budget_reservation", release)
socket, sink, close_call, supervisor = fixture()
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await socket.messages.put(None)
await supervisor.wait()
close_call.assert_awaited_once()
assert socket.closed
assert sink.logs == 1
assert sink.logger.model_call_details["realtime_usage_incomplete"] is True
invalidate.assert_awaited_once()
release.assert_not_awaited()
@pytest.mark.asyncio
async def test_observer_error_rejects_start(caplog):
socket, sink, close_call, supervisor = fixture()
await socket.messages.put(RuntimeError("private-provider-credential"))
with pytest.raises(RuntimeError, match="ended before"):
await supervisor.start()
assert socket.closed
close_call.assert_awaited_once()
assert "private-provider-credential" not in caplog.text
@pytest.mark.asyncio
async def test_failed_logging_invalidates_reservation_without_zeroing_spend(monkeypatch):
from litellm.proxy.realtime_endpoints import call_supervision
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = MagicMock()
sink.log_messages = AsyncMock(side_effect=RuntimeError("logging unavailable"))
release = AsyncMock()
invalidate = AsyncMock()
monkeypatch.setattr(call_supervision, "release_or_invalidate_budget_reservation", release)
monkeypatch.setattr(call_supervision, "invalidate_budget_reservation_counters", invalidate)
supervisor = CallSupervisor(socket, sink, logger, UserAPIKeyAuth(), AsyncMock())
await socket.messages.put({"type": "session.started"})
await supervisor.start()
await socket.messages.put({"type": "session.closed"})
with pytest.raises(RuntimeError, match="logging unavailable"):
await supervisor.wait()
release.assert_not_awaited()
invalidate.assert_awaited_once_with(budget_reservation=None)
assert logger.model_call_details["realtime_accounting_incomplete"] is True
assert socket.closed
sink.log_messages.assert_awaited_once_with(wait_for_dispatch=True)
@pytest.mark.asyncio
async def test_start_rejects_terminal_session_while_accounting_is_pending():
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
dispatch_started = asyncio.Event()
allow_dispatch = asyncio.Event()
async def log_messages(*, wait_for_dispatch=False):
dispatch_started.set()
await allow_dispatch.wait()
logger.model_call_details[REALTIME_SESSION_SUCCESS_LOGGED_KEY] = True
sink = MagicMock()
sink.log_messages = AsyncMock(side_effect=log_messages)
supervisor = CallSupervisor(socket, sink, logger, UserAPIKeyAuth(), AsyncMock())
await socket.messages.put({"type": "session.created"})
await socket.messages.put({"type": "session.closed"})
startup = asyncio.create_task(supervisor.start())
try:
await asyncio.wait_for(dispatch_started.wait(), timeout=1)
finally:
allow_dispatch.set()
with pytest.raises(RuntimeError, match="ended before"):
await asyncio.wait_for(startup, timeout=1)
assert socket.closed
sink.log_messages.assert_awaited_once_with(wait_for_dispatch=True)
@pytest.mark.asyncio
async def test_shutdown_bounds_accounting_and_invalidates_partial_dispatch(monkeypatch):
from litellm.proxy.realtime_endpoints import call_supervision
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
dispatch_cancelled = asyncio.Event()
invalidate = AsyncMock()
release = AsyncMock()
monkeypatch.setattr(call_supervision, "invalidate_budget_reservation_counters", invalidate)
monkeypatch.setattr(call_supervision, "release_or_invalidate_budget_reservation", release)
async def log_messages(*, wait_for_dispatch=False):
try:
await asyncio.Event().wait()
finally:
dispatch_cancelled.set()
async def hangup():
await socket.messages.put({"type": "session.closed", "usage": {"total_tokens": 42}})
sink = MagicMock()
sink.log_messages = AsyncMock(side_effect=log_messages)
supervisor = CallSupervisor(socket, sink, logger, UserAPIKeyAuth(), hangup, logging_timeout=0.01)
registry = CallSupervisors()
await socket.messages.put({"type": "session.created"})
await registry.start(supervisor)
await asyncio.wait_for(registry.shutdown(), timeout=1)
assert dispatch_cancelled.is_set()
assert socket.closed
assert logger.model_call_details["realtime_accounting_incomplete"] is True
assert not logger.model_call_details.get(REALTIME_SESSION_SUCCESS_LOGGED_KEY)
invalidate.assert_awaited_once_with(budget_reservation=None)
release.assert_not_awaited()
sink.log_messages.assert_awaited_once_with(wait_for_dispatch=True)
@pytest.mark.asyncio
async def test_shutdown_waits_for_usage_dispatch_completion():
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
dispatch_started = asyncio.Event()
dispatch_complete = asyncio.Event()
dispatch_finished = asyncio.Event()
async def log_messages(*, wait_for_dispatch=False):
assert wait_for_dispatch
dispatch_started.set()
await dispatch_complete.wait()
logger.model_call_details[REALTIME_SESSION_SUCCESS_LOGGED_KEY] = True
dispatch_finished.set()
async def hangup():
await socket.messages.put({"type": "session.closed", "usage": {"total_tokens": 42}})
sink = MagicMock()
sink.log_messages = AsyncMock(side_effect=log_messages)
registry = CallSupervisors()
supervisor = CallSupervisor(socket, sink, logger, UserAPIKeyAuth(), hangup)
await socket.messages.put({"type": "session.created"})
await registry.start(supervisor)
shutdown = asyncio.create_task(registry.shutdown())
try:
await asyncio.wait_for(dispatch_started.wait(), timeout=1)
assert not shutdown.done()
assert not dispatch_finished.is_set()
finally:
dispatch_complete.set()
await asyncio.wait_for(shutdown, timeout=1)
assert dispatch_finished.is_set()
sink.log_messages.assert_awaited_once_with(wait_for_dispatch=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("terminal_usage_required", [True, False])
async def test_confirmed_hangup_without_terminal_usage_matches_protocol(terminal_usage_required):
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
close_call = AsyncMock()
supervisor = CallSupervisor(
socket,
sink,
logger,
UserAPIKeyAuth(),
close_call,
terminal_usage_required=terminal_usage_required,
drain_timeout=0.01,
)
await socket.messages.put({"type": "session.created"})
await supervisor.start()
await socket.messages.put({"type": "response.done", "response": {"usage": {"total_tokens": 17}}})
await supervisor.close()
assert bool(logger.model_call_details.get("realtime_usage_incomplete")) == terminal_usage_required
assert sink.events[-1]["response"]["usage"]["total_tokens"] == 17
assert sink.logs == 1
assert socket.closed
close_call.assert_awaited_once()
@pytest.mark.asyncio
async def test_shutdown_allows_hangup_longer_than_usage_drain_timeout():
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
hangup_started = asyncio.Event()
allow_hangup = asyncio.Event()
hangup_finished = asyncio.Event()
async def hangup():
hangup_started.set()
await allow_hangup.wait()
await socket.messages.put({"type": "session.closed", "usage": {"total_tokens": 42}})
hangup_finished.set()
supervisor = CallSupervisor(
socket,
sink,
logger,
UserAPIKeyAuth(),
hangup,
drain_timeout=0.01,
termination_timeout=1,
terminal_usage_required=False,
)
registry = CallSupervisors()
await socket.messages.put({"type": "session.created"})
await registry.start(supervisor)
shutdown = asyncio.create_task(registry.shutdown())
try:
await asyncio.wait_for(hangup_started.wait(), timeout=1)
await asyncio.sleep(0.04)
assert not shutdown.done()
assert not socket.closed
assert not hangup_finished.is_set()
finally:
allow_hangup.set()
await asyncio.wait_for(shutdown, timeout=1)
assert hangup_finished.is_set()
assert socket.closed
assert sink.logs == 1
assert sink.events[-1]["usage"]["total_tokens"] == 42
assert not logger.model_call_details.get("realtime_usage_incomplete")
@pytest.mark.asyncio
async def test_termination_timeout_cancels_hangup_and_finishes_cleanup():
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
hangup_cancelled = asyncio.Event()
async def hangup():
try:
await asyncio.Event().wait()
finally:
hangup_cancelled.set()
supervisor = CallSupervisor(
socket,
sink,
logger,
UserAPIKeyAuth(),
hangup,
drain_timeout=0.01,
termination_timeout=0.02,
terminal_usage_required=False,
)
await socket.messages.put({"type": "session.created"})
await supervisor.start()
await asyncio.wait_for(supervisor.close(), timeout=1)
assert hangup_cancelled.is_set()
assert socket.closed
assert sink.logs == 1
assert logger.model_call_details["realtime_usage_incomplete"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize("closure", ["eof", "normal_close", "error"])
@pytest.mark.parametrize("hangup_succeeds", [True, False])
async def test_ga_observer_disconnect_requires_confirmed_hangup(closure, hangup_succeeds):
from websockets.exceptions import ConnectionClosedOK
from websockets.frames import Close
socket = Socket()
logger = MagicMock(spec=Logging)
logger.model_call_details = {}
sink = Sink(logger)
close_call = AsyncMock(side_effect=None if hangup_succeeds else RuntimeError("unconfirmed hangup"))
supervisor = CallSupervisor(
socket,
sink,
logger,
UserAPIKeyAuth(),
close_call,
terminal_usage_required=False,
drain_timeout=0.01,
)
await socket.messages.put({"type": "session.created"})
await supervisor.start()
await socket.messages.put(
None
if closure == "eof"
else ConnectionClosedOK(Close(1000, ""), Close(1000, ""), True)
if closure == "normal_close"
else RuntimeError("observer failed")
)
await supervisor.wait()
close_call.assert_awaited_once()
assert bool(logger.model_call_details.get("realtime_usage_incomplete")) == (not hangup_succeeds)
assert sink.logs == 1
assert socket.closed

View file

@ -1,6 +1,7 @@
import datetime as real_datetime
import smtplib
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
@ -9,15 +10,10 @@ from litellm.caching.caching import DualCache
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import ProxyErrorTypes, UserAPIKeyAuth
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.proxy.utils import PrismaClient, ProxyLogging, get_custom_url, join_paths
from litellm.types.guardrails import GuardrailEventHooks
from unittest.mock import MagicMock, patch
from litellm.proxy.utils import get_custom_url, join_paths
def test_get_custom_url(monkeypatch):
monkeypatch.setenv("SERVER_ROOT_PATH", "/litellm")
custom_url = get_custom_url(request_base_url="http://0.0.0.0:4000", route="ui/")
@ -2110,9 +2106,7 @@ def test_create_model_info_response_resolves_alias_to_deployment_model():
]
)
response = create_model_info_response(
model_id="bedrock-claude-opus-5", provider="openai", llm_router=router
)
response = create_model_info_response(model_id="bedrock-claude-opus-5", provider="openai", llm_router=router)
finally:
litellm.model_cost.clear()
litellm.model_cost.update(saved_model_cost)
@ -2141,9 +2135,7 @@ def test_create_model_info_response_keeps_exact_alias_over_generalized_deploymen
]
)
response = create_model_info_response(
model_id="claude-opus-5", provider="openai", llm_router=router
)
response = create_model_info_response(model_id="claude-opus-5", provider="openai", llm_router=router)
finally:
litellm.model_cost.clear()
litellm.model_cost.update(saved_model_cost)
@ -2167,9 +2159,7 @@ def test_create_model_info_response_falls_back_to_alias_for_opaque_deployment_na
]
)
response = create_model_info_response(
model_id="gpt-4o", provider="openai", llm_router=router
)
response = create_model_info_response(model_id="gpt-4o", provider="openai", llm_router=router)
finally:
litellm.model_cost.clear()
litellm.model_cost.update(saved_model_cost)
@ -2194,9 +2184,7 @@ def test_create_model_info_response_resolves_mode_through_deployment_model():
]
)
response = create_model_info_response(
model_id="my-embeddings", provider="openai", llm_router=router
)
response = create_model_info_response(model_id="my-embeddings", provider="openai", llm_router=router)
finally:
litellm.model_cost.clear()
litellm.model_cost.update(saved_model_cost)
@ -2274,7 +2262,9 @@ async def test_post_call_failure_hook_redacts_traceback_before_callbacks(monkeyp
with patch.object(proxy_logging_obj, "update_request_status", new=AsyncMock()):
await proxy_logging_obj.post_call_failure_hook(
request_data={"metadata": {}},
original_exception=HTTPException(status_code=400, detail="Upstream passthrough request failed with status 400"),
original_exception=HTTPException(
status_code=400, detail="Upstream passthrough request failed with status 400"
),
user_api_key_dict=UserAPIKeyAuth(),
traceback_str=upstream_traceback,
)
@ -2284,6 +2274,130 @@ async def test_post_call_failure_hook_redacts_traceback_before_callbacks(monkeyp
assert "REDACTED" in recorder.received_traceback
@pytest.mark.asyncio
@pytest.mark.parametrize("limiter_version", [1, 3])
@pytest.mark.parametrize("limit", ["rpm_limit", "max_parallel_requests"])
async def test_internal_realtime_observer_preserves_quota_and_custom_hooks(monkeypatch, limiter_version, limit):
import asyncio
from datetime import datetime
from litellm.caching.caching import DualCache
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.hooks.parallel_request_limiter import _PROXY_MaxParallelRequestsHandler
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_PROXY_MaxParallelRequestsHandler_v3,
_request_stash,
get_request_stash,
)
from litellm.proxy.utils import InternalUsageCache, ProxyLogging
observed = []
class Hook(CustomLogger):
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
observed.append(call_type)
return {**data, "extra_headers": {"x-hook": "required"}}
cache = DualCache()
limiter_type = _PROXY_MaxParallelRequestsHandler if limiter_version == 1 else _PROXY_MaxParallelRequestsHandler_v3
limiter = limiter_type(InternalUsageCache(dual_cache=cache))
proxy = ProxyLogging(UserApiKeyCache())
monkeypatch.setattr(litellm, "callbacks", [limiter, Hook()])
token = _request_stash.set(None)
try:
auth = UserAPIKeyAuth(api_key="observer-quota-test", **{limit: 1})
await proxy.pre_call_hook(
auth, {"model": "voice", "litellm_call_id": "signaling", "metadata": {}}, "arealtime_calls"
)
await asyncio.sleep(0)
initial_stash = get_request_stash()
result = await proxy.pre_call_hook(
auth,
{"model": "voice", "litellm_call_id": "observer", "metadata": {}},
"_arealtime",
internal_realtime_observer=True,
)
assert result["extra_headers"] == {"x-hook": "required"}
assert observed == ["arealtime_calls", "_arealtime"]
if limiter_version == 3:
assert get_request_stash() is initial_stash
assert initial_stash.owner_litellm_call_id == "signaling"
if limit == "max_parallel_requests":
await limiter.async_log_success_event(
{
"litellm_call_id": "signaling",
"litellm_params": {"metadata": {"user_api_key": auth.api_key, "user_api_key_model_max_budget": {}}},
},
litellm.ModelResponse(usage=litellm.Usage(total_tokens=0)),
datetime.now(),
datetime.now(),
)
if limiter_version == 3:
assert initial_stash.parallel_slot is None
await proxy.pre_call_hook(
auth, {"model": "voice", "litellm_call_id": "next", "metadata": {}}, "arealtime_calls"
)
if limiter_version == 1 and limit == "max_parallel_requests":
from litellm.proxy._types import InternalRequestOrigin
await asyncio.sleep(0)
observer_kwargs = {
"internal_request_origin": InternalRequestOrigin.REALTIME_OBSERVER,
"litellm_call_id": "observer",
"litellm_params": {"metadata": {"user_api_key": auth.api_key, "user_api_key_model_max_budget": {}}},
}
await limiter.async_log_success_event(
observer_kwargs,
litellm.ModelResponse(usage=litellm.Usage(total_tokens=17)),
datetime.now(),
datetime.now(),
)
current = await limiter.internal_usage_cache.async_get_cache(
key=f"{auth.api_key}::{datetime.now():%Y-%m-%d-%H-%M}::request_count", litellm_parent_otel_span=None
)
assert current["current_requests"] == 1
assert current["current_tpm"] == 17
with pytest.raises(HTTPException) as error:
await proxy.pre_call_hook(
auth,
{"model": "voice", "litellm_call_id": "forged", "metadata": {}, "internal_realtime_observer": True},
"_arealtime",
)
assert error.value.status_code == 429
finally:
_request_stash.reset(token)
@pytest.mark.asyncio
@pytest.mark.parametrize("scope", ["key", "user", "team", "end_user"])
async def test_internal_observer_missing_legacy_counter_only_adds_usage(scope):
from datetime import datetime
from litellm.proxy._types import InternalRequestOrigin
from litellm.proxy.hooks.parallel_request_limiter import _PROXY_MaxParallelRequestsHandler
from litellm.proxy.utils import InternalUsageCache
limiter = _PROXY_MaxParallelRequestsHandler(InternalUsageCache(dual_cache=DualCache()))
metadata = {"user_api_key": "expired-key", "user_api_key_model_max_budget": {}}
if scope in ("user", "team"):
metadata[f"user_api_key_{scope}_id"] = "expired-scope"
kwargs = {
"internal_request_origin": InternalRequestOrigin.REALTIME_OBSERVER,
"litellm_params": {"metadata": metadata},
**({"user": "expired-scope"} if scope == "end_user" else {}),
}
await limiter.async_log_success_event(
kwargs, litellm.ModelResponse(usage=litellm.Usage(total_tokens=23)), datetime.now(), datetime.now()
)
identity = "expired-key" if scope == "key" else "expired-scope"
current = await limiter.internal_usage_cache.async_get_cache(
key=f"{identity}::{datetime.now():%Y-%m-%d-%H-%M}::request_count", litellm_parent_otel_span=None
)
assert current == {"current_requests": 0, "current_tpm": 23, "current_rpm": 0}
class TestPrismaClientTokenAuthBehindThePool:
"""Behind the in-container pool the supervisor renews the writer's database
token and hands the workers a loopback URL with a static password, so the

View file

@ -1,4 +1,5 @@
import asyncio
import json
import time
from types import TracebackType
from typing import Final
@ -17,6 +18,42 @@ class FakeLogging:
pass
@pytest.mark.parametrize("provider", [litellm.LlmProviders.XAI, litellm.LlmProviders.OPENAI, litellm.LlmProviders.GEMINI])
def test_realtime_handler_factory_does_not_read_headers_without_a_handler(provider):
from litellm.types.router import GenericLiteLLMParams
read_headers = MagicMock(side_effect=AssertionError("Headers must not be read"))
assert realtime_main.ProviderConfigManager.get_provider_realtime_handler(
provider, GenericLiteLLMParams(), read_headers
) is None
read_headers.assert_not_called()
def test_realtime_handler_factory_passes_actual_chatgpt_headers(tmp_path, monkeypatch):
from litellm.llms.chatgpt.realtime import ChatGPTRealtime
from litellm.types.router import GenericLiteLLMParams
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.setenv("CHATGPT_AUTH_FILE", "auth.json")
(tmp_path / "auth.json").write_text(
json.dumps({"access_token": "factory-test-token", "account_id": "factory-account", "expires_at": time.time() + 3600})
)
params = GenericLiteLLMParams(litellm_session_id="factory-session")
headers = {"openai-alpha": "quicksilver=v2"}
extra_headers = {"x-gateway-route": "required"}
read_headers = MagicMock(return_value=headers)
result = realtime_main.ProviderConfigManager.get_provider_realtime_handler(
litellm.LlmProviders.CHATGPT, params, read_headers, extra_headers
)
assert isinstance(result, ChatGPTRealtime)
read_headers.assert_called_once_with()
outgoing_headers = result._get_additional_headers("unused")
assert outgoing_headers["openai-alpha"] == headers["openai-alpha"]
assert outgoing_headers["x-gateway-route"] == extra_headers["x-gateway-route"]
assert outgoing_headers["session_id"] == "factory-session"
assert outgoing_headers["Authorization"] == "Bearer factory-test-token"
def test_resolves_top_level_session_model():
resolved = _with_resolved_session_model({"model": "alias/gpt-realtime"}, "gpt-realtime")
assert resolved == {"model": "gpt-realtime"}
@ -404,3 +441,29 @@ async def test_arealtime_azure_env_beta_protocol_wins_over_a_ga_client(monkeypat
assert await _azure_backend_url_dialed_for(_GA_CLIENT) == (
"wss://my-endpoint.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=gpt-realtime"
)
@pytest.mark.parametrize("is_call", [False, True])
@pytest.mark.parametrize("provider", ["chatgpt", "openai", "azure"])
def test_realtime_http_provider_controls_dynamic_base_precedence(provider, is_call, monkeypatch):
from litellm.types.router import GenericLiteLLMParams
monkeypatch.delenv("CHATGPT_API_BASE", raising=False)
monkeypatch.delenv("OPENAI_CHATGPT_API_BASE", raising=False)
config, base, key = realtime_main._get_realtime_http_provider_config(
custom_llm_provider=provider,
dynamic_api_base="https://dynamic.example/v1",
dynamic_api_key="dynamic-key",
litellm_params=GenericLiteLLMParams(api_base="https://configured.example/v1"),
is_call=is_call,
)
expected_base = "https://configured.example/v1" if provider == "chatgpt" else "https://dynamic.example/v1"
assert base == expected_base
assert key == ("chatgpt-oauth" if provider == "chatgpt" else "dynamic-key")
assert config is not None
if provider == "chatgpt":
assert config.get_realtime_calls_url(base, "gpt-realtime-1.5") == expected_base + "/realtime/calls"
else:
assert config.get_realtime_calls_extra_headers({"x-gateway-route": "required"}) == {
"x-gateway-route": "required"
}

View file

@ -4768,3 +4768,102 @@ def test_collect_and_combine_realtime_usage_stores_partitioned_text_tokens() ->
assert combined.completion_tokens_details.reasoning_tokens == 95
assert combined.completion_tokens_details.text_tokens == 38
assert combined.completion_tokens_details.audio_tokens == 0
def _live_terminal_event(duration=4000):
return {"type": "session.closed", "usage": {"audio_duration_ms": duration, "backend_model_usage": []}}
@pytest.mark.parametrize("rate,expected", [(0.025, 0.1), (0, 0), (None, 0)])
def test_live_terminal_duration_uses_configured_second_price(monkeypatch, rate, expected):
monkeypatch.setitem(
litellm.model_cost,
"live-priced-test",
{"litellm_provider": "chatgpt", "mode": "realtime", "input_cost_per_second": rate},
)
assert handle_realtime_stream_cost_calculation(
[_live_terminal_event()], Usage(), "chatgpt", "live-priced-test"
) == pytest.approx(expected)
def test_live_terminal_duration_honors_deployment_override(monkeypatch):
monkeypatch.setitem(
litellm.model_cost,
"live-deployment-test",
{"litellm_provider": "chatgpt", "mode": "realtime", "input_cost_per_second": 0.025},
)
result = RealtimeAPITokenUsageProcessor.create_logging_realtime_object(Usage(), [_live_terminal_event()])
assert completion_cost(
completion_response=result,
model="gpt-live-1",
custom_llm_provider="chatgpt",
call_type="_arealtime",
custom_pricing=True,
router_model_id="live-deployment-test",
) == pytest.approx(0.1)
@pytest.mark.parametrize("duration", [-1, True, "4000", float("inf"), float("nan"), None])
def test_live_terminal_invalid_duration_does_not_create_spend(monkeypatch, duration):
monkeypatch.setitem(
litellm.model_cost,
"live-priced-test",
{"litellm_provider": "chatgpt", "mode": "realtime", "input_cost_per_second": 0.025},
)
assert (
handle_realtime_stream_cost_calculation(
[_live_terminal_event(duration)], Usage(), "chatgpt", "live-priced-test"
)
== 0
)
def test_live_terminal_is_not_counted_twice(monkeypatch):
monkeypatch.setitem(
litellm.model_cost,
"live-priced-test",
{"litellm_provider": "chatgpt", "mode": "realtime", "input_cost_per_second": 0.025},
)
assert handle_realtime_stream_cost_calculation(
[_live_terminal_event(), _live_terminal_event()], Usage(), "chatgpt", "live-priced-test"
) == pytest.approx(0.1)
@pytest.mark.parametrize("with_tokens", [False, True])
@pytest.mark.parametrize("terminal_count", [1, 2])
@pytest.mark.parametrize("duration_priced", [False, True])
def test_live_terminal_with_response_done_preserves_configured_billing(
monkeypatch, with_tokens, terminal_count, duration_priced
):
monkeypatch.setitem(
litellm.model_cost,
"realtime-deployment-test",
{
"litellm_provider": "chatgpt",
"mode": "realtime",
**(
{"input_cost_per_second": 0.025}
if duration_priced
else {"input_cost_per_token": 0.001, "output_cost_per_token": 0.002}
),
},
)
events = [
{
"type": "response.done",
"response": {
"usage": ({"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} if with_tokens else {})
},
},
*(_live_terminal_event() for _ in range(terminal_count)),
]
usage = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(events)
result = RealtimeAPITokenUsageProcessor.create_logging_realtime_object(usage, events)
assert completion_cost(
completion_response=result,
model="gpt-live-1-codex" if duration_priced else "gpt-realtime-1.5",
custom_llm_provider="chatgpt",
call_type="_arealtime",
custom_pricing=True,
router_model_id="realtime-deployment-test",
) == pytest.approx(0.1 if duration_priced else (0.02 if with_tokens else 0))

View file

@ -1153,6 +1153,8 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"/v1/messages",
"/v1/images/generations",
"/v1/realtime",
"/v1/realtime/calls",
"/v1/live",
"/v1/realtime/transcription_sessions",
"/v1/images/variations",
"/v1/images/edits",

View file

@ -8320,6 +8320,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/live": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* WebSocket: realtime_websocket_endpoint
* @description WebSocket connection endpoint
*/
get: operations["websocket_realtime_websocket_endpoint_get_4"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/login": {
parameters: {
query?: never;
@ -18340,6 +18360,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/live": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* WebSocket: realtime_websocket_endpoint
* @description WebSocket connection endpoint
*/
get: operations["websocket_realtime_websocket_endpoint_get_5"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/live/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* WebSocket: codex_live_sideband_endpoint
* @description WebSocket connection endpoint
*/
get: operations["websocket_codex_live_sideband_endpoint"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/mcp/access_groups": {
parameters: {
query?: never;
@ -50839,6 +50899,24 @@ export interface operations {
};
};
};
websocket_realtime_websocket_endpoint_get_4: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description WebSocket Protocol Switched */
101: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
login_login_post: {
parameters: {
query?: never;
@ -63159,6 +63237,42 @@ export interface operations {
};
};
};
websocket_realtime_websocket_endpoint_get_5: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description WebSocket Protocol Switched */
101: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
websocket_codex_live_sideband_endpoint: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description WebSocket Protocol Switched */
101: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
get_mcp_access_groups_v1_mcp_access_groups_get: {
parameters: {
query?: never;