feat: add openai realtime translation layer to litellm-rust (1/2) (#31129)

* add RealtimeTransformResult type for realtime transforms

* add RealtimeProviderConfig pure trait in litellm-core

* add core realtime module

* register realtime module in litellm-core lib

* add OpenAI realtime transform + complete_url parity in providers

* add openai realtime module

* add openai provider module

* register openai provider module in providers lib

* add realtime() fn that invokes OpenAI GA realtime API end to end

* register realtime route module in providers lib

* wire tokio/tokio-tungstenite/futures-util into providers crate

* add tokio, tokio-tungstenite, futures-util to rust workspace deps

* update Cargo.lock for realtime websocket deps

* docs: add litellm-rust provider/route contributor guide

* add typed RealtimeEvent; make RealtimeTransformResult hold typed events

* type RealtimeProviderConfig trait on RealtimeEvent instead of raw strings

* type OpenAI realtime passthrough transforms on RealtimeEvent

* type realtime() fn on RealtimeEvent end to end (parse/serialize at host edge)

* docs: add typed-contracts core rule to core CLAUDE.md

* harden complete_url: default bare host / unknown scheme to wss://

---------

Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com>
This commit is contained in:
ishaan-berri 2026-06-23 15:48:51 -07:00 committed by GitHub
parent c2e06890ad
commit 18406c2bad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 759 additions and 8 deletions

View file

@ -0,0 +1,9 @@
# Adding a provider / route to litellm-rust
Three layers, same for every route (see `ocr` and `realtime` as references):
1. **Transform contract (pure)**`crates/core/src/<route>/transformation.rs`: a `…ProviderConfig` trait (URL build + request/response transforms) + types in `types.rs`. No network, env, or auth.
2. **Provider config (pure)**`crates/providers/src/<provider>/<route>/transformation.rs`: implement that trait as a `const <PROVIDER>_<ROUTE>_CONFIG`, mirroring the Python provider tree. Add parity unit tests.
3. **HTTP / transport (the host)**`crates/providers/src/<route>.rs` (e.g. `ocr.rs`, `realtime.rs`): the callable fn (`run_ocr`, `realtime`). It resolves the key, builds the auth header, builds URL + transforms via the config, then does the network call. This is the only layer allowed to do I/O.
**Calling:** the host invokes the route fn — the Python bridge calls `run_ocr`; the `ai-gateway` server calls `realtime`. Register new modules in `lib.rs` / `mod.rs`, then run `cargo fmt && cargo clippy --workspace -- -D warnings && cargo test --workspace`.

272
litellm-rust/Cargo.lock generated
View file

@ -26,12 +26,27 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.0"
@ -60,6 +75,57 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@ -135,6 +201,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -413,16 +489,19 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
name = "litellm-providers"
version = "0.1.0"
dependencies = [
"futures-util",
"litellm-core",
"reqwest",
"serde_json",
"tokio",
"tokio-tungstenite",
]
[[package]]
@ -485,6 +564,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -607,7 +692,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@ -622,13 +707,13 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@ -663,14 +748,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
@ -680,7 +786,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
@ -766,6 +881,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@ -799,6 +926,38 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "serde"
version = "1.0.228"
@ -854,6 +1013,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -931,13 +1101,33 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@ -987,9 +1177,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -1000,6 +1202,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
]
[[package]]
name = "tower"
version = "0.5.3"
@ -1070,6 +1288,32 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.6",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -1100,12 +1344,24 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"

View file

@ -19,3 +19,6 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }

View file

@ -22,6 +22,15 @@ Not allowed:
- Provider-specific branching that belongs in `providers`.
- Panics for user/provider-controlled input.
## Typed Contracts (core rule)
Trait and function boundaries MUST be strongly typed. No stringly-typed JSON
(`&str` / `String` / `Vec<String>` / bare `serde_json::Value`) as a transform
input or output. Parse wire bytes into typed structs/enums at the host edge;
`core` and `providers` operate only on those types (e.g. `RealtimeEvent`,
`RealtimeTransformResult`, `OcrRequestData`). A `type`-style discriminator is a
typed field on a struct, not a raw string threaded through the API.
## Structure
Use route names directly under `src/`: `ocr`, future `messages`,

