fix(http): honor transport settings for Rust WebSockets (#47968)

* fix(http): honor transport settings for Rust WebSockets

The Responses WebSocket connected through tokio-tungstenite with its own
native-roots TLS, so it ignored the configured CA bundle, verify flag,
client certificate, proxies and timeouts. The handshake now goes through
a pooled reqwest client (ClientVariant::WebSocket, HTTP/1.1, no
redirects) and upgrades the response, so it shares the settings every
other outbound request uses

The upgrade response is checked for the Sec-WebSocket-Accept key, the
Upgrade and Connection headers and a subprotocol that was offered

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(http): preserve native WebSocket trust and optional protocols

* fix(http): allow plain WebSockets without native roots

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yujonglee 2026-10-28 07:32:41 -07:00 • committed by GitHub
parent 6c0585b8f1
commit cfc651dcd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 699 additions and 130 deletions

View file

@ -3987,6 +3987,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.19",
"tokio",
"tokio-rustls 0.26.4",
"tokio-tungstenite",
"tracing",
"veil",
@ -7425,11 +7426,7 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"rustls 0.23.42",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tungstenite",
]
@ -7774,8 +7771,6 @@ dependencies = [
"httparse",
"log",
"rand 0.8.7",
"rustls 0.23.42",
"rustls-pki-types",
"sha1 0.10.7",
"thiserror 1.0.69",
"utf-8",

View file

@ -100,10 +100,10 @@ rstest = "0.26.1"
wiremock = "0.6.5"
rstest_reuse = "0.7.0"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8"
rustify = "=0.7.0"
rustify_derive = "=0.5.5"
vaultrs = { version = "=0.8.0", default-features = false, features = ["rustls"] }
rustls-native-certs = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["float_roundtrip"] }
serde_with = { version = "=3.21.0", default-features = false, features = ["std", "macros"] }
@ -118,7 +118,7 @@ thiserror = "2.0"
tokenizers = { version = "0.23.1", default-features = false, features = ["onig"] }
tiktoken-rs = "0.12.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
base64 = "0.22"
flate2 = "1"

View file

@ -29,5 +29,6 @@ webpki-roots.workspace = true
futures-util.workspace = true
rcgen = "0.14.10"
tempfile.workspace = true
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
rstest.workspace = true
tokio.workspace = true

View file

@ -16,6 +16,7 @@ pub enum Verify {
Disabled,
CaBundle(PathBuf),
BuiltInRoots,
NativeRoots,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]

View file

