diff --git a/litellm-rust/ADDING_A_PROVIDER.md b/litellm-rust/ADDING_A_PROVIDER.md new file mode 100644 index 00000000000..2fa81798605 --- /dev/null +++ b/litellm-rust/ADDING_A_PROVIDER.md @@ -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//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///transformation.rs`: implement that trait as a `const __CONFIG`, mirroring the Python provider tree. Add parity unit tests. +3. **HTTP / transport (the host)** — `crates/providers/src/.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`. diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 0fb2fcb2921..2ec86fc00e7 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -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" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index fdc5f5efde1..0b1528672a7 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -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"] } diff --git a/litellm-rust/crates/core/CLAUDE.md b/litellm-rust/crates/core/CLAUDE.md index 51e0d215e6a..20873878967 100644 --- a/litellm-rust/crates/core/CLAUDE.md +++ b/litellm-rust/crates/core/CLAUDE.md @@ -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` / 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`, diff --git a/litellm-rust/crates/core/src/lib.rs b/litellm-rust/crates/core/src/lib.rs index a88204867c4..5d4d5bfd142 100644 --- a/litellm-rust/crates/core/src/lib.rs +++ b/litellm-rust/crates/core/src/lib.rs @@ -1,4 +1,5 @@ pub mod error; pub mod ocr; +pub mod realtime; pub use error::{CoreError, CoreResult}; diff --git a/litellm-rust/crates/core/src/realtime/mod.rs b/litellm-rust/crates/core/src/realtime/mod.rs new file mode 100644 index 00000000000..ec2fbb969a6 --- /dev/null +++ b/litellm-rust/crates/core/src/realtime/mod.rs @@ -0,0 +1,2 @@ +pub mod transformation; +pub mod types; diff --git a/litellm-rust/crates/core/src/realtime/transformation.rs b/litellm-rust/crates/core/src/realtime/transformation.rs new file mode 100644 index 00000000000..a4baa27a6c2 --- /dev/null +++ b/litellm-rust/crates/core/src/realtime/transformation.rs @@ -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; + + /// Transform a backend → client event before it is forwarded downstream. + fn transform_realtime_response( + &self, + event: &RealtimeEvent, + model: &str, + ) -> CoreResult; +} diff --git a/litellm-rust/crates/core/src/realtime/types.rs b/litellm-rust/crates/core/src/realtime/types.rs new file mode 100644 index 00000000000..3b59224b6e9 --- /dev/null +++ b/litellm-rust/crates/core/src/realtime/types.rs @@ -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, +} + +/// One or more typed events produced by a realtime transform. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RealtimeTransformResult { + pub events: Vec, +} + +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]); + } +} diff --git a/litellm-rust/crates/providers/Cargo.toml b/litellm-rust/crates/providers/Cargo.toml index b8ed2066079..cb8a91aa5c2 100644 --- a/litellm-rust/crates/providers/Cargo.toml +++ b/litellm-rust/crates/providers/Cargo.toml @@ -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 diff --git a/litellm-rust/crates/providers/src/lib.rs b/litellm-rust/crates/providers/src/lib.rs index 1a0ca5b7e42..34b69d88cbf 100644 --- a/litellm-rust/crates/providers/src/lib.rs +++ b/litellm-rust/crates/providers/src/lib.rs @@ -1,2 +1,4 @@ pub mod mistral; pub mod ocr; +pub mod openai; +pub mod realtime; diff --git a/litellm-rust/crates/providers/src/openai/mod.rs b/litellm-rust/crates/providers/src/openai/mod.rs new file mode 100644 index 00000000000..403e32975cf --- /dev/null +++ b/litellm-rust/crates/providers/src/openai/mod.rs @@ -0,0 +1 @@ +pub mod realtime; diff --git a/litellm-rust/crates/providers/src/openai/realtime/mod.rs b/litellm-rust/crates/providers/src/openai/realtime/mod.rs new file mode 100644 index 00000000000..f239b6921fa --- /dev/null +++ b/litellm-rust/crates/providers/src/openai/realtime/mod.rs @@ -0,0 +1 @@ +pub mod transformation; diff --git a/litellm-rust/crates/providers/src/openai/realtime/transformation.rs b/litellm-rust/crates/providers/src/openai/realtime/transformation.rs new file mode 100644 index 00000000000..2e127c699e0 --- /dev/null +++ b/litellm-rust/crates/providers/src/openai/realtime/transformation.rs @@ -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=` 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 { + Ok(RealtimeTransformResult::passthrough(event.clone())) + } + + fn transform_realtime_response( + &self, + event: &RealtimeEvent, + _model: &str, + ) -> CoreResult { + Ok(RealtimeTransformResult::passthrough(event.clone())) + } +} + +pub fn transform_realtime_request( + event: &RealtimeEvent, + model: &str, +) -> CoreResult { + OPENAI_REALTIME_CONFIG.transform_realtime_request(event, model) +} + +pub fn transform_realtime_response( + event: &RealtimeEvent, + model: &str, +) -> CoreResult { + 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]); + } +} diff --git a/litellm-rust/crates/providers/src/realtime.rs b/litellm-rust/crates/providers/src/realtime.rs new file mode 100644 index 00000000000..f2946db4137 --- /dev/null +++ b/litellm-rust/crates/providers/src/realtime.rs @@ -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 { + 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, + api_key: Option<&str>, + api_base: Option<&str>, + timeout: Option, +) -> CoreResult> { + 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 = 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:?}" + ); + } +}