1use std::fmt;
4use std::future::{Future, IntoFuture};
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use bytes::Bytes;
11use http::header::{
12 ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT,
13};
14use http::{Method, StatusCode};
15use serde::Serialize;
16use serde_json::{Map, Value};
17
18use crate::cassette;
19use crate::constants::*;
20use crate::error::{ApiError, Error, ResponseValidationError, Result, lenient_body};
21use crate::question::Questions;
22use crate::response::{
23 DecodeFailure, DecodedSystemOne, ListModelsResponse, ResponseMeta, SystemOneResponse,
24 decode_models, decode_system_one,
25};
26use crate::retry::RetryPolicy;
27
28pub(crate) type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
29
30#[must_use = "a builder does nothing until .build() is called"]
35#[derive(Default)]
36pub struct ClientBuilder {
37 api_key: Option<String>,
38 base_url: Option<String>,
39 model: Option<String>,
40 timeout: Option<Duration>,
41 retry: Option<RetryPolicy>,
42 headers: HeaderMap,
43 http: Option<reqwest::Client>,
44 record: Option<PathBuf>,
45 replay: Option<PathBuf>,
46}
47
48impl fmt::Debug for ClientBuilder {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.debug_struct("ClientBuilder")
51 .field("api_key", &self.api_key.as_ref().map(|_| "***"))
52 .field("base_url", &self.base_url)
53 .field("model", &self.model)
54 .field("timeout", &self.timeout)
55 .field("retry", &self.retry)
56 .field("headers", &redacted(&self.headers))
57 .field("http", &self.http)
58 .field("record", &self.record)
59 .field("replay", &self.replay)
60 .finish()
61 }
62}
63
64impl ClientBuilder {
65 pub fn api_key(mut self, key: impl Into<String>) -> Self {
68 self.api_key = Some(key.into());
69 self
70 }
71
72 pub fn base_url(mut self, url: impl Into<String>) -> Self {
74 self.base_url = Some(url.into());
75 self
76 }
77
78 pub fn model(mut self, model: impl Into<String>) -> Self {
80 self.model = Some(model.into());
81 self
82 }
83
84 pub fn timeout(mut self, timeout: Duration) -> Self {
86 self.timeout = Some(timeout);
87 self
88 }
89
90 pub fn retry(mut self, policy: RetryPolicy) -> Self {
92 self.retry = Some(policy);
93 self
94 }
95
96 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
99 self.headers.insert(name, value);
100 self
101 }
102
103 pub fn record(mut self, dir: impl Into<PathBuf>) -> Self {
106 self.record = Some(dir.into());
107 self
108 }
109
110 pub fn replay(mut self, dir: impl Into<PathBuf>) -> Self {
114 self.replay = Some(dir.into());
115 self
116 }
117
118 #[cfg(feature = "reqwest-client")]
121 #[cfg_attr(docsrs, doc(cfg(feature = "reqwest-client")))]
122 pub fn http_client(mut self, client: reqwest::Client) -> Self {
123 self.http = Some(client);
124 self
125 }
126
127 #[cfg(feature = "blocking")]
133 #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
134 pub fn build_blocking(self) -> Result<crate::blocking::Client> {
135 crate::blocking::Client::new(self.build()?)
136 }
137
138 pub fn build(self) -> Result<Client> {
147 let cassette = match (
148 resolve_path(self.record, RECORD_ENV),
149 resolve_path(self.replay, REPLAY_ENV),
150 ) {
151 (Some(_), Some(_)) => {
152 return Err(Error::config(format!(
153 "both record and replay are set, and a client does one or the other; unset \
154 {RECORD_ENV} or {REPLAY_ENV}, or drop one of the builder calls"
155 )));
156 }
157 (Some(dir), None) => {
158 std::fs::create_dir_all(&dir).map_err(|e| {
159 Error::config_caused(format!("cannot record into {}", dir.display()), e)
160 })?;
161 Some(Cassette::Record(dir))
162 }
163 (None, Some(dir)) => Some(Cassette::Replay(dir)),
164 (None, None) => None,
165 };
166 let replaying = matches!(cassette, Some(Cassette::Replay(_)));
167 let api_key = resolve(self.api_key, API_KEY_ENV, None)
169 .map(|k| k.trim().to_owned())
170 .filter(|k| !k.is_empty());
171 if api_key.is_none() && !replaying {
172 return Err(Error::config(format!(
173 "no API key was provided; pass api_key or set the {API_KEY_ENV} environment variable"
174 )));
175 }
176 if api_key
177 .as_deref()
178 .is_some_and(|k| !k.bytes().all(|b| b.is_ascii_graphic()))
179 {
180 return Err(Error::config(
181 "the API key must contain only printable ASCII characters without whitespace",
182 ));
183 }
184 let base_url = resolve(self.base_url, BASE_URL_ENV, Some(DEFAULT_BASE_URL))
185 .unwrap_or_default()
186 .trim_end_matches('/')
187 .to_owned();
188 check_base_url(&base_url)?;
189 let model = resolve(self.model, DEFAULT_MODEL_ENV, Some(DEFAULT_MODEL)).unwrap_or_default();
190 let timeout = check_timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))?;
191 let retry = self.retry.unwrap_or_default();
192 retry.validate()?;
193
194 let mut protected = HeaderMap::new();
195 if let Some(api_key) = api_key {
196 let mut auth = HeaderValue::from_str(&format!("Bearer {api_key}"))
197 .map_err(|e| Error::config_caused("the API key is not a valid header value", e))?;
198 auth.set_sensitive(true);
199 protected.insert(AUTHORIZATION, auth);
200 }
201 protected.insert(ACCEPT, HeaderValue::from_static("application/json"));
202 let ident = HeaderValue::from_str(&format!("{SDK_NAME}/{VERSION}")).expect("ascii");
203 protected.insert(USER_AGENT, ident.clone());
204 protected.insert(HeaderName::from_static(SDK_HEADER), ident);
205 protected.insert(
206 HeaderName::from_static(RUNTIME_HEADER),
207 HeaderValue::from_str(&format!(
208 "rust ({}; {})",
209 std::env::consts::OS,
210 std::env::consts::ARCH
211 ))
212 .expect("ascii"),
213 );
214
215 Ok(Client {
216 inner: Arc::new(Inner {
217 http: self.http.unwrap_or_default(),
218 base_url,
219 model,
220 timeout,
221 retry,
222 default_headers: self.headers,
223 protected,
224 cassette,
225 }),
226 })
227 }
228}
229
230fn resolve(explicit: Option<String>, env: &str, default: Option<&str>) -> Option<String> {
231 explicit
232 .or_else(|| {
233 std::env::var(env)
234 .ok()
235 .map(|v| v.trim().to_owned())
236 .filter(|v| !v.is_empty())
237 })
238 .or_else(|| default.map(str::to_owned))
239}
240
241fn resolve_path(explicit: Option<PathBuf>, env: &str) -> Option<PathBuf> {
242 explicit.or_else(|| resolve(None, env, None).map(PathBuf::from))
243}
244
245fn check_base_url(url: &str) -> Result<()> {
246 match reqwest::Url::parse(url) {
247 Ok(u) if matches!(u.scheme(), "http" | "https") && u.has_host() => Ok(()),
248 Ok(_) => Err(Error::config(format!(
249 "base_url must be an http(s) URL with a host, got {url:?}"
250 ))),
251 Err(e) => Err(Error::config_caused(
252 format!("base_url {url:?} is not a valid URL"),
253 e,
254 )),
255 }
256}
257
258fn check_timeout(t: Duration) -> Result<Duration> {
259 if t.is_zero() {
260 Err(Error::config("timeout must be a positive duration"))
261 } else {
262 Ok(t)
263 }
264}
265
266struct Inner {
267 http: reqwest::Client,
268 base_url: String,
269 model: String,
270 timeout: Duration,
271 retry: RetryPolicy,
272 default_headers: HeaderMap,
273 protected: HeaderMap,
274 cassette: Option<Cassette>,
275}
276
277impl fmt::Debug for Inner {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 f.debug_struct("Inner")
281 .field("http", &self.http)
282 .field("base_url", &self.base_url)
283 .field("model", &self.model)
284 .field("timeout", &self.timeout)
285 .field("retry", &self.retry)
286 .field("default_headers", &redacted(&self.default_headers))
287 .field("protected", &redacted(&self.protected))
288 .field("cassette", &self.cassette)
289 .finish()
290 }
291}
292
293#[derive(Debug)]
295enum Cassette {
296 Record(PathBuf),
297 Replay(PathBuf),
298}
299
300#[derive(Debug, Clone)]
322pub struct Client {
323 inner: Arc<Inner>,
324}
325
326impl Client {
327 pub fn builder() -> ClientBuilder {
329 ClientBuilder::default()
330 }
331
332 pub fn from_env() -> Result<Self> {
338 Self::builder().build()
339 }
340
341 pub fn default_model(&self) -> &str {
343 &self.inner.model
344 }
345
346 pub fn system_one<S: Serialize>(
349 &self,
350 state: S,
351 questions: impl Into<Questions>,
352 ) -> SystemOneRequest {
353 SystemOneRequest {
354 client: self.clone(),
355 state: serde_json::to_value(state),
356 questions: questions.into(),
357 model: None,
358 extra_body: Map::new(),
359 opts: CallOptions::default(),
360 }
361 }
362
363 pub fn models(&self) -> Models {
365 Models {
366 client: self.clone(),
367 }
368 }
369
370 async fn execute(
371 &self,
372 method: Method,
373 path: &str,
374 body: Option<Bytes>,
375 opts: CallOptions,
376 ) -> Result<(Bytes, ResponseMeta, String)> {
377 let inner = &self.inner;
378 let retry = opts.retry.as_ref().unwrap_or(&inner.retry);
379 retry.validate()?;
380 let timeout = check_timeout(opts.timeout.unwrap_or(inner.timeout))?;
381 let url = format!("{}{}", inner.base_url, path);
382 let endpoint = format!("{method} {}", redact_url(&url));
383
384 let mut headers = inner.default_headers.clone();
385 headers.extend(opts.headers);
386 headers.remove(RETRY_COUNT_HEADER);
387 headers.extend(inner.protected.clone());
388 if body.is_some() {
389 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
390 }
391
392 let started = Instant::now();
393 let mut attempts: u32 = 0;
394 loop {
395 let mut h = headers.clone();
396 if attempts > 0 {
397 h.insert(
398 HeaderName::from_static(RETRY_COUNT_HEADER),
399 HeaderValue::from(attempts),
400 );
401 tracing::info!(%endpoint, retry = attempts, "retrying");
402 }
403 attempts += 1;
404 let result = self
406 .attempt(&method, &url, &endpoint, h, body.clone(), timeout)
407 .await;
408 match result {
409 Ok((bytes, status, resp_headers)) => {
410 let meta = ResponseMeta {
411 status,
412 headers: resp_headers,
413 attempts,
414 };
415 return Ok((bytes, meta, endpoint));
416 }
417 Err(err) => {
418 if !retry.is_retryable(&err) {
419 return Err(err);
420 }
421 let delay = retry.delay(attempts, &err);
422 if retry.should_stop(attempts, started.elapsed(), delay) {
423 return Err(err);
424 }
425 if !delay.is_zero() {
426 tokio::time::sleep(delay).await;
427 }
428 }
429 }
430 }
431 }
432
433 async fn attempt(
434 &self,
435 method: &Method,
436 url: &str,
437 endpoint: &str,
438 headers: HeaderMap,
439 body: Option<Bytes>,
440 timeout: Duration,
441 ) -> Result<(Bytes, StatusCode, HeaderMap)> {
442 let t0 = Instant::now();
443 tracing::debug!(%endpoint, "->");
444 if tracing::enabled!(tracing::Level::TRACE) {
445 tracing::trace!(%endpoint, headers = ?redacted(&headers),
446 body = %body.as_deref().map(String::from_utf8_lossy).unwrap_or_default(), "->");
447 }
448 let mut req = self
449 .inner
450 .http
451 .request(method.clone(), url)
452 .headers(headers)
453 .timeout(timeout);
454 if let Some(b) = body {
455 req = req.body(b);
456 }
457 let map_err = |e: reqwest::Error| {
458 tracing::debug!(%endpoint, error = %e, "<- transport error");
459 if e.is_timeout() {
460 Error::Timeout(timeout)
461 } else {
462 Error::Connection(Box::new(e))
463 }
464 };
465 let resp = req.send().await.map_err(map_err)?;
466 let status = resp.status();
467 let resp_headers = resp.headers().clone();
468 let bytes = resp.bytes().await.map_err(map_err)?;
469
470 tracing::debug!(
471 %endpoint,
472 status = status.as_u16(),
473 elapsed_ms = t0.elapsed().as_millis() as u64,
474 request_id = resp_headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()).unwrap_or("-"),
475 "<-"
476 );
477 if tracing::enabled!(tracing::Level::TRACE) {
478 tracing::trace!(%endpoint, headers = ?redacted(&resp_headers),
479 body = %String::from_utf8_lossy(&bytes), "<-");
480 }
481
482 if !status.is_success() {
483 return Err(Error::Api(Box::new(ApiError::new(
484 status,
485 lenient_body(&bytes),
486 resp_headers,
487 Some(endpoint.to_owned()),
488 ))));
489 }
490 Ok((bytes, status, resp_headers))
491 }
492}
493
494fn validation_error(
495 status: StatusCode,
496 body: Option<Value>,
497 headers: HeaderMap,
498 endpoint: &str,
499 f: DecodeFailure,
500) -> Error {
501 Error::ResponseValidation(Box::new(ResponseValidationError {
502 status,
503 field_path: f.path,
504 detail: f.detail,
505 body,
506 headers,
507 endpoint: Some(endpoint.to_owned()),
508 }))
509}
510
511fn redact_url(url: &str) -> String {
512 match reqwest::Url::parse(url) {
513 Ok(mut u) => {
514 let _ = u.set_username("");
515 let _ = u.set_password(None);
516 u.set_query(None);
517 u.set_fragment(None);
518 u.to_string()
519 }
520 Err(_) => url.to_owned(),
521 }
522}
523
524fn is_secret(name: &str) -> bool {
527 let name = name.to_ascii_lowercase();
528 SECRET_HEADERS.contains(&name.as_str())
529 || ["authorization", "api-key", "token", "secret"]
530 .iter()
531 .any(|part| name.contains(part))
532}
533
534fn redacted(headers: &HeaderMap) -> Vec<(String, String)> {
535 headers
536 .iter()
537 .map(|(k, v)| {
538 let value = if is_secret(k.as_str()) {
539 "[REDACTED]".to_owned()
540 } else {
541 v.to_str().unwrap_or("<binary>").to_owned()
542 };
543 (k.as_str().to_owned(), value)
544 })
545 .collect()
546}
547
548#[derive(Debug, Default, Clone)]
549struct CallOptions {
550 retry: Option<RetryPolicy>,
551 timeout: Option<Duration>,
552 headers: HeaderMap,
553}
554
555macro_rules! call_option_methods {
556 () => {
557 pub fn retry(mut self, policy: RetryPolicy) -> Self {
559 self.opts.retry = Some(policy);
560 self
561 }
562
563 pub fn timeout(mut self, timeout: Duration) -> Self {
565 self.opts.timeout = Some(timeout);
566 self
567 }
568
569 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
571 self.opts.headers.insert(name, value);
572 self
573 }
574 };
575}
576
577#[must_use = "requests do nothing until awaited"]
579#[derive(Debug)]
580pub struct SystemOneRequest {
581 client: Client,
582 state: serde_json::Result<Value>,
583 questions: Questions,
584 model: Option<String>,
585 extra_body: Map<String, Value>,
586 opts: CallOptions,
587}
588
589impl SystemOneRequest {
590 call_option_methods!();
591
592 pub fn model(mut self, model: impl Into<String>) -> Self {
594 self.model = Some(model.into());
595 self
596 }
597
598 pub fn extra_body(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
601 self.extra_body.insert(key.into(), value.into());
602 self
603 }
604
605 pub async fn send(self) -> Result<SystemOneResponse> {
617 let state = self.state.map_err(|e| {
618 Error::invalid_request_caused("the state could not be encoded as JSON", e)
619 })?;
620 self.questions.validate()?;
621 let model = self
622 .model
623 .unwrap_or_else(|| self.client.inner.model.clone());
624 let extra = &self.extra_body;
627 let body = SystemOneBody {
628 state: (!extra.contains_key("state")).then_some(&state),
629 model: (!extra.contains_key("model")).then_some(&model),
630 questions: (!extra.contains_key("questions")).then_some(&self.questions),
631 extra,
632 };
633 let bytes = serde_json::to_vec(&body).map_err(|e| {
634 Error::invalid_request_caused("the request body could not be encoded as JSON", e)
635 })?;
636
637 let cassette = self
639 .client
640 .inner
641 .cassette
642 .as_ref()
643 .map(|c| (c, cassette::body_key(&bytes)));
644 let (bytes, meta, endpoint) = match &cassette {
645 Some((Cassette::Replay(dir), key)) => replay(dir, key)?,
646 _ => {
647 self.client
648 .execute(Method::POST, SYSTEM_ONE_PATH, Some(bytes.into()), self.opts)
649 .await?
650 }
651 };
652 let decoded = decode_system_one(&bytes);
653 if let (Ok(_), Some((Cassette::Record(dir), key))) = (&decoded, &cassette)
654 && let Err(e) = cassette::write(dir, key, &bytes)
655 {
656 tracing::warn!(dir = %dir.display(), %key, error = %e, "could not record the response");
657 }
658 match decoded {
659 Ok(DecodedSystemOne {
660 model,
661 usage,
662 answers,
663 raw,
664 }) => Ok(SystemOneResponse {
665 model,
666 usage,
667 answers,
668 raw,
669 meta,
670 }),
671 Err(f) => Err(validation_error(
672 meta.status,
673 lenient_body(&bytes),
674 meta.headers,
675 &endpoint,
676 f,
677 )),
678 }
679 }
680}
681
682fn replay(dir: &std::path::Path, key: &str) -> Result<(Bytes, ResponseMeta, String)> {
684 let path = cassette::path(dir, key);
685 let bytes = std::fs::read(&path).map_err(|e| match e.kind() {
686 std::io::ErrorKind::NotFound => Error::ReplayMiss {
687 key: key.to_owned(),
688 path: path.clone(),
689 },
690 _ => Error::config_caused(format!("cannot read the recording {}", path.display()), e),
691 })?;
692 tracing::debug!(path = %path.display(), "<- replayed");
693 let meta = ResponseMeta {
694 status: StatusCode::OK,
695 headers: HeaderMap::new(),
696 attempts: 0,
697 };
698 Ok((bytes.into(), meta, format!("replay {}", path.display())))
699}
700
701#[derive(Serialize)]
702struct SystemOneBody<'a> {
703 #[serde(skip_serializing_if = "Option::is_none")]
704 state: Option<&'a Value>,
705 #[serde(skip_serializing_if = "Option::is_none")]
706 model: Option<&'a str>,
707 #[serde(skip_serializing_if = "Option::is_none")]
708 questions: Option<&'a Questions>,
709 #[serde(flatten)]
710 extra: &'a Map<String, Value>,
711}
712
713impl IntoFuture for SystemOneRequest {
714 type Output = Result<SystemOneResponse>;
715 type IntoFuture = BoxFuture<'static, Self::Output>;
716
717 fn into_future(self) -> Self::IntoFuture {
718 Box::pin(self.send())
719 }
720}
721
722#[derive(Debug, Clone)]
724pub struct Models {
725 client: Client,
726}
727
728impl Models {
729 pub fn list(&self) -> ListModelsRequest {
731 ListModelsRequest {
732 client: self.client.clone(),
733 opts: CallOptions::default(),
734 }
735 }
736}
737
738#[must_use = "requests do nothing until awaited"]
740#[derive(Debug)]
741pub struct ListModelsRequest {
742 client: Client,
743 opts: CallOptions,
744}
745
746impl ListModelsRequest {
747 call_option_methods!();
748
749 pub async fn send(self) -> Result<ListModelsResponse> {
758 if let Some(Cassette::Replay(dir)) = &self.client.inner.cassette {
759 return Err(Error::config(format!(
760 "listing models is not recorded, so a client replaying from {} cannot answer it",
761 dir.display()
762 )));
763 }
764 let (bytes, meta, endpoint) = self
765 .client
766 .execute(Method::GET, MODELS_PATH, None, self.opts)
767 .await?;
768 match decode_models(&bytes) {
769 Ok((models, raw)) => Ok(ListModelsResponse { models, raw, meta }),
770 Err(f) => Err(validation_error(
771 meta.status,
772 lenient_body(&bytes),
773 meta.headers,
774 &endpoint,
775 f,
776 )),
777 }
778 }
779}
780
781impl IntoFuture for ListModelsRequest {
782 type Output = Result<ListModelsResponse>;
783 type IntoFuture = BoxFuture<'static, Self::Output>;
784
785 fn into_future(self) -> Self::IntoFuture {
786 Box::pin(self.send())
787 }
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn debug_output_hides_the_key() {
796 let builder = Client::builder()
797 .api_key("sk-very-secret")
798 .header(
799 HeaderName::from_static("x-portkey-api-key"),
800 HeaderValue::from_static("gw-very-secret"),
801 )
802 .base_url("https://example.test");
803 let shown = format!("{builder:?}");
804 assert!(!shown.contains("very-secret"), "{shown}");
805 assert!(
806 shown.contains("***") && shown.contains("example.test"),
807 "{shown}"
808 );
809
810 let client = builder.build().unwrap();
811 let shown = format!("{client:?}");
812 assert!(!shown.contains("very-secret"), "{shown}");
813 assert!(shown.contains("example.test"), "{shown}");
814 }
815
816 #[test]
817 fn masks_a_gateways_key_as_well_as_the_apis() {
818 for name in [
819 "Authorization",
820 "cookie",
821 "cf-aig-authorization",
822 "x-portkey-api-key",
823 "x-gateway-token",
824 "x-client-secret",
825 ] {
826 assert!(is_secret(name), "{name}");
827 }
828 for name in ["content-type", "x-typesafe-request-id", "retry-after"] {
829 assert!(!is_secret(name), "{name}");
830 }
831 }
832}