@ -6,7 +6,12 @@ use std::{
use reqwest::dns::Resolve;
use crate::{client::Client, config::HttpClientConfig, error::Error, proxy::EnvironmentProxies};
use crate::{
client::Client,
config::{HttpClientConfig, Verify},
error::Error,
proxy::EnvironmentProxies,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ClientVariant {
@ -14,6 +19,7 @@ pub enum ClientVariant {
NoRedirect,
Media,
UnpinnedMedia,
WebSocket,
}
const CLIENT_TTL: Duration = Duration::from_secs(3600);
@ -59,6 +65,14 @@ impl HttpClientPool {
client_certificate: None,
..config.clone()
},
ClientVariant::WebSocket => HttpClientConfig {
http2: false,
verify: match &config.verify {
Verify::BuiltInRoots => Verify::NativeRoots,
verify => verify.clone(),
},
..config.clone()
},
ClientVariant::Provider | ClientVariant::NoRedirect => config.clone(),
};
let key = (effective, variant);
@ -91,7 +105,7 @@ impl HttpClientPool {
) -> reqwest::ClientBuilder {
match variant {
ClientVariant::Provider => builder,
ClientVariant::NoRedirect | ClientVariant::UnpinnedMedia => {
ClientVariant::NoRedirect | ClientVariant::UnpinnedMedia | ClientVariant::WebSocket => {
builder.redirect(reqwest::redirect::Policy::none())
}
ClientVariant::Media => builder

View file

@ -195,6 +195,12 @@ impl TryFrom<&HttpClientConfig> for ClientConfig {
Verify::BuiltInRoots => builder.with_root_certificates(RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
}),
Verify::NativeRoots => {
let native = rustls_native_certs::load_native_certs();
let mut roots = RootCertStore::empty();
roots.add_parsable_certificates(native.certs);
builder.with_root_certificates(roots)
}
Verify::CaBundle(path) => {
builder.with_root_certificates(bundle_roots(path, TlsSource::CaBundle)?)
}

View file

@ -1,62 +1,135 @@
use std::{
io,
sync::{Arc, OnceLock},
use http::{
HeaderMap, HeaderValue, StatusCode,
header::{
CONNECTION, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_PROTOCOL,
SEC_WEBSOCKET_VERSION, UPGRADE,
},
};
use rustls::{ClientConfig, RootCertStore};
use tokio::net::TcpStream;
use reqwest::Url;
use tokio_tungstenite::{
Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config,
tungstenite::{client::IntoClientRequest, error::TlsError, handshake::client::Response},
WebSocketStream,
tungstenite::{
handshake::{client::generate_key, derive_accept_key},
protocol::Role,
},
};
pub type UpstreamWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
use crate::{client::Client, transport::Error};
static TLS_CONFIG: OnceLock<Arc<ClientConfig>> = OnceLock::new();
fn build_tls_config() -> Result<ClientConfig, Box<tokio_tungstenite::tungstenite::Error>> {
let native = rustls_native_certs::load_native_certs();
let mut store = RootCertStore::empty();
let (added, _ignored) = store.add_parsable_certificates(native.certs);
if added == 0 {
return Err(Box::new(tokio_tungstenite::tungstenite::Error::Io(
io::Error::other(format!(
"no usable native root certificates: {:?}",
native.errors
)),
)));
}
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map(|builder| builder.with_root_certificates(store).with_no_client_auth())
.map_err(|error| {
Box::new(tokio_tungstenite::tungstenite::Error::Tls(
TlsError::Rustls(error),
))
})
}
fn tls_config() -> Result<Arc<ClientConfig>, Box<tokio_tungstenite::tungstenite::Error>> {
if let Some(config) = TLS_CONFIG.get() {
return Ok(Arc::clone(config));
}
let built = Arc::new(build_tls_config()?);
Ok(Arc::clone(TLS_CONFIG.get_or_init(|| built)))
}
pub type UpstreamWebSocket = WebSocketStream<reqwest::Upgraded>;
#[tracing::instrument(name = "litellm.websocket.handshake", level = "debug", skip_all)]
pub async fn connect_upstream<R>(
request: R,
) -> Result<(UpstreamWebSocket, Response), Box<tokio_tungstenite::tungstenite::Error>>
where
R: IntoClientRequest + Unpin,
{
let request = request.into_client_request().map_err(Box::new)?;
let connector = match request.uri().scheme_str() {
Some("wss") => Some(Connector::Rustls(tls_config()?)),
_ => None,
};
connect_async_tls_with_config(request, None, false, connector)
pub async fn connect(
client: &Client,
url: &str,
headers: HeaderMap,
) -> Result<UpstreamWebSocket, Error> {
let offered_protocols = headers.get(SEC_WEBSOCKET_PROTOCOL).cloned();
let key = generate_key();
let response = client
.get(http_url(url)?)
.headers(handshake_headers(headers, &key)?)
.send()
.await
.map_err(Box::new)
.map_err(Error::from_reqwest_before_dispatch)?;
let status = response.status();
if status != StatusCode::SWITCHING_PROTOCOLS {
return Err(Error::Http {
status: status.as_u16(),
body: response.text().await.unwrap_or_default(),
});
}
let expected = derive_accept_key(key.as_bytes());
if response
.headers()
.get(SEC_WEBSOCKET_ACCEPT)
.map(HeaderValue::as_bytes)
!= Some(expected.as_bytes())
{
return Err(Error::Network(
"WebSocket handshake returned a wrong Sec-WebSocket-Accept".into(),
));
}
validate_response_headers(response.headers(), offered_protocols.as_ref())?;
let upgraded = response.upgrade().await?;
Ok(WebSocketStream::from_raw_socket(upgraded, Role::Client, None).await)
}
fn validate_response_headers(
headers: &HeaderMap,
offered_protocols: Option<&HeaderValue>,
) -> Result<(), Error> {
if !headers
.get(UPGRADE)
.is_some_and(|value| value.as_bytes().eq_ignore_ascii_case(b"websocket"))
{
return Err(Error::Network(
"WebSocket handshake returned an invalid Upgrade header".into(),
));
}
if !headers.get_all(CONNECTION).iter().any(|value| {
value.to_str().is_ok_and(|value| {
value
.split(',')
.any(|token| token.trim().eq_ignore_ascii_case("upgrade"))
})
}) {
return Err(Error::Network(
"WebSocket handshake returned an invalid Connection header".into(),
));
}
let protocol_matches = match (offered_protocols, headers.get(SEC_WEBSOCKET_PROTOCOL)) {
(_, None) => true,
(Some(offered), Some(selected)) => offered
.to_str()
.ok()
.zip(selected.to_str().ok())
.is_some_and(|(offered, selected)| {
offered
.split(',')
.any(|protocol| protocol.trim() == selected)
}),
_ => false,
};
if !protocol_matches {
return Err(Error::Network(
"WebSocket handshake returned an invalid subprotocol".into(),
));
}
Ok(())
}
fn http_url(url: &str) -> Result<Url, Error> {
let mut url = Url::parse(url).map_err(not_dispatched)?;
let scheme = match url.scheme() {
"ws" | "http" => "http",
"wss" | "https" => "https",
other => {
return Err(not_dispatched(format!(
"unsupported WebSocket URL scheme {other:?}"
)));
}
};
url.set_scheme(scheme)
.map_err(|()| not_dispatched("WebSocket URL cannot be sent over HTTP"))?;
Ok(url)
}
fn handshake_headers(headers: HeaderMap, key: &str) -> Result<HeaderMap, Error> {
let key = HeaderValue::from_str(key).map_err(not_dispatched)?;
Ok([
(CONNECTION, HeaderValue::from_static("Upgrade")),
(UPGRADE, HeaderValue::from_static("websocket")),
(SEC_WEBSOCKET_VERSION, HeaderValue::from_static("13")),
(SEC_WEBSOCKET_KEY, key),
]
.into_iter()
.fold(headers, |mut headers, (name, value)| {
headers.insert(name, value);
headers
}))
}
fn not_dispatched(error: impl std::fmt::Display) -> Error {
Error::Connect(error.to_string())
}

View file

@ -1,59 +1,539 @@
use std::{
net::SocketAddr,
path::PathBuf,
sync::{Arc, Mutex},
};
use futures_util::{SinkExt, StreamExt};
use litellm_http::websocket::connect_upstream;
use rstest::rstest;
use tokio::net::TcpListener;
use http::{HeaderMap, HeaderValue};
use litellm_http::{
ClientIdentity, ClientVariant, EnvironmentProxies, HttpClientConfig, HttpClientPool,
HttpSettings, Resolution, Verify, media::PublicDnsResolver, transport::Error,
websocket::connect,
};
use rcgen::{BasicConstraints, CertificateParams, CertifiedIssuer, IsCa, KeyPair};
use rstest::{Context, rstest};
use rustls::{
RootCertStore, ServerConfig,
crypto::ring,
pki_types::{CertificateDer, PrivateKeyDer},
server::WebPkiClientVerifier,
};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
use tokio_rustls::TlsAcceptor;
use tokio_tungstenite::{
accept_hdr_async,
tungstenite::{
Message,
client::IntoClientRequest,
handshake::server::{Request, Response},
},
};
struct Pki {
directory: tempfile::TempDir,
ca: CertifiedIssuer<'static, KeyPair>,
server_chain: Vec<CertificateDer<'static>>,
server_key: Vec<u8>,
}
impl Pki {
fn new() -> Self {
let mut ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
let ca = CertifiedIssuer::self_signed(ca_params, KeyPair::generate().unwrap()).unwrap();
let server_key = KeyPair::generate().unwrap();
let server = CertificateParams::new(vec!["localhost".into()])
.unwrap()
.signed_by(&server_key, &ca)
.unwrap();
let client_key = KeyPair::generate().unwrap();
let client = CertificateParams::new(vec!["client".into()])
.unwrap()
.signed_by(&client_key, &ca)
.unwrap();
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("ca.pem"), ca.pem()).unwrap();
std::fs::write(
directory.path().join("client.pem"),
format!("{}{}", client.pem(), client_key.serialize_pem()),
)
.unwrap();
Self {
directory,
server_chain: vec![server.der().clone()],
server_key: server_key.serialize_der(),
ca,
}
}
fn path(&self, name: &str) -> PathBuf {
self.directory.path().join(name)
}
fn acceptor(&self, require_client_certificate: bool) -> TlsAcceptor {
let provider = Arc::new(ring::default_provider());
let builder = ServerConfig::builder_with_provider(Arc::clone(&provider))
.with_safe_default_protocol_versions()
.unwrap();
let builder = if require_client_certificate {
let mut roots = RootCertStore::empty();
roots.add(self.ca.der().clone()).unwrap();
builder.with_client_cert_verifier(
WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider)
.build()
.unwrap(),
)
} else {
builder.with_no_client_auth()
};
let mut config = builder
.with_single_cert(
self.server_chain.clone(),
PrivateKeyDer::try_from(self.server_key.clone()).unwrap(),
)
.unwrap();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
TlsAcceptor::from(Arc::new(config))
}
}
fn config(verify: Verify) -> HttpClientConfig {
HttpClientConfig {
verify,
http2: true,
..Resolution::from(&HttpSettings::default()).config
}
}
fn proxied_through(proxy: SocketAddr) -> EnvironmentProxies {
EnvironmentProxies::from_environment(&move |name: &str| {
(name == "HTTPS_PROXY").then(|| format!("http://{proxy}"))
})
}
async fn open(
config: &HttpClientConfig,
url: &str,
headers: HeaderMap,
) -> Result<litellm_http::websocket::UpstreamWebSocket, Error> {
let client = HttpClientPool::new(Arc::new(PublicDnsResolver))
.client(config, ClientVariant::WebSocket)
.unwrap();
connect(&client, url, headers).await
}
async fn echo_once<S: AsyncRead + AsyncWrite + Unpin>(
stream: S,
seen: Arc<Mutex<Option<Request>>>,
) {
#[expect(
clippy::result_large_err,
reason = "tungstenite requires an unboxed handshake error response"
)]
let Ok(mut socket) = accept_hdr_async(stream, |request: &Request, response: Response| {
*seen.lock().unwrap() = Some(request.clone());
Ok(response)
})
.await
else {
return;
};
if let Some(Ok(message)) = socket.next().await {
let _ = socket.send(message).await;
}
}
async fn serve_tls(
pki: &Pki,
require_client_certificate: bool,
) -> (SocketAddr, Arc<Mutex<Option<Request>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let acceptor = pki.acceptor(require_client_certificate);
let seen = Arc::new(Mutex::new(None));
let recorded = Arc::clone(&seen);
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
if let Ok(stream) = acceptor.accept(stream).await {
echo_once(stream, Arc::clone(&recorded)).await;
}
}
});
(address, seen)
}
async fn read_head(stream: &mut TcpStream) -> String {
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
let mut byte = [0u8; 1];
if stream.read(&mut byte).await.unwrap() == 0 {
break;
}
head.push(byte[0]);
}
String::from_utf8(head).unwrap()
}
async fn serve_tunnel_proxy(target: SocketAddr) -> (SocketAddr, Arc<Mutex<Vec<String>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&requests);
tokio::spawn(async move {
while let Ok((mut client, _)) = listener.accept().await {
let head = read_head(&mut client).await;
seen.lock()
.unwrap()
.push(head.lines().next().unwrap_or_default().to_owned());
let mut upstream = TcpStream::connect(target).await.unwrap();
client
.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n")
.await
.unwrap();
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
}
});
(address, requests)
}
async fn serve_raw(response: &'static str) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
read_head(&mut stream).await;
stream.write_all(response.as_bytes()).await.unwrap();
let _ = stream.read(&mut [0u8; 1]).await;
});
address
}
async fn round_trip(socket: &mut litellm_http::websocket::UpstreamWebSocket) {
socket.send(Message::Text("ping".into())).await.unwrap();
assert_eq!(
socket.next().await.unwrap().unwrap(),
Message::Text("ping".into())
);
}
#[rstest]
#[case::without_query("/responses")]
#[case::with_query("/responses?model=test-model")]
#[tokio::test]
async fn connects_with_caller_headers_and_exchanges_frames(#[case] path: &'static str) {
async fn plain_websocket_sends_caller_headers_and_path(#[case] path: &'static str) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let seen = Arc::new(Mutex::new(None));
let recorded = Arc::clone(&seen);
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
echo_once(stream, recorded).await;
});
let headers = HeaderMap::from_iter([(
http::header::AUTHORIZATION,
HeaderValue::from_static("Bearer test-key"),
)]);
let mut socket = open(
&config(Verify::BuiltInRoots),
&format!("ws://{address}{path}"),
headers,
)
.await
.unwrap();
round_trip(&mut socket).await;
let request = seen.lock().unwrap().take().unwrap();
assert_eq!(request.uri().path_and_query().unwrap().as_str(), path);
assert_eq!(request.headers()["authorization"], "Bearer test-key");
}
#[rstest]
#[case::ca_bundle_trusts_the_private_ca(|pki: &Pki| Verify::CaBundle(pki.path("ca.pem")), true)]
#[case::ssl_verify_off_skips_verification(|_: &Pki| Verify::Disabled, true)]
#[case::built_in_roots_reject_the_private_ca(|_: &Pki| Verify::BuiltInRoots, false)]
#[tokio::test]
async fn wss_honors_ssl_verify(#[case] verify: fn(&Pki) -> Verify, #[case] trusted: bool) {
let pki = Pki::new();
let (address, _) = serve_tls(&pki, false).await;
let result = open(
&config(verify(&pki)),
&format!("wss://localhost:{}/responses", address.port()),
HeaderMap::new(),
)
.await;
match result {
Ok(mut socket) if trusted => round_trip(&mut socket).await,
Err(Error::Connect(_)) if !trusted => {}
other => panic!("unexpected handshake outcome: {:?}", other.map(drop)),
}
}
#[rstest]
#[case::plain_without_roots(false, false, true)]
#[case::tls_with_roots(true, true, true)]
#[case::tls_without_roots(true, false, false)]
#[tokio::test]
async fn websocket_uses_system_roots(
#[context] context: Context,
#[case] tls: bool,
#[case] trusted_roots: bool,
#[case] connects: bool,
) {
const CHILD_URL: &str = "LITELLM_TEST_SYSTEM_ROOTS_WSS_URL";
if let Ok(url) = std::env::var(CHILD_URL) {
let result = open(&config(Verify::BuiltInRoots), &url, HeaderMap::new()).await;
match result {
Ok(mut socket) if connects => round_trip(&mut socket).await,
Err(Error::Connect(_)) if !connects => {}
other => panic!("unexpected handshake outcome: {:?}", other.map(drop)),
}
return;
}
let pki = Pki::new();
let url = if tls {
let (address, _) = serve_tls(&pki, false).await;
format!("wss://localhost:{}/responses", address.port())
} else {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
echo_once(stream, Arc::new(Mutex::new(None))).await;
});
format!("ws://{address}/responses")
};
let ca = if trusted_roots {
pki.path("ca.pem")
} else {
let empty = pki.path("empty.pem");
std::fs::write(&empty, "").unwrap();
empty
};
let test = format!(
"{}::case_{}_{}",
context.name,
context.case.unwrap() + 1,
context.description.unwrap()
);
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test, "--nocapture"])
.env(CHILD_URL, url)
.env("SSL_CERT_FILE", ca)
.env("SSL_CERT_DIR", "")
.output()
.unwrap()
})
.await
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("1 passed"),
"system-root WebSocket child failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[rstest]
#[case::with_certificate(true)]
#[case::without_certificate(false)]
#[tokio::test]
async fn wss_presents_the_configured_client_certificate(#[case] configured: bool) {
let pki = Pki::new();
let (address, _) = serve_tls(&pki, true).await;
let config = HttpClientConfig {
client_certificate: configured.then(|| ClientIdentity::Pem(pki.path("client.pem"))),
..config(Verify::CaBundle(pki.path("ca.pem")))
};
let result = open(
&config,
&format!("wss://localhost:{}/responses", address.port()),
HeaderMap::new(),
)
.await;
match result {
Ok(mut socket) if configured => round_trip(&mut socket).await,
Err(_) if !configured => {}
other => panic!("unexpected handshake outcome: {:?}", other.map(drop)),
}
}
#[rstest]
#[tokio::test]
async fn wss_tunnels_through_the_https_proxy() {
let pki = Pki::new();
let (target, _) = serve_tls(&pki, false).await;
let (proxy, requests) = serve_tunnel_proxy(target).await;
let config = HttpClientConfig {
proxies: proxied_through(proxy),
..config(Verify::CaBundle(pki.path("ca.pem")))
};
let mut socket = open(
&config,
&format!("wss://localhost:{}/responses", target.port()),
HeaderMap::new(),
)
.await
.unwrap();
round_trip(&mut socket).await;
assert_eq!(
*requests.lock().unwrap(),
[format!("CONNECT localhost:{} HTTP/1.1", target.port())]
);
}
#[rstest]
#[tokio::test]
async fn refused_upgrade_reports_the_status_and_body() {
let address =
serve_raw("HTTP/1.1 403 Forbidden\r\nContent-Length: 6\r\nConnection: close\r\n\r\ndenied")
.await;
let result = open(
&config(Verify::BuiltInRoots),
&format!("ws://{address}/responses"),
HeaderMap::new(),
)
.await;
assert_eq!(
result.map(drop),
Err(Error::Http {
status: 403,
body: "denied".into()
})
);
}
#[rstest]
#[tokio::test]
async fn upgrade_with_a_wrong_accept_key_is_rejected() {
let address = serve_raw(
"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: wrong\r\n\r\n",
)
.await;
let result = open(
&config(Verify::BuiltInRoots),
&format!("ws://{address}/responses"),
HeaderMap::new(),
)
.await;
assert!(matches!(result, Err(Error::Network(_))));
}
#[rstest]
#[case::non_websocket_scheme("ftp://localhost/responses")]
#[case::unparseable("not a url")]
#[tokio::test]
async fn invalid_url_is_rejected_before_dispatch(#[case] url: &str) {
let result = open(&config(Verify::BuiltInRoots), url, HeaderMap::new()).await;
assert!(matches!(result, Err(Error::Connect(_))));
}
#[rstest]
#[case::missing_upgrade(None, Some("Upgrade"), None, None, false)]
#[case::wrong_upgrade(Some("http"), Some("Upgrade"), None, None, false)]
#[case::missing_connection(Some("websocket"), None, None, None, false)]
#[case::wrong_connection(Some("websocket"), Some("keep-alive"), None, None, false)]
#[case::unsolicited_protocol(Some("websocket"), Some("Upgrade"), None, Some("other"), false)]
#[case::unoffered_protocol(
Some("websocket"),
Some("Upgrade"),
Some("events"),
Some("other"),
false
)]
#[case::declined_optional_protocol(Some("websocket"), Some("Upgrade"), Some("events"), None, true)]
#[case::matching_protocol(
Some("websocket"),
Some("Upgrade"),
Some("events"),
Some("events"),
true
)]
#[case::multiple_offers(
Some("websocket"),
Some("Upgrade"),
Some("events, realtime"),
Some("realtime"),
true
)]
#[case::case_insensitive_upgrade(Some("WebSocket"), Some("uPgRaDe"), None, None, true)]
#[case::connection_tokens(Some("websocket"), Some("keep-alive, Upgrade"), None, None, true)]
#[tokio::test]
async fn upgrade_validates_headers_and_negotiated_protocol(
#[case] upgrade: Option<&'static str>,
#[case] connection: Option<&'static str>,
#[case] offered: Option<&'static str>,
#[case] selected: Option<&'static str>,
#[case] accepted: bool,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
#[expect(
clippy::result_large_err,
reason = "tungstenite requires an unboxed handshake error response"
)]
let mut socket = accept_hdr_async(stream, move |request: &Request, response: Response| {
assert_eq!(request.uri().path_and_query().unwrap().as_str(), path);
assert_eq!(request.headers()["authorization"], "Bearer test-key");
let result = accept_hdr_async(stream, move |_: &Request, mut response: Response| {
response.headers_mut().remove("upgrade");
response.headers_mut().remove("connection");
if let Some(value) = upgrade {
response
.headers_mut()
.insert("upgrade", HeaderValue::from_static(value));
}
if let Some(value) = connection {
response
.headers_mut()
.insert("connection", HeaderValue::from_static(value));
}
if let Some(value) = selected {
response
.headers_mut()
.insert("sec-websocket-protocol", HeaderValue::from_static(value));
}
Ok(response)
})
.await
.unwrap();
assert_eq!(
socket.next().await.unwrap().unwrap(),
Message::Text("request".into())
);
socket.send(Message::Text("response".into())).await.unwrap();
socket.close(None).await.unwrap();
.await;
let Ok(mut socket) = result else {
return;
};
if let Some(Ok(message)) = socket.next().await {
let _ = socket.send(message).await;
}
});
let mut request = format!("ws://{address}{path}")
.into_client_request()
.unwrap();
request
.headers_mut()
.insert("authorization", "Bearer test-key".parse().unwrap());
let (mut socket, response) = connect_upstream(request).await.unwrap();
assert_eq!(response.status(), 101);
socket.send(Message::Text("request".into())).await.unwrap();
assert_eq!(
socket.next().await.unwrap().unwrap(),
Message::Text("response".into())
);
assert!(matches!(
socket.next().await.unwrap().unwrap(),
Message::Close(_)
));
server.await.unwrap();
let headers = offered.map_or_else(HeaderMap::new, |value| {
HeaderMap::from_iter([(
http::header::SEC_WEBSOCKET_PROTOCOL,
HeaderValue::from_static(value),
)])
});
let result = open(
&config(Verify::BuiltInRoots),
&format!("ws://{address}/responses"),
headers,
)
.await;
match result {
Ok(mut socket) if accepted => round_trip(&mut socket).await,
Err(Error::Network(_)) if !accepted => {}
other => panic!("unexpected handshake outcome: {:?}", other.map(drop)),
}
}

