1mod batches;
4mod connections;
5mod encode;
6mod files;
7pub(crate) mod generate;
8pub mod jobs;
9pub(crate) mod meta;
10mod pools;
11pub mod stream;
12#[cfg(feature = "watch")]
13mod watch;
14
15pub use batches::{BatchCreate, BatchListRequest, Batches};
16pub use connections::{ConnectionAdd, Connections};
17pub use encode::{EncodeRequest, ExtractRequest, ScoreRequest};
18pub use files::{FileListRequest, FileUpload, Files, SortOrder};
19pub use generate::{ChatRequest, GenerateRequest, ResponseInput, ResponsesRequest};
20pub use jobs::{
21 JobItem, JobSink, JobSource, JobSubmit, Jobs, chunk_is_retrievable, connection_name,
22 decode_chunk_payload, require_connection_name, require_connection_schema_policy,
23 require_connector_idempotency_key,
24};
25pub use pools::{PoolCreate, Pools};
26pub use stream::ChunkStream;
27#[cfg(feature = "watch")]
28pub use watch::WatchMode;
29
30use std::collections::HashMap;
31use std::sync::{Arc, Mutex};
32use std::time::Duration;
33
34use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
35use reqwest::{Method, Url};
36use serde_json::Value;
37use tokio::sync::Semaphore;
38
39use crate::error::{Error, Result, TransportErrorKind};
40use crate::http::headers::Origin;
41use crate::http::{HttpResponse, PreparedRequest, headers, version};
42use crate::retry::{Decision, RequestOptions, RetryPolicy, RetryState};
43
44#[derive(Debug, Clone, Default)]
46pub(crate) struct Routing {
47 pub pool: Option<String>,
48 pub profile: Option<String>,
49}
50
51pub(crate) struct Inner {
52 http: reqwest::Client,
53 base_url: Url,
55 base_origin: Option<Origin>,
56 control_plane_url: Option<Url>,
57 org: Option<String>,
58 timeout: Duration,
59 defaults: RequestOptions,
60 default_options: Option<Value>,
61 edge_headers: Vec<(HeaderName, HeaderValue)>,
62 #[cfg(feature = "watch")]
65 http_authorization: Option<HeaderValue>,
66 concurrency: Option<Semaphore>,
67 leases: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
69}
70
71impl Drop for Inner {
72 fn drop(&mut self) {
73 if let Ok(leases) = self.leases.lock() {
74 for handle in leases.values() {
75 handle.abort();
76 }
77 }
78 }
79}
80
81#[derive(Clone)]
86pub struct Client {
87 pub(crate) inner: Arc<Inner>,
88}
89
90impl std::fmt::Debug for Client {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("Client")
93 .field("base_url", &self.inner.base_url.as_str())
94 .field("timeout", &self.inner.timeout)
95 .finish_non_exhaustive()
96 }
97}
98
99impl Client {
100 pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
102 ClientBuilder::new(base_url).build()
103 }
104
105 pub fn builder(base_url: impl AsRef<str>) -> ClientBuilder {
107 ClientBuilder::new(base_url)
108 }
109
110 pub fn base_url(&self) -> &str {
112 self.inner.base_url.as_str()
113 }
114
115 pub fn default_options(&self) -> &RequestOptions {
117 &self.inner.defaults
118 }
119
120 pub(crate) fn timeout(&self) -> Duration {
121 self.inner.timeout
122 }
123
124 pub(crate) fn url(&self, path: &str) -> Result<Url> {
126 self.inner
127 .base_url
128 .join(path.trim_start_matches('/'))
129 .map_err(|err| Error::invalid(format!("invalid request path {path:?}: {err}")))
130 }
131
132 pub(crate) fn control_plane(&self) -> Result<(&Url, &str)> {
134 let url = self.inner.control_plane_url.as_ref().ok_or_else(|| {
135 Error::invalid("connections require a control_plane_url on the client builder")
136 })?;
137 let org =
138 self.inner.org.as_deref().ok_or_else(|| {
139 Error::invalid("connections require an org on the client builder")
140 })?;
141 Ok((url, org))
142 }
143
144 pub(crate) fn routing(&self, gpu: Option<&str>) -> Routing {
146 let resolved = gpu.or(self.inner.defaults.gpu.as_deref());
147 match resolved {
148 Some(value) => {
149 let (pool, profile) = version::parse_gpu_param(value);
150 Routing {
151 pool: pool.map(str::to_string),
152 profile: Some(profile.to_string()).filter(|p| !p.is_empty()),
153 }
154 }
155 None => Routing::default(),
156 }
157 }
158
159 pub(crate) fn merge_options(&self, options: Option<&Value>) -> Option<Value> {
161 match (self.inner.default_options.as_ref(), options) {
162 (None, other) => other.cloned(),
163 (Some(defaults), None) => Some(defaults.clone()),
164 (Some(defaults), Some(overrides)) => {
165 let mut merged = defaults.as_object().cloned().unwrap_or_default();
166 if let Some(overrides) = overrides.as_object() {
167 for (key, value) in overrides {
168 merged.insert(key.clone(), value.clone());
169 }
170 }
171 Some(Value::Object(merged))
172 }
173 }
174 }
175
176 pub(crate) fn request_options(&self) -> RequestOptions {
178 self.inner.defaults.clone()
179 }
180
181 pub(crate) fn metadata_options(&self) -> RequestOptions {
183 RequestOptions {
184 gpu: None,
185 wait_for_capacity: false,
186 provision_timeout: self.inner.timeout.max(Duration::from_secs(1)),
187 max_oom_retries: 0,
188 }
189 }
190
191 #[cfg(feature = "watch")]
193 pub(crate) fn authorization_header(&self) -> Option<HeaderValue> {
194 self.inner.http_authorization.clone()
195 }
196
197 #[cfg(feature = "watch")]
199 pub(crate) fn edge_headers(&self) -> &[(HeaderName, HeaderValue)] {
200 &self.inner.edge_headers
201 }
202
203 #[cfg(feature = "watch")]
205 pub(crate) fn websocket_matches_base_origin(&self, url: &Url) -> bool {
206 !self.inner.edge_headers.is_empty()
207 && self
208 .inner
209 .base_origin
210 .as_ref()
211 .is_some_and(|origin| origin.matches_websocket(url))
212 }
213
214 pub(crate) fn edge_headers_apply_to(&self, url: &Url) -> bool {
215 !self.inner.edge_headers.is_empty()
216 && self
217 .inner
218 .base_origin
219 .as_ref()
220 .is_some_and(|origin| Origin::parse(url).is_some_and(|target| target == *origin))
221 }
222
223 fn build_attempt(
224 &self,
225 request: &PreparedRequest,
226 timeout: Duration,
227 allow_edge: bool,
228 ) -> reqwest::RequestBuilder {
229 let mut builder = self
230 .inner
231 .http
232 .request(request.method.clone(), request.url.clone())
233 .timeout(timeout)
234 .headers(request.headers.clone());
235
236 if allow_edge && self.edge_headers_apply_to(&request.url) {
237 for (name, value) in &self.inner.edge_headers {
238 builder = builder.header(name.clone(), value.clone());
239 }
240 }
241 match &request.body {
242 Some(body) => builder.body(body.clone()),
243 None => builder,
244 }
245 }
246
247 async fn permit(&self) -> Option<tokio::sync::SemaphorePermit<'_>> {
248 match &self.inner.concurrency {
249 Some(semaphore) => semaphore.acquire().await.ok(),
251 None => None,
252 }
253 }
254
255 pub(crate) async fn send(
257 &self,
258 request: PreparedRequest,
259 state: &mut RetryState,
260 ) -> Result<HttpResponse> {
261 loop {
262 let timeout = state.attempt_timeout(self.inner.timeout)?;
263 let outcome = {
264 let _permit = self.permit().await;
265 match self.build_attempt(&request, timeout, true).send().await {
266 Ok(response) => HttpResponse::read(response).await,
267 Err(error) => Err(error),
268 }
269 };
270
271 let response = match outcome {
272 Ok(response) => response,
273 Err(error) => {
274 let delay = state.on_transport_error(&error, self.base_url())?;
275 tokio::time::sleep(delay).await;
276 continue;
277 }
278 };
279
280 match state.on_response(&response)? {
281 Decision::Accept => {
282 Self::check_version(&response);
283 return Ok(response);
284 }
285 Decision::Retry(delay) => tokio::time::sleep(delay).await,
286 }
287 }
288 }
289
290 pub(crate) async fn send_streaming(
295 &self,
296 request: &PreparedRequest,
297 state: &mut RetryState,
298 ) -> Result<StreamAttempt> {
299 loop {
300 let timeout = state.attempt_timeout(self.inner.timeout)?;
301 let attempt = {
304 let _permit = self.permit().await;
305 self.build_attempt(request, timeout, true).send().await
306 };
307
308 let response = match attempt {
309 Ok(response) => response,
310 Err(error) => {
311 let delay = state.on_transport_error(&error, self.base_url())?;
312 tokio::time::sleep(delay).await;
313 continue;
314 }
315 };
316
317 if response.status().is_success() {
318 let buffered = HttpResponse {
319 status: response.status().as_u16(),
320 headers: response.headers().clone(),
321 body: bytes::Bytes::new(),
322 };
323 Self::check_version(&buffered);
324 return Ok(StreamAttempt {
325 headers: buffered.headers,
326 response,
327 });
328 }
329
330 let buffered = HttpResponse::read(response).await.map_err(|error| {
331 Error::connection(TransportErrorKind::MidFlight, error.to_string(), error)
332 })?;
333 match state.on_response(&buffered)? {
334 Decision::Accept => {
337 return Err(crate::wire::handle_error(&buffered, None, state.retries()));
338 }
339 Decision::Retry(delay) => tokio::time::sleep(delay).await,
340 }
341 }
342 }
343
344 fn check_version(response: &HttpResponse) {
346 if let Some(server_version) = response.header(headers::SERVER_VERSION) {
347 version::warn_once(server_version);
348 }
349 }
350
351 pub(crate) async fn send_once(
353 &self,
354 request: PreparedRequest,
355 policy: RetryPolicy,
356 ) -> Result<HttpResponse> {
357 let mut state = RetryState::new(policy, &self.metadata_options(), None);
358 self.send(request, &mut state).await
359 }
360
361 pub(crate) async fn send_with_timeout(
366 &self,
367 request: PreparedRequest,
368 policy: RetryPolicy,
369 floor: Duration,
370 ) -> Result<HttpResponse> {
371 let options = RequestOptions {
372 provision_timeout: self.inner.timeout.max(floor),
373 ..self.metadata_options()
374 };
375 let mut state = RetryState::new(policy, &options, None);
376 self.send(request, &mut state).await
377 }
378
379 pub(crate) fn request(&self, method: Method, path: &str) -> Result<PreparedRequest> {
381 Ok(PreparedRequest::new(method, self.url(path)?))
382 }
383}
384
385pub(crate) struct StreamAttempt {
387 pub headers: HeaderMap,
388 pub response: reqwest::Response,
389}
390
391pub struct ClientBuilder {
393 base_url: String,
394 api_key: Option<String>,
395 timeout: Duration,
396 gpu: Option<String>,
397 options: Option<Value>,
398 max_connections: Option<usize>,
399 max_concurrency: Option<usize>,
400 control_plane_url: Option<String>,
401 org: Option<String>,
402 base_url_headers: HashMap<String, String>,
403 wait_for_capacity: bool,
404 provision_timeout: Duration,
405 max_oom_retries: u32,
406}
407
408impl ClientBuilder {
409 fn new(base_url: impl AsRef<str>) -> Self {
410 let defaults = RequestOptions::default();
411 Self {
412 base_url: base_url.as_ref().to_string(),
413 api_key: None,
414 timeout: Duration::from_secs(30),
415 gpu: None,
416 options: None,
417 max_connections: None,
418 max_concurrency: None,
419 control_plane_url: None,
420 org: None,
421 base_url_headers: HashMap::new(),
422 wait_for_capacity: defaults.wait_for_capacity,
423 provision_timeout: defaults.provision_timeout,
424 max_oom_retries: defaults.max_oom_retries,
425 }
426 }
427
428 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
430 self.api_key = Some(api_key.into());
431 self
432 }
433
434 pub fn timeout(mut self, timeout: Duration) -> Self {
436 self.timeout = timeout;
437 self
438 }
439
440 pub fn gpu(mut self, gpu: impl Into<String>) -> Self {
442 self.gpu = Some(gpu.into());
443 self
444 }
445
446 pub fn options(mut self, options: Value) -> Self {
448 self.options = Some(options);
449 self
450 }
451
452 pub fn max_connections(mut self, max: usize) -> Self {
454 self.max_connections = Some(max);
455 self
456 }
457
458 pub fn max_concurrency(mut self, max: usize) -> Self {
460 self.max_concurrency = Some(max);
461 self
462 }
463
464 pub fn control_plane_url(mut self, url: impl Into<String>) -> Self {
466 self.control_plane_url = Some(url.into());
467 self
468 }
469
470 pub fn org(mut self, org: impl Into<String>) -> Self {
472 self.org = Some(org.into());
473 self
474 }
475
476 pub fn base_url_headers(mut self, headers: HashMap<String, String>) -> Self {
481 self.base_url_headers = headers;
482 self
483 }
484
485 pub fn wait_for_capacity(mut self, wait: bool) -> Self {
487 self.wait_for_capacity = wait;
488 self
489 }
490
491 pub fn provision_timeout(mut self, timeout: Duration) -> Self {
493 self.provision_timeout = timeout;
494 self
495 }
496
497 pub fn max_oom_retries(mut self, retries: u32) -> Self {
499 self.max_oom_retries = retries;
500 self
501 }
502
503 pub fn build(self) -> Result<Client> {
505 let trimmed = self.base_url.trim_end_matches('/');
506 let base_url = Url::parse(&format!("{trimmed}/")).map_err(|err| {
507 Error::invalid(format!("invalid base_url {:?}: {err}", self.base_url))
508 })?;
509 let base_origin = Origin::parse(&base_url);
510
511 let edge_pairs = headers::validate_base_url_headers(&self.base_url_headers)?;
512 if !edge_pairs.is_empty()
513 && !base_origin
514 .as_ref()
515 .is_some_and(Origin::accepts_credentials)
516 {
517 return Err(Error::invalid(
518 "base_url_headers require an absolute https base_url without embedded credentials",
519 ));
520 }
521 let mut edge_headers = Vec::with_capacity(edge_pairs.len());
522 for (name, value) in edge_pairs {
523 let name = HeaderName::from_bytes(name.as_bytes())
524 .map_err(|err| Error::invalid(format!("invalid base_url_headers name: {err}")))?;
525 let value = HeaderValue::from_str(&value)
526 .map_err(|err| Error::invalid(format!("invalid base_url_headers value: {err}")))?;
527 edge_headers.push((name, value));
528 }
529
530 let control_plane_url = self
531 .control_plane_url
532 .as_deref()
533 .map(|url| {
534 Url::parse(&format!("{}/", url.trim_end_matches('/'))).map_err(|err| {
535 Error::invalid(format!("invalid control_plane_url {url:?}: {err}"))
536 })
537 })
538 .transpose()?;
539
540 let mut default_headers = HeaderMap::new();
541 default_headers.insert(
542 HeaderName::from_static("x-sie-sdk-version"),
543 HeaderValue::from_static(version::SDK_VERSION),
544 );
545 #[cfg(feature = "watch")]
546 let mut http_authorization = None;
547 if let Some(api_key) = &self.api_key {
548 let mut value = HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|_| {
549 Error::invalid("api_key contains characters that cannot be sent in a header")
550 })?;
551 value.set_sensitive(true);
552 #[cfg(feature = "watch")]
553 {
554 http_authorization = Some(value.clone());
555 }
556 default_headers.insert(reqwest::header::AUTHORIZATION, value);
557 }
558
559 let mut http = reqwest::Client::builder()
560 .redirect(reqwest::redirect::Policy::none())
563 .default_headers(default_headers);
564 if let Some(max) = self.max_connections {
565 http = http.pool_max_idle_per_host(max);
566 }
567 let http = http
568 .build()
569 .map_err(|err| Error::invalid(format!("could not build the HTTP client: {err}")))?;
570
571 Ok(Client {
572 inner: Arc::new(Inner {
573 http,
574 base_url,
575 base_origin,
576 control_plane_url,
577 org: self.org,
578 timeout: self.timeout,
579 defaults: RequestOptions {
580 gpu: self.gpu,
581 wait_for_capacity: self.wait_for_capacity,
582 provision_timeout: self.provision_timeout,
583 max_oom_retries: self.max_oom_retries,
584 },
585 default_options: self.options,
586 edge_headers,
587 #[cfg(feature = "watch")]
588 http_authorization,
589 concurrency: self.max_concurrency.map(Semaphore::new),
590 leases: Mutex::new(HashMap::new()),
591 }),
592 })
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use serde_json::json;
600
601 fn client() -> Client {
602 Client::new("https://sie.example.com").unwrap()
603 }
604
605 #[test]
606 fn base_url_always_ends_in_a_slash() {
607 assert_eq!(client().base_url(), "https://sie.example.com/");
608 assert_eq!(
609 Client::new("https://sie.example.com///")
610 .unwrap()
611 .base_url(),
612 "https://sie.example.com/"
613 );
614 }
615
616 #[test]
617 fn paths_join_onto_the_base_url() {
618 let client = Client::new("https://sie.example.com/prefix").unwrap();
619 assert_eq!(
620 client.url("/v1/models").unwrap().as_str(),
621 "https://sie.example.com/prefix/v1/models"
622 );
623 assert_eq!(
624 client.url("v1/models").unwrap().as_str(),
625 "https://sie.example.com/prefix/v1/models"
626 );
627 }
628
629 #[test]
630 fn edge_headers_require_an_https_origin() {
631 let headers = HashMap::from([("Modal-Key".to_string(), "k".to_string())]);
632 assert!(
633 Client::builder("https://sie.example.com")
634 .base_url_headers(headers.clone())
635 .build()
636 .is_ok()
637 );
638 let err = Client::builder("http://localhost:8080")
639 .base_url_headers(headers)
640 .build()
641 .unwrap_err();
642 assert!(err.to_string().contains("https base_url"), "{err}");
643 }
644
645 #[test]
646 fn edge_headers_are_scoped_to_the_base_origin() {
647 let client = Client::builder("https://sie.example.com")
648 .base_url_headers(HashMap::from([("Modal-Key".to_string(), "k".to_string())]))
649 .build()
650 .unwrap();
651 assert!(
652 client.edge_headers_apply_to(&Url::parse("https://sie.example.com/v1/models").unwrap())
653 );
654 assert!(
655 !client
656 .edge_headers_apply_to(&Url::parse("https://other.example.com/v1/models").unwrap())
657 );
658 assert!(
659 !client.edge_headers_apply_to(&Url::parse("https://sie.example.com:8443/v1").unwrap())
660 );
661 assert!(!client.edge_headers_apply_to(&Url::parse("http://sie.example.com/v1").unwrap()));
662 }
663
664 #[test]
665 fn routing_splits_pool_from_profile() {
666 let client = Client::builder("https://sie.example.com")
667 .gpu("prod/l4")
668 .build()
669 .unwrap();
670 let routing = client.routing(None);
671 assert_eq!(routing.pool.as_deref(), Some("prod"));
672 assert_eq!(routing.profile.as_deref(), Some("l4"));
673
674 let overridden = client.routing(Some("a100-80gb"));
675 assert!(overridden.pool.is_none());
676 assert_eq!(overridden.profile.as_deref(), Some("a100-80gb"));
677
678 let bare = Client::new("https://sie.example.com")
679 .unwrap()
680 .routing(None);
681 assert!(bare.pool.is_none() && bare.profile.is_none());
682 }
683
684 #[test]
685 fn per_call_options_override_client_defaults() {
686 let client = Client::builder("https://sie.example.com")
687 .options(json!({"is_query": true, "keep": 1}))
688 .build()
689 .unwrap();
690 let merged = client
691 .merge_options(Some(&json!({"is_query": false})))
692 .unwrap();
693 assert_eq!(merged, json!({"is_query": false, "keep": 1}));
694 assert_eq!(
695 client.merge_options(None).unwrap(),
696 json!({"is_query": true, "keep": 1})
697 );
698 assert!(
699 Client::new("https://x.example.com")
700 .unwrap()
701 .merge_options(None)
702 .is_none()
703 );
704 }
705
706 #[test]
707 fn connections_need_a_control_plane_and_org() {
708 assert!(client().control_plane().is_err());
709 let configured = Client::builder("https://sie.example.com")
710 .control_plane_url("https://cp.example.com/")
711 .org("acme")
712 .build()
713 .unwrap();
714 let (url, org) = configured.control_plane().unwrap();
715 assert_eq!(url.as_str(), "https://cp.example.com/");
716 assert_eq!(org, "acme");
717 }
718
719 #[test]
720 fn rejects_a_malformed_base_url() {
721 assert!(Client::new("not a url").is_err());
722 }
723}