View file

@ -1,4 +1,5 @@
pub mod error;
pub mod ocr;
pub mod realtime;
pub use error::{CoreError, CoreResult};

View file

@ -0,0 +1,2 @@
pub mod transformation;
pub mod types;

View file

@ -0,0 +1,22 @@
use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult};
use crate::CoreResult;
pub trait RealtimeProviderConfig {
/// Build the upstream WebSocket URL (e.g. `wss://api.openai.com/v1/realtime?model=…`).
/// Pure string construction only — no network, no env.
fn complete_url(&self, api_base: Option<&str>, model: &str) -> String;
/// Transform a client → backend event before it is forwarded upstream.
fn transform_realtime_request(
&self,
event: &RealtimeEvent,
model: &str,
) -> CoreResult<RealtimeTransformResult>;
/// Transform a backend → client event before it is forwarded downstream.
fn transform_realtime_response(
&self,
event: &RealtimeEvent,
model: &str,
) -> CoreResult<RealtimeTransformResult>;
}

View file

@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A single realtime event exchanged over the WebSocket.
///
/// The `type` discriminator is a typed field; the remaining fields are
/// preserved losslessly in `data` so a transform can pass an event through, or
/// inspect/modify specific fields, without enumerating every event variant.
/// Wire (de)serialization happens at the host edge — `core`/`providers` operate
/// only on this typed form.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RealtimeEvent {
#[serde(rename = "type")]
pub event_type: String,
#[serde(flatten)]
pub data: Map<String, Value>,
}
/// One or more typed events produced by a realtime transform.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RealtimeTransformResult {
pub events: Vec<RealtimeEvent>,
}
impl RealtimeTransformResult {
/// Forward a single event unchanged (the OpenAI baseline).
pub fn passthrough(event: RealtimeEvent) -> Self {
Self {
events: vec![event],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(raw: &str) -> RealtimeEvent {
serde_json::from_str(raw).expect("valid event json")
}
#[test]
fn realtime_event_round_trips_type_and_extra_fields() {
let raw = r#"{"type":"response.output_text.delta","delta":"hi","response_id":"r1"}"#;
let parsed = event(raw);
assert_eq!(parsed.event_type, "response.output_text.delta");
assert_eq!(parsed.data.get("delta"), Some(&Value::String("hi".into())));
// Re-serializing yields a semantically-equal event (key order may differ).
let reparsed: RealtimeEvent =
serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
assert_eq!(parsed, reparsed);
}
#[test]
fn passthrough_produces_single_element_vec() {
let parsed = event(r#"{"type":"session.update"}"#);
let result = RealtimeTransformResult::passthrough(parsed.clone());
assert_eq!(result.events, vec![parsed]);
}
}

View file

@ -9,6 +9,9 @@ repository.workspace = true
litellm-core.workspace = true
reqwest.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-tungstenite.workspace = true
futures-util.workspace = true
[dev-dependencies]
serde_json.workspace = true

View file

@ -1,2 +1,4 @@
pub mod mistral;
pub mod ocr;
pub mod openai;
pub mod realtime;

View file

@ -0,0 +1 @@
pub mod realtime;

View file

@ -0,0 +1 @@
pub mod transformation;

View file

@ -0,0 +1,189 @@
use litellm_core::realtime::transformation::RealtimeProviderConfig;
use litellm_core::realtime::types::{RealtimeEvent, RealtimeTransformResult};
use litellm_core::CoreResult;
/// Default OpenAI API base, used when the caller does not override `api_base`.
pub const OPENAI_REALTIME_DEFAULT_API_BASE: &str = "https://api.openai.com";
/// Path appended to the resolved host base to reach the realtime endpoint.
pub const OPENAI_REALTIME_PATH: &str = "/v1/realtime";
/// Percent-encode a query value, escaping any char outside the RFC 3986
/// unreserved set (`A-Za-z0-9-._~`). Keeps us dependency-free; common realtime
/// model slugs have no special chars, but this stays correct for the rest.
fn percent_encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
let unreserved = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~');
if unreserved {
encoded.push(byte as char);
} else {
encoded.push('%');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
/// Build the realtime WebSocket URL, porting Python's `OpenAIRealtime._construct_url`.
///
/// Blank/whitespace `api_base` is treated as absent (guard at resolution time),
/// falling back to the default. The scheme is swapped to its WebSocket
/// equivalent (`https://`→`wss://`, `http://`→`ws://`); bases already using
/// `ws`/`wss` are left untouched. A bare host or unrecognized scheme defaults to
/// secure `wss://` so we never hand a scheme-less URL to the connector (this is
/// a deliberate hardening over Python's `_construct_url`, which would emit a
/// scheme-less URL here). A trailing `/` is trimmed before the path and
/// `?model=<encoded>` are appended.
pub fn complete_url(api_base: Option<&str>, model: &str) -> String {
let base = api_base
.map(str::trim)
.filter(|base| !base.is_empty())
.unwrap_or(OPENAI_REALTIME_DEFAULT_API_BASE);
let base = if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = base.strip_prefix("http://") {
format!("ws://{rest}")
} else if base.starts_with("wss://") || base.starts_with("ws://") {
base.to_string()
} else {
format!("wss://{base}")
};
let base = base.trim_end_matches('/');
format!(
"{base}{OPENAI_REALTIME_PATH}?model={}",
percent_encode(model)
)
}
pub struct OpenAiRealtimeConfig;
pub const OPENAI_REALTIME_CONFIG: OpenAiRealtimeConfig = OpenAiRealtimeConfig;
impl RealtimeProviderConfig for OpenAiRealtimeConfig {
fn complete_url(&self, api_base: Option<&str>, model: &str) -> String {
complete_url(api_base, model)
}
fn transform_realtime_request(
&self,
event: &RealtimeEvent,
_model: &str,
) -> CoreResult<RealtimeTransformResult> {
Ok(RealtimeTransformResult::passthrough(event.clone()))
}
fn transform_realtime_response(
&self,
event: &RealtimeEvent,
_model: &str,
) -> CoreResult<RealtimeTransformResult> {
Ok(RealtimeTransformResult::passthrough(event.clone()))
}
}
pub fn transform_realtime_request(
event: &RealtimeEvent,
model: &str,
) -> CoreResult<RealtimeTransformResult> {
OPENAI_REALTIME_CONFIG.transform_realtime_request(event, model)
}
pub fn transform_realtime_response(
event: &RealtimeEvent,
model: &str,
) -> CoreResult<RealtimeTransformResult> {
OPENAI_REALTIME_CONFIG.transform_realtime_response(event, model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn complete_url_defaults_to_openai_wss() {
assert_eq!(
complete_url(None, "gpt-4o-realtime-preview"),
"wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview"
);
}
#[test]
fn complete_url_blank_base_uses_default() {
assert_eq!(
complete_url(Some(" "), "gpt-4o-realtime-preview"),
"wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview"
);
}
#[test]
fn complete_url_swaps_http_to_ws() {
assert_eq!(
complete_url(Some("http://localhost:8080"), "gpt-4o-realtime-preview"),
"ws://localhost:8080/v1/realtime?model=gpt-4o-realtime-preview"
);
}
#[test]
fn complete_url_dedupes_trailing_slash() {
assert_eq!(
complete_url(Some("https://api.openai.com/"), "gpt-4o-realtime-preview"),
"wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview"
);
}
#[test]
fn complete_url_custom_base() {
assert_eq!(
complete_url(Some("https://oai.azure.example"), "gpt-4o-realtime-preview"),
"wss://oai.azure.example/v1/realtime?model=gpt-4o-realtime-preview"
);
}
#[test]
fn complete_url_preserves_existing_wss_scheme() {
assert_eq!(
complete_url(Some("wss://api.openai.com"), "gpt-realtime"),
"wss://api.openai.com/v1/realtime?model=gpt-realtime"
);
}
#[test]
fn complete_url_bare_host_defaults_to_wss() {
assert_eq!(
complete_url(Some("api.openai.com"), "gpt-realtime"),
"wss://api.openai.com/v1/realtime?model=gpt-realtime"
);
}
#[test]
fn complete_url_percent_encodes_model_space() {
assert_eq!(
complete_url(None, "gpt 4o"),
"wss://api.openai.com/v1/realtime?model=gpt%204o"
);
}
#[test]
fn transform_realtime_request_passthrough_preserves_event() {
let event: RealtimeEvent =
serde_json::from_str(r#"{"type":"session.update","session":{"voice":"alloy"}}"#)
.expect("valid event");
let result =
transform_realtime_request(&event, "gpt-realtime").expect("passthrough is infallible");
assert_eq!(result.events, vec![event]);
}
#[test]
fn transform_realtime_response_passthrough_preserves_event() {
let event: RealtimeEvent =
serde_json::from_str(r#"{"type":"response.output_audio.delta","delta":"abc=="}"#)
.expect("valid event");
let result =
transform_realtime_response(&event, "gpt-realtime").expect("passthrough is infallible");
assert_eq!(result.events, vec![event]);
}
}

View file

@ -0,0 +1,193 @@
//! End-to-end OpenAI realtime invocation.
//!
//! The host-facing entry point, mirroring `providers::ocr::run_ocr`: open the
//! WebSocket to OpenAI, drive typed events through the pure
//! `OPENAI_REALTIME_CONFIG` transforms, and collect the response events.
//! Network, auth header, key resolution, and wire (de)serialization live here so
//! the `transformation` module stays pure and typed.
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use litellm_core::error::CoreError;
use litellm_core::realtime::transformation::RealtimeProviderConfig;
use litellm_core::realtime::types::RealtimeEvent;
use litellm_core::CoreResult;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::Message;
use crate::openai::realtime::transformation::OPENAI_REALTIME_CONFIG;
/// Environment variable holding the OpenAI API key (last-resort fallback).
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
/// Default overall ceiling for a single realtime invocation.
const DEFAULT_TIMEOUT_SECS: u64 = 60;
const MISSING_KEY_MESSAGE: &str = "Missing OpenAI API Key - a realtime call is being made but no key was passed via params or the OPENAI_API_KEY environment variable";
/// Resolve the OpenAI API key from the explicit param or the environment.
///
/// Blank/whitespace values are treated as absent (guard at resolution time).
fn resolve_api_key(api_key: Option<&str>) -> CoreResult<String> {
api_key
.map(str::trim)
.filter(|key| !key.is_empty())
.map(str::to_string)
.or_else(|| {
std::env::var(OPENAI_API_KEY_ENV)
.ok()
.filter(|key| !key.trim().is_empty())
})
.ok_or_else(|| CoreError::Auth(MISSING_KEY_MESSAGE.to_string()))
}
/// True for events that end a realtime turn: a completed response or an error.
fn is_terminal_event(event: &RealtimeEvent) -> bool {
event.event_type == "response.done" || event.event_type == "error"
}
/// Invoke the OpenAI realtime API end to end over a WebSocket.
///
/// Sends each `input_events` entry after passing it through
/// `transform_realtime_request`, then collects backend events — each passed
/// through `transform_realtime_response` — until a terminal event
/// (`response.done` / `error`) arrives, the socket closes, or the `timeout`
/// elapses. Returns the transformed backend events in arrival order.
///
/// Mirrors `run_ocr`: pure transforms come from `core`/`providers`; the network,
/// auth header, key resolution, and JSON (de)serialization are owned here.
pub async fn realtime(
model: &str,
input_events: Vec<RealtimeEvent>,
api_key: Option<&str>,
api_base: Option<&str>,
timeout: Option<Duration>,
) -> CoreResult<Vec<RealtimeEvent>> {
let config = &OPENAI_REALTIME_CONFIG;
let api_key = resolve_api_key(api_key)?;
let url = config.complete_url(api_base, model);
let mut request = url
.as_str()
.into_client_request()
.map_err(|err| CoreError::Network(err.to_string()))?;
// GA realtime API: only Authorization is needed. The legacy
// `OpenAI-Beta: realtime=v1` header opts into the now-removed beta request
// shape and triggers `beta_api_shape_disabled`, so we do not send it.
request.headers_mut().insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {api_key}"))
.map_err(|err| CoreError::Auth(err.to_string()))?,
);
let (mut ws, _response) = connect_async(request)
.await
.map_err(|err| CoreError::Network(err.to_string()))?;
for event in &input_events {
for outbound in config.transform_realtime_request(event, model)?.events {
let payload = serde_json::to_string(&outbound)
.map_err(|err| CoreError::InvalidResponse(err.to_string()))?;
ws.send(Message::Text(payload))
.await
.map_err(|err| CoreError::Network(err.to_string()))?;
}
}
let deadline = timeout.unwrap_or_else(|| Duration::from_secs(DEFAULT_TIMEOUT_SECS));
let mut received: Vec<RealtimeEvent> = Vec::new();
let collect = async {
while let Some(message) = ws.next().await {
match message.map_err(|err| CoreError::Network(err.to_string()))? {
Message::Text(text) => {
let event: RealtimeEvent = serde_json::from_str(text.as_str())
.map_err(|err| CoreError::InvalidResponse(err.to_string()))?;
for outbound in config.transform_realtime_response(&event, model)?.events {
let terminal = is_terminal_event(&outbound);
received.push(outbound);
if terminal {
return Ok::<(), CoreError>(());
}
}
}
Message::Close(_) => return Ok(()),
_ => {}
}
}
Ok(())
};
tokio::time::timeout(deadline, collect)
.await
.map_err(|_| CoreError::Network("realtime call timed out".to_string()))??;
Ok(received)
}
#[cfg(test)]
mod tests {
use super::*;
fn event(raw: &str) -> RealtimeEvent {
serde_json::from_str(raw).expect("valid event json")
}
#[test]
fn resolve_api_key_prefers_param_then_blank_falls_through() {
assert_eq!(resolve_api_key(Some("sk-test")).unwrap(), "sk-test");
// A blank param with no env set should error.
if std::env::var(OPENAI_API_KEY_ENV).is_err() {
assert!(resolve_api_key(Some(" ")).is_err());
}
}
#[test]
fn is_terminal_event_matches_done_and_error_only() {
assert!(is_terminal_event(&event(r#"{"type":"response.done"}"#)));
assert!(is_terminal_event(&event(r#"{"type":"error","error":{}}"#)));
assert!(!is_terminal_event(&event(
r#"{"type":"response.output_text.delta"}"#
)));
}
/// Live end-to-end check against OpenAI. Ignored by default (CI never runs
/// it); run explicitly with `OPENAI_API_KEY` set:
/// `cargo test -p litellm-providers realtime_invokes_openai -- --ignored --nocapture`
#[tokio::test]
#[ignore = "hits the live OpenAI realtime API; needs OPENAI_API_KEY"]
async fn realtime_invokes_openai_and_responds() {
let key =
std::env::var(OPENAI_API_KEY_ENV).expect("set OPENAI_API_KEY to run this ignored test");
let response_create = event(
r#"{"type":"response.create","response":{"output_modalities":["text"],"instructions":"Respond with exactly: hello world"}}"#,
);
let events = realtime(
"gpt-realtime",
vec![response_create],
Some(&key),
None,
Some(Duration::from_secs(30)),
)
.await
.expect("realtime call should succeed");
let types: Vec<&str> = events.iter().map(|e| e.event_type.as_str()).collect();
eprintln!("received {} events: {:?}", events.len(), types);
assert!(
types.contains(&"response.done"),
"expected a response.done event, got: {types:?}"
);
assert!(
types.contains(&"response.output_text.delta"),
"expected streamed text output, got: {types:?}"
);
}
}