View file

@ -25,6 +25,7 @@ tokio-tungstenite.workspace = true
tracing.workspace = true
[dev-dependencies]
litellm-http = { workspace = true, features = ["test-support"] }
litellm-cache.workspace = true
litellm-cache-memory.workspace = true
litellm-host-native.workspace = true

View file

@ -1,12 +1,14 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use futures_util::{SinkExt, StreamExt};
use litellm_http::websocket::{UpstreamWebSocket, connect_upstream};
use litellm_http::{
Client,
websocket::{self, UpstreamWebSocket},
};
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::{
Message,
client::IntoClientRequest,
http::{HeaderName, HeaderValue},
http::{HeaderMap, HeaderName, HeaderValue},
};
use super::Error;
@ -24,42 +26,33 @@ impl ResponsesWebSocketConnection {
fields(outcome)
)]
pub async fn connect_url(
client: &Client,
url: &str,
headers: &HashMap<String, String>,
timeout: Option<Duration>,
) -> Result<Self, Error> {
litellm_inference::diagnostic::operation("litellm.websocket.connect_url", async {
let mut request = url.into_client_request().map_err(|error| {
Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
})?;
for (name, value) in headers {
let header_name = name
.parse::<HeaderName>()
.map_err(|error| Error::InvalidRequest(error.to_string().into()))?;
let header_value = HeaderValue::from_str(value)
.map_err(|error| Error::InvalidRequest(error.to_string().into()))?;
request.headers_mut().insert(header_name, header_value);
}
let connect = connect_upstream(request);
let result = match timeout {
let headers = headers
.iter()
.map(|(name, value)| {
Ok((
name.parse::<HeaderName>()
.map_err(|error| Error::InvalidRequest(error.to_string().into()))?,
HeaderValue::from_str(value)
.map_err(|error| Error::InvalidRequest(error.to_string().into()))?,
))
})
.collect::<Result<HeaderMap, Error>>()?;
let connect = websocket::connect(client, url, headers);
let socket = match timeout {
Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| {
Error::Transport(litellm_http::transport::Error::Network(
"Responses WebSocket connection timed out".into(),
))
})?,
None => connect.await,
};
let (socket, _) = result.map_err(|error| match *error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
Error::Transport(litellm_http::transport::Error::Http {
status: response.status().as_u16(),
body: String::new(),
})
}
other => {
Error::Transport(litellm_http::transport::Error::Network(other.to_string()))
}
})?;
}
.map_err(Error::Transport)?;
Ok(Self {
socket: Arc::new(Mutex::new(Some(socket))),
})

