use anyhow::Result; use async_trait::async_trait; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, redirect::Policy, Client, Response, StatusCode, }; #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] use reqwest::{Certificate, Identity}; use serde_json::Value; use std::fmt; #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] use std::fs::read_to_string; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use url::Host; pub const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600; pub const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30; pub type RequestBuilderDecorator = Arc Result + Send + Sync>; pub struct ApiClient { client: Client, host: String, auth: AuthMethod, default_headers: HeaderMap, default_query: Vec<(String, String)>, timeout: Duration, tls_config: Option, request_builder: Option, transport_policy: TransportPolicy, } #[derive(Clone)] enum TransportPolicy { Default, HttpsOnly, LoopbackHttp, SameOrigin(url::Origin), } pub enum AuthMethod { NoAuth, BearerToken(String), ApiKey { header_name: String, key: String }, Custom(Box), } #[derive(Debug, Clone)] pub struct TlsCertKeyPair { pub cert_path: PathBuf, pub key_path: PathBuf, } #[derive(Debug, Clone)] pub struct TlsConfig { pub client_identity: Option, pub ca_cert_path: Option, } impl TlsConfig { pub fn new() -> Self { Self { client_identity: None, ca_cert_path: None, } } pub fn with_client_cert_and_key(mut self, cert_path: PathBuf, key_path: PathBuf) -> Self { self.client_identity = Some(TlsCertKeyPair { cert_path, key_path, }); self } pub fn with_ca_cert(mut self, path: PathBuf) -> Self { self.ca_cert_path = Some(path); self } pub fn is_configured(&self) -> bool { self.client_identity.is_some() || self.ca_cert_path.is_some() } #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] fn load_identity(&self) -> Result> { if let Some(cert_key_pair) = &self.client_identity { let cert_pem = read_to_string(&cert_key_pair.cert_path) .map_err(|e| anyhow::anyhow!("Failed to read client certificate: {}", e))?; let key_pem = read_to_string(&cert_key_pair.key_path) .map_err(|e| anyhow::anyhow!("Failed to read client private key: {}", e))?; #[cfg(not(feature = "native-tls"))] let identity = { let combined_pem = format!("{}\n{}", cert_pem, key_pem); Identity::from_pem(combined_pem.as_bytes()).map_err(|e| { anyhow::anyhow!("Failed to create identity from cert and key: {}", e) })? }; #[cfg(feature = "native-tls")] let identity = { let pkcs8_key_pem = convert_key_to_pkcs8_pem(&key_pem)?; Identity::from_pkcs8_pem(cert_pem.as_bytes(), pkcs8_key_pem.as_bytes()).map_err( |e| anyhow::anyhow!("Failed to create identity from cert and key: {}", e), )? }; Ok(Some(identity)) } else { Ok(None) } } #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] fn load_ca_certificates(&self) -> Result> { match &self.ca_cert_path { Some(ca_path) => { let ca_pem = read_to_string(ca_path) .map_err(|e| anyhow::anyhow!("Failed to read CA certificate: {}", e))?; let certs = Certificate::from_pem_bundle(ca_pem.as_bytes()) .map_err(|e| anyhow::anyhow!("Failed to parse CA certificate bundle: {}", e))?; Ok(certs) } None => Ok(Vec::new()), } } } impl Default for TlsConfig { fn default() -> Self { Self::new() } } /// Convert a PEM private key from any format (PKCS#1, SEC1, PKCS#8) to PKCS#8 PEM. /// /// `reqwest::Identity::from_pkcs8_pem` (native-tls) only accepts PKCS#8 /// (`-----BEGIN PRIVATE KEY-----`), but private keys in the wild come in three formats: /// /// - **PKCS#1** (`-----BEGIN RSA PRIVATE KEY-----`): Legacy RSA-specific format. /// Generated by default with `openssl genrsa`. Very common in older setups, tutorials, /// and CA-issued key files. /// - **SEC1** (`-----BEGIN EC PRIVATE KEY-----`): Legacy EC-specific format. /// Generated by default with `openssl ecparam -genkey`. Common with EC certificates. /// - **PKCS#8** (`-----BEGIN PRIVATE KEY-----`): Generic, algorithm-agnostic wrapper. /// This is the only format native-tls accepts. /// /// Without this conversion, users with legacy-format keys (Kubernetes secrets, corporate /// PKI, etc.) would get a cryptic "Failed to create identity" error and need to manually /// run `openssl pkey -in key.pem -out key-pkcs8.pem`. /// /// Note: the rustls code path (`Identity::from_pem`) accepts all formats natively, /// so this conversion is only needed for native-tls. #[cfg(feature = "native-tls")] const RSA_ALGORITHM_ID: pkcs8::AlgorithmIdentifierRef<'static> = pkcs8::AlgorithmIdentifierRef { oid: pkcs8::ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"), parameters: Some(pkcs8::der::asn1::AnyRef::NULL), }; #[cfg(feature = "native-tls")] fn convert_key_to_pkcs8_pem(key_pem_str: &str) -> Result { use pkcs8::der::{asn1::OctetStringRef, Decode, Encode}; let parsed = pem::parse(key_pem_str).map_err(|e| anyhow::anyhow!("Failed to parse PEM key: {}", e))?; match parsed.tag() { "PRIVATE KEY" => Ok(key_pem_str.to_string()), "RSA PRIVATE KEY" => { let private_key = OctetStringRef::new(parsed.contents())?; let info = pkcs8::PrivateKeyInfoRef::new(RSA_ALGORITHM_ID, private_key); let der_bytes = info .to_der() .map_err(|e| anyhow::anyhow!("Failed to encode PKCS#8: {}", e))?; Ok(pem::encode(&pem::Pem::new("PRIVATE KEY", der_bytes))) } "EC PRIVATE KEY" => { let ec_key = sec1::EcPrivateKey::from_der(parsed.contents()) .map_err(|e| anyhow::anyhow!("Failed to parse EC key: {}", e))?; let curve_oid = ec_key .parameters .and_then(|p| p.named_curve()) .ok_or_else(|| { anyhow::anyhow!( "EC key missing curve parameters. Convert to PKCS#8: \ openssl pkey -in key.pem -out key-pkcs8.pem" ) })?; let algorithm = pkcs8::AlgorithmIdentifierRef { oid: sec1::ALGORITHM_OID, parameters: Some((&curve_oid).into()), }; let private_key = OctetStringRef::new(parsed.contents())?; let info = pkcs8::PrivateKeyInfoRef::new(algorithm, private_key); let der_bytes = info .to_der() .map_err(|e| anyhow::anyhow!("Failed to encode PKCS#8: {}", e))?; Ok(pem::encode(&pem::Pem::new("PRIVATE KEY", der_bytes))) } tag => Err(anyhow::anyhow!( "Unsupported key format '{}'. Expected PKCS#8, PKCS#1, or SEC1. \ Convert with: openssl pkey -in key.pem -out key-pkcs8.pem", tag )), } } #[async_trait] pub trait AuthProvider: Send + Sync { async fn get_auth_header(&self) -> Result<(String, String)>; async fn refresh_credentials(&self) -> Result<()> { anyhow::bail!("credential refresh not supported") } } pub struct ApiResponse { pub status: StatusCode, pub payload: Option, } impl fmt::Debug for AuthMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AuthMethod::NoAuth => f.debug_tuple("NoAuth").finish(), AuthMethod::BearerToken(_) => f.debug_tuple("BearerToken").field(&"[hidden]").finish(), AuthMethod::ApiKey { header_name, .. } => f .debug_struct("ApiKey") .field("header_name", header_name) .field("key", &"[hidden]") .finish(), AuthMethod::Custom(_) => f.debug_tuple("Custom").field(&"[provider]").finish(), } } } impl ApiResponse { pub async fn from_response(response: Response) -> Result { let status = response.status(); let payload = response.json().await.ok(); Ok(Self { status, payload }) } } pub struct ApiRequestBuilder<'a> { client: &'a ApiClient, path: &'a str, headers: HeaderMap, streaming: bool, } impl ApiClient { pub fn new_with_tls( host: String, auth: AuthMethod, tls_config: Option, ) -> Result { Self::with_timeout_and_tls( host, auth, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), tls_config, ) } pub fn with_timeout_and_tls( host: String, auth: AuthMethod, timeout: Duration, tls_config: Option, ) -> Result { let mut client_builder = Self::client_builder(timeout); if let Some(ref config) = tls_config { client_builder = Self::configure_tls(client_builder, config)?; } let client = client_builder.build()?; Ok(Self { client, host, auth, default_headers: HeaderMap::new(), default_query: Vec::new(), timeout, tls_config, request_builder: None, transport_policy: TransportPolicy::Default, }) } pub fn host(&self) -> &str { &self.host } pub fn timeout(&self) -> Duration { self.timeout } fn client_builder(timeout: Duration) -> reqwest::ClientBuilder { Client::builder() .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS)) .read_timeout(timeout) } fn rebuild_client(&mut self) -> Result<()> { let mut client_builder = Self::client_builder(self.timeout).default_headers(self.default_headers.clone()); client_builder = Self::configure_transport(client_builder, &self.transport_policy); // Configure TLS if needed if let Some(ref tls_config) = self.tls_config { client_builder = Self::configure_tls(client_builder, tls_config)?; } self.client = client_builder.build()?; Ok(()) } fn configure_transport( client_builder: reqwest::ClientBuilder, transport_policy: &TransportPolicy, ) -> reqwest::ClientBuilder { match transport_policy { TransportPolicy::Default => client_builder, TransportPolicy::HttpsOnly => client_builder.https_only(true), TransportPolicy::LoopbackHttp => { client_builder .no_proxy() .redirect(Policy::custom(|attempt| { if attempt.previous().len() > 10 { return attempt.error("too many redirects"); } let url = attempt.url(); let is_loopback_http = url.scheme() == "http" && match url.host() { Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), Some(Host::Ipv4(address)) => address.is_loopback(), Some(Host::Ipv6(address)) => address.is_loopback(), None => false, }; if url.scheme() == "https" || is_loopback_http { attempt.follow() } else { attempt.error("redirect violates the loopback transport policy") } })) } TransportPolicy::SameOrigin(origin) => { let origin = origin.clone(); client_builder.redirect(Policy::custom(move |attempt| { if attempt.previous().len() >= 10 { return attempt.error("too many redirects"); } if attempt.url().origin() == origin { attempt.follow() } else { attempt.error("redirect crosses the authenticated request origin") } })) } } } /// Configure TLS settings on a reqwest ClientBuilder #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] fn configure_tls( mut client_builder: reqwest::ClientBuilder, tls_config: &TlsConfig, ) -> Result { if tls_config.is_configured() { // Load client identity (certificate + private key) if let Some(identity) = tls_config.load_identity()? { client_builder = client_builder.identity(identity); } // Load CA certificates let ca_certs = tls_config.load_ca_certificates()?; for ca_cert in ca_certs { client_builder = client_builder.add_root_certificate(ca_cert); } } Ok(client_builder) } /// Reject custom TLS settings when goose is compiled without a TLS backend. #[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] fn configure_tls( client_builder: reqwest::ClientBuilder, tls_config: &TlsConfig, ) -> Result { if tls_config.is_configured() { return Err(anyhow::anyhow!( "Custom TLS configuration requires the `rustls-tls` or `native-tls` feature" )); } Ok(client_builder) } pub fn with_headers(mut self, headers: HeaderMap) -> Result { self.default_headers = headers; self.rebuild_client()?; Ok(self) } pub fn with_query(mut self, params: Vec<(String, String)>) -> Self { self.default_query = params; self } pub fn with_header(mut self, key: &str, value: &str) -> Result { let header_name = HeaderName::from_bytes(key.as_bytes())?; let header_value = HeaderValue::from_str(value)?; self.default_headers.insert(header_name, header_value); self.rebuild_client()?; Ok(self) } pub fn with_request_builder(mut self, request_builder: RequestBuilderDecorator) -> Self { self.request_builder = Some(request_builder); self } pub fn with_https_only(mut self) -> Result { self.transport_policy = TransportPolicy::HttpsOnly; self.rebuild_client()?; Ok(self) } pub fn with_loopback_http_only(mut self) -> Result { self.transport_policy = TransportPolicy::LoopbackHttp; self.rebuild_client()?; Ok(self) } pub fn with_same_origin_redirects(mut self) -> Result { let origin = url::Url::parse(&self.host) .map_err(|error| anyhow::anyhow!("Invalid base URL: {}", error))? .origin(); self.transport_policy = TransportPolicy::SameOrigin(origin); self.rebuild_client()?; Ok(self) } pub fn request<'a>(&'a self, path: &'a str) -> ApiRequestBuilder<'a> { ApiRequestBuilder { client: self, path, headers: HeaderMap::new(), streaming: false, } } pub async fn refresh_credentials(&self) -> Result<()> { match &self.auth { AuthMethod::Custom(provider) => provider.refresh_credentials().await, _ => anyhow::bail!("credential refresh not supported"), } } pub async fn api_post(&self, path: &str, payload: &Value) -> Result { self.request(path).api_post(payload).await } pub async fn response_post(&self, path: &str, payload: &Value) -> Result { self.request(path).response_post(payload).await } pub async fn api_get(&self, path: &str) -> Result { self.request(path).api_get().await } pub async fn response_get(&self, path: &str) -> Result { self.request(path).response_get().await } fn build_url(&self, path: &str) -> Result { use url::Url; let mut base_url = Url::parse(&self.host).map_err(|e| anyhow::anyhow!("Invalid base URL: {}", e))?; let base_path = base_url.path(); if !base_path.is_empty() && base_path != "/" && !base_path.ends_with('/') { base_url.set_path(&format!("{}/", base_path)); } let mut url = base_url .join(path) .map_err(|e| anyhow::anyhow!("Failed to construct URL: {}", e))?; for (key, value) in &self.default_query { url.query_pairs_mut().append_pair(key, value); } Ok(url) } } impl<'a> ApiRequestBuilder<'a> { pub fn header(mut self, key: &str, value: &str) -> Result { let header_name = HeaderName::from_bytes(key.as_bytes())?; let header_value = HeaderValue::from_str(value)?; self.headers.insert(header_name, header_value); Ok(self) } #[allow(dead_code)] pub fn headers(mut self, headers: HeaderMap) -> Self { self.headers.extend(headers); self } /// Apply per-request headers from a model config, overriding any static /// client headers on key collision. pub fn model_headers(self, model_config: &crate::model::ModelConfig) -> Result { match &model_config.request_headers { Some(headers) => headers .iter() .try_fold(self, |builder, (key, value)| builder.header(key, value)), None => Ok(self), } } pub fn streaming(mut self, streaming: bool) -> Self { self.streaming = streaming; self } pub async fn api_post(self, payload: &Value) -> Result { let response = self.response_post(payload).await?; ApiResponse::from_response(response).await } async fn send_bounded(&self, request: reqwest::RequestBuilder) -> Result { if self.streaming { Ok(crate::http_status::send_bounded(request, self.client.timeout).await?) } else { Ok(request.send().await?) } } pub async fn response_post(self, payload: &Value) -> Result { let request = self.send_request(|url, client| client.post(url)).await?; self.send_bounded(request.json(payload)).await } pub async fn multipart_post(self, form: reqwest::multipart::Form) -> Result { let request = self.send_request(|url, client| client.post(url)).await?; Ok(request.multipart(form).send().await?) } pub async fn api_get(self) -> Result { let response = self.response_get().await?; ApiResponse::from_response(response).await } pub async fn response_get(self) -> Result { let request = self.send_request(|url, client| client.get(url)).await?; self.send_bounded(request).await } async fn send_request(&self, request_builder: F) -> Result where F: FnOnce(url::Url, &Client) -> reqwest::RequestBuilder, { let url = self.client.build_url(self.path)?; let headers = self.headers.clone(); let mut request = request_builder(url, &self.client.client); request = request.headers(headers); if !self.streaming { request = request.timeout(self.client.timeout); } if let Some(decorator) = &self.client.request_builder { request = decorator(request)?; } request = match &self.client.auth { AuthMethod::NoAuth => request, AuthMethod::BearerToken(token) => { request.header("Authorization", format!("Bearer {}", token)) } AuthMethod::ApiKey { header_name, key } => request.header(header_name.as_str(), key), AuthMethod::Custom(provider) => { let (header_name, header_value) = provider.get_auth_header().await?; request.header(header_name, header_value) } }; Ok(request) } } impl fmt::Debug for ApiClient { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ApiClient") .field("host", &self.host) .field("auth", &"[auth method]") .field("timeout", &self.timeout) .field("default_headers", &self.default_headers) .finish_non_exhaustive() } } #[cfg(all(test, feature = "native-tls"))] mod native_tls_tests { use super::convert_key_to_pkcs8_pem; const PKCS8_RSA_KEY: &str = "\ -----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCzmc7SPaB07+gK p/VETtmayzNASEaaGIEKO5eOrlrKfIMdmSyB38Ia4dCyxb8WKAROeBtaev8AXfKh 5sPRPi42SNF77d171QWKU4WKXRWWpO2UWwzrXc/BaCNr0Ey9S4ej3v0DmdPkW+pY PZ5lzHUT6wTutGwJsceCN+/lgPmazaZvfnTdLlRChLyPFjwGkGFwmu7YZACdbIqd CW5oIs+weAK2L9+HZaLi0TWcnJrnONs00GHTm2DwLkl8ulKRn6+XygWZLpY4owE6 /x4v4CrTKStvfJlPiErcR/t13vZ+kZq3XxcgIY5V0HEz+AT57/MvvGomQXyMmQnN bQrmdbvRAgMBAAECggEABd6knqSmuDeogeOmHYzFqhxSwRqzmwDqw7i5ne9HFpfN 6bNaO+biF0KS0Wrq0Tiu1lkwzKrZJ2wKnF24P+AMPtgvp4EZRF0sjI4o/sIGzPj4 LklxAUa8Pd4PTUxeG7Aqn1GP+5SFEzEpVieyY/GUEHn1e+8v2jvCEViOabXmwndA tGHScHwMA0qW1smCOLj12mfFJJjJjlj7QveJFSJ94wo8XjdZvzLDonQoAf4vN99f rf40zgAx8VQk1UmOPYgc7mqN3JerjWX3rG820UWilLnAA35rrVxjzeV9yfAbgpVC qHPaOaN4tBVTb3z9w94wK/uDjd4Px26Qq0Yipi4xGQKBgQD7RXE6hNlx1hW7pdWw hrxdH9ofptBzJfQwYnO0cq6KFF6+QQYzJcMAZAKEytDzfjkH1iHURtBFA1ndKBSI B1G8iR7Jie4szDghJ+zS9QHHhXLRprSqyamRefnLe/614YlaP7i9wq/sHg8KKm5b g+S49EfuKctAenMx8sDPDG7Y2QKBgQC2+xOZTE5uPPitOyR8eMUF1ye9zeONzyJ2 l2rjEZuIOWd12wsrgGVTRXoecpqFNIxcmK2mnBtrWCJ/A5xFXjQjIZ1huJ/oNm+t bI/GtlM5BknfEP6m8NVtMx5ubzqDIlsgRpXCWco3ytaKZJtdnJhZAOmBogAKUiPr NL2afWbfuQKBgA722MJybPzBkjPFsY88xvUI05W0+o8RAJTtGBT0C37/rZDJEJR1 OELKsfe0mHMX3k+gKg2ZVgf2JiDspSRgwzZmOCYqh7u1QdZ/qTP7EWsPgIIJ2pKd RfL6/6xlqwqr7uEoEFmEwbNfKughFMdweGunaK0/YfJqGHguC9uI0wUpAoGBAKDM U5TLscC+Y+oXpyCbIMjZIltxqx7bf/Wnao7Q0lUL3Rd8vnwkAOcEjyRiodedLhvR MAjR9maGtQnZKmLrPfYBfER78komTE2isVZ2svvRwuj8Dky2J1gnK/7wUAMdFedv H/wC2+nbnl1PvBivnFHas1jv/AkV1erEFjrFKLpBAoGABQ29M3xsVv7++/ivl0JM lNzUZwWbG4GI9Yx/zBMJpjiPi9Bkbe2Iu7EgLuiaTnktLSzm9hwVEIxd6a6X/wRA cGr4L/FDExxjHQPzYpeGQ5fA9HlDWZzQ7Ou+nZ/O+OwXuvnmjXTXyB0djVJHOmLU gOfZqLKYsL5zn6pefvXu0dU= -----END PRIVATE KEY-----"; const PKCS1_RSA_KEY: &str = "\ -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAs5nO0j2gdO/oCqf1RE7ZmsszQEhGmhiBCjuXjq5aynyDHZks gd/CGuHQssW/FigETngbWnr/AF3yoebD0T4uNkjRe+3de9UFilOFil0VlqTtlFsM 613PwWgja9BMvUuHo979A5nT5FvqWD2eZcx1E+sE7rRsCbHHgjfv5YD5ms2mb350 3S5UQoS8jxY8BpBhcJru2GQAnWyKnQluaCLPsHgCti/fh2Wi4tE1nJya5zjbNNBh 05tg8C5JfLpSkZ+vl8oFmS6WOKMBOv8eL+Aq0ykrb3yZT4hK3Ef7dd72fpGat18X ICGOVdBxM/gE+e/zL7xqJkF8jJkJzW0K5nW70QIDAQABAoIBAAXepJ6kprg3qIHj ph2MxaocUsEas5sA6sO4uZ3vRxaXzemzWjvm4hdCktFq6tE4rtZZMMyq2SdsCpxd uD/gDD7YL6eBGURdLIyOKP7CBsz4+C5JcQFGvD3eD01MXhuwKp9Rj/uUhRMxKVYn smPxlBB59XvvL9o7whFYjmm15sJ3QLRh0nB8DANKltbJgji49dpnxSSYyY5Y+0L3 iRUifeMKPF43Wb8yw6J0KAH+LzffX63+NM4AMfFUJNVJjj2IHO5qjdyXq41l96xv NtFFopS5wAN+a61cY83lfcnwG4KVQqhz2jmjeLQVU298/cPeMCv7g43eD8dukKtG IqYuMRkCgYEA+0VxOoTZcdYVu6XVsIa8XR/aH6bQcyX0MGJztHKuihRevkEGMyXD AGQChMrQ8345B9Yh1EbQRQNZ3SgUiAdRvIkeyYnuLMw4ISfs0vUBx4Vy0aa0qsmp kXn5y3v+teGJWj+4vcKv7B4PCipuW4PkuPRH7inLQHpzMfLAzwxu2NkCgYEAtvsT mUxObjz4rTskfHjFBdcnvc3jjc8idpdq4xGbiDlnddsLK4BlU0V6HnKahTSMXJit ppwba1gifwOcRV40IyGdYbif6DZvrWyPxrZTOQZJ3xD+pvDVbTMebm86gyJbIEaV wlnKN8rWimSbXZyYWQDpgaIAClIj6zS9mn1m37kCgYAO9tjCcmz8wZIzxbGPPMb1 CNOVtPqPEQCU7RgU9At+/62QyRCUdThCyrH3tJhzF95PoCoNmVYH9iYg7KUkYMM2 ZjgmKoe7tUHWf6kz+xFrD4CCCdqSnUXy+v+sZasKq+7hKBBZhMGzXyroIRTHcHhr p2itP2Hyahh4LgvbiNMFKQKBgQCgzFOUy7HAvmPqF6cgmyDI2SJbcase23/1p2qO 0NJVC90XfL58JADnBI8kYqHXnS4b0TAI0fZmhrUJ2Spi6z32AXxEe/JKJkxNorFW drL70cLo/A5MtidYJyv+8FADHRXnbx/8Atvp255dT7wYr5xR2rNY7/wJFdXqxBY6 xSi6QQKBgAUNvTN8bFb+/vv4r5dCTJTc1GcFmxuBiPWMf8wTCaY4j4vQZG3tiLux IC7omk55LS0s5vYcFRCMXemul/8EQHBq+C/xQxMcYx0D82KXhkOXwPR5Q1mc0Ozr vp2fzvjsF7r55o1018gdHY1SRzpi1IDn2aiymLC+c5+qXn717tHV -----END RSA PRIVATE KEY-----"; const SEC1_EC_KEY: &str = "\ -----BEGIN EC PRIVATE KEY----- MHcCAQEEIIQyP9q8dmPTyzxgCAF22fS5w6GJP7lVrkq3gdG216TtoAoGCCqGSM49 AwEHoUQDQgAEAxC2ZMkrbwEImb3S2BD8qA9rquFbozW6d7VrhVk1tOqnIuM+vXkz ShGoCNbfNS+COlPMRAujyDlATZcLs9p4tA== -----END EC PRIVATE KEY-----"; #[test] fn test_pkcs8_key_passthrough() { let result = convert_key_to_pkcs8_pem(PKCS8_RSA_KEY).unwrap(); assert_eq!(result, PKCS8_RSA_KEY.to_string()); } #[test] fn test_pkcs1_rsa_key_conversion() { let result = convert_key_to_pkcs8_pem(PKCS1_RSA_KEY).unwrap(); assert!(result.contains("-----BEGIN PRIVATE KEY-----")); assert!(result.contains("-----END PRIVATE KEY-----")); assert!(!result.contains("RSA PRIVATE KEY")); // Verify the result is valid PKCS#8 by re-parsing let re_parsed = pem::parse(&result).unwrap(); assert_eq!(re_parsed.tag(), "PRIVATE KEY"); use pkcs8::der::Decode; pkcs8::PrivateKeyInfoRef::from_der(re_parsed.contents()).unwrap(); } #[test] fn test_sec1_ec_key_conversion() { let result = convert_key_to_pkcs8_pem(SEC1_EC_KEY).unwrap(); assert!(result.contains("-----BEGIN PRIVATE KEY-----")); assert!(result.contains("-----END PRIVATE KEY-----")); assert!(!result.contains("EC PRIVATE KEY")); // Verify the result is valid PKCS#8 by re-parsing let re_parsed = pem::parse(&result).unwrap(); assert_eq!(re_parsed.tag(), "PRIVATE KEY"); use pkcs8::der::Decode; pkcs8::PrivateKeyInfoRef::from_der(re_parsed.contents()).unwrap(); } #[test] fn test_unsupported_key_format() { let bad_pem = "-----BEGIN DSA PRIVATE KEY-----\nMIIBuwIBAAKB\n-----END DSA PRIVATE KEY-----"; let result = convert_key_to_pkcs8_pem(bad_pem); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Unsupported key format"),); } #[test] fn test_invalid_pem() { let result = convert_key_to_pkcs8_pem("not a pem"); assert!(result.is_err()); } } #[cfg(test)] mod tests { use super::*; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; async fn spawn_chunked_server(gap_ms: u64, chunks: usize) -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { loop { let Ok((mut sock, _)) = listener.accept().await else { break; }; tokio::spawn(async move { let mut buf = [0u8; 8192]; let _ = sock.read(&mut buf).await; if sock .write_all( b"HTTP/1.1 200 OK\r\n\ content-type: text/event-stream\r\n\ transfer-encoding: chunked\r\n\r\n", ) .await .is_err() { return; } for i in 0..chunks { if i > 0 { tokio::time::sleep(Duration::from_millis(gap_ms)).await; } let data = format!("data: {}\n\n", i); let chunk = format!("{:x}\r\n{}\r\n", data.len(), data); if sock.write_all(chunk.as_bytes()).await.is_err() { return; } let _ = sock.flush().await; } let _ = sock.write_all(b"0\r\n\r\n").await; }); } }); addr } fn client_with_timeout(addr: SocketAddr, timeout_ms: u64) -> ApiClient { let mut client = ApiClient::with_timeout_and_tls( format!("http://{}", addr), AuthMethod::NoAuth, Duration::from_millis(timeout_ms), None, ) .unwrap(); client.client = Client::builder() .no_proxy() .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS)) .read_timeout(client.timeout) .build() .unwrap(); client } async fn drain_counting_data_lines(mut response: Response) -> Result { let mut body = Vec::new(); while let Some(chunk) = response.chunk().await? { body.extend_from_slice(&chunk); } Ok(String::from_utf8_lossy(&body).matches("data:").count()) } #[tokio::test] async fn https_only_rejects_http_after_client_rebuild() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let client = ApiClient::new_with_tls( format!("http://{addr}"), AuthMethod::BearerToken("secret".to_string()), None, ) .unwrap() .with_https_only() .unwrap() .with_header("x-test", "value") .unwrap(); assert!(client.response_get("models").await.is_err()); assert!( tokio::time::timeout(Duration::from_millis(100), listener.accept()) .await .is_err() ); } #[tokio::test] async fn loopback_transport_rejects_remote_http_redirect() { for status in ["307 Temporary Redirect", "308 Permanent Redirect"] { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { let (mut socket, _) = listener.accept().await.unwrap(); let mut request = [0u8; 4096]; let _ = socket.read(&mut request).await; let response = format!( "HTTP/1.1 {status}\r\nLocation: http://192.0.2.1/capture\r\nContent-Length: 0\r\n\r\n" ); socket.write_all(response.as_bytes()).await.unwrap(); }); let client = ApiClient::new_with_tls( format!("http://{addr}"), AuthMethod::BearerToken("secret".to_string()), None, ) .unwrap() .with_loopback_http_only() .unwrap() .with_header("x-test", "value") .unwrap(); let error = client .response_post("chat", &serde_json::json!({ "secret": "prompt" })) .await .unwrap_err(); assert!( format!("{error:#}").contains("redirect violates the loopback transport policy"), "unexpected redirect error: {error:#}" ); } } #[tokio::test] async fn streaming_request_survives_beyond_total_timeout() { let addr = spawn_chunked_server(50, 12).await; let client = client_with_timeout(addr, 400); let response = client .request("v1/messages") .streaming(true) .response_post(&serde_json::json!({})) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let count = drain_counting_data_lines(response).await.unwrap(); assert_eq!(count, 12); } #[tokio::test] async fn streaming_request_fails_when_stream_stalls() { let addr = spawn_chunked_server(5_000, 2).await; let client = client_with_timeout(addr, 400); let response = client .request("v1/messages") .streaming(true) .response_post(&serde_json::json!({})) .await .unwrap(); let err = drain_counting_data_lines(response) .await .expect_err("stalled stream should time out, not complete"); assert!(err.is_timeout(), "expected a timeout error, got: {err}"); } #[tokio::test] async fn non_streaming_request_enforces_total_deadline() { let addr = spawn_chunked_server(50, 12).await; let client = client_with_timeout(addr, 400); let response = client .request("v1/messages") .response_post(&serde_json::json!({})) .await .unwrap(); let err = drain_counting_data_lines(response) .await .expect_err("total deadline should cut off the response body"); assert!(err.is_timeout(), "expected a timeout error, got: {err}"); } #[tokio::test] async fn streaming_request_times_out_before_response_headers() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { loop { let Ok((mut sock, _)) = listener.accept().await else { break; }; tokio::spawn(async move { let mut buf = [0u8; 8192]; while sock.read(&mut buf).await.is_ok_and(|n| n > 0) {} }); } }); let client = client_with_timeout(addr, 400); let started = std::time::Instant::now(); let err = client .request("v1/messages") .streaming(true) .response_post(&serde_json::json!({})) .await .expect_err("the phase before the response body must stay bounded"); assert!( started.elapsed() < Duration::from_secs(5), "should fail near the configured timeout, took {:?}", started.elapsed() ); assert!(matches!( crate::errors::ProviderError::from(err), crate::errors::ProviderError::NetworkError(message) if message.starts_with("Request timed out") )); } #[tokio::test] async fn streaming_error_body_shares_send_deadline() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { let (mut socket, _) = listener.accept().await.unwrap(); let mut buf = [0u8; 8192]; let _ = socket.read(&mut buf).await; tokio::time::sleep(Duration::from_millis(300)).await; socket .write_all(b"HTTP/1.1 500 Internal Server Error\r\ncontent-length: 1\r\n\r\n") .await .unwrap(); tokio::time::sleep(Duration::from_millis(300)).await; let _ = socket.write_all(b"x").await; }); let client = client_with_timeout(addr, 400); let started = std::time::Instant::now(); let response = client .request("v1/messages") .streaming(true) .response_post(&serde_json::json!({})) .await .unwrap(); crate::http_status::handle_status(response) .await .unwrap_err(); assert!( started.elapsed() < Duration::from_millis(550), "send and error body used separate deadlines: {:?}", started.elapsed() ); } #[test] fn test_model_headers_applied_and_override_static_headers() { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(async { let client = ApiClient::new_with_tls( "http://localhost:8080".to_string(), AuthMethod::NoAuth, None, ) .unwrap() .with_header("x-static", "static-value") .unwrap() .with_header("queue_threshold", "1000") .unwrap(); let model_config = crate::model::ModelConfig::new("test-model").with_request_headers( Some(std::collections::HashMap::from([ ("queue_threshold".to_string(), "500".to_string()), ("Idempotency-Key".to_string(), "abc-123".to_string()), ])), ); let request = client .request("/test") .model_headers(&model_config) .unwrap() .send_request(|url, client| client.get(url)) .await .unwrap(); let headers = request.build().unwrap().headers().clone(); let get = |name: &str| { headers .get(name) .and_then(|value| value.to_str().ok()) .map(str::to_string) }; assert_eq!(get("queue_threshold"), Some("500".to_string())); assert_eq!(get("Idempotency-Key"), Some("abc-123".to_string())); }); } #[test] fn test_model_headers_rejects_invalid_header_name() { let client = ApiClient::new_with_tls( "http://localhost:8080".to_string(), AuthMethod::NoAuth, None, ) .unwrap(); let model_config = crate::model::ModelConfig::new("test-model").with_request_headers(Some( std::collections::HashMap::from([("bad header name".to_string(), "value".to_string())]), )); assert!(client .request("/test") .model_headers(&model_config) .is_err()); } #[test] fn test_request_builder_decorator() { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(async { let client = ApiClient::new_with_tls( "http://localhost:8080".to_string(), AuthMethod::BearerToken("test-token".to_string()), None, ) .unwrap() .with_request_builder(Arc::new(|request| { Ok(request.header("test-my-session-id", "test-session_id-456")) })); let request = client .request("/test") .send_request(|url, client| client.get(url)) .await .unwrap(); let headers = request.build().unwrap().headers().clone(); let actual = headers .get("test-my-session-id") .and_then(|value| value.to_str().ok()); assert_eq!(actual, Some("test-session_id-456")); }); } }