View file

@ -538,6 +538,7 @@ async fn websocket_operations_trace_outcomes_without_capturing_frames_or_credent
.logger()
.instrument(async {
let connection = ResponsesWebSocketConnection::connect_url(
&litellm_http::Client::for_test(),
&format!("ws://{address}/responses"),
&std::collections::HashMap::from([(
"authorization".into(),

View file

@ -1,7 +1,9 @@
use litellm_inference_responses::websocket::ResponsesWebSocketConnection as RustResponsesWebSocketConnection;
use pyo3::prelude::*;
use serde_json::Value;
use litellm_http::ClientVariant;
use litellm_inference_responses::websocket::ResponsesWebSocketConnection as RustResponsesWebSocketConnection;
use crate::{
errors::route_error_to_pyerr,
marshal::{marshal_headers, optional_timeout},
@ -25,10 +27,12 @@ impl ResponsesWebSocketConnection {
) -> PyResult<Bound<'py, PyAny>> {
let headers = marshal_headers(headers)?;
let timeout = optional_timeout(timeout_seconds);
let client = crate::http::host_client(py, ClientVariant::WebSocket)?;
crate::execution::run_async_value(py, async move {
let inner = RustResponsesWebSocketConnection::connect_url(&url, &headers, timeout)
.await
.map_err(route_error_to_pyerr)?;
let inner =
RustResponsesWebSocketConnection::connect_url(&client, &url, &headers, timeout)
.await
.map_err(route_error_to_pyerr)?;
Ok(ResponsesWebSocketConnection { inner })
})
}