Skip to main content

kunobi_decision/client/
mod.rs

1//! The API client.
2
3mod builder;
4mod call;
5mod env;
6mod models;
7mod provider;
8pub use provider::Provider;
9mod transport;
10
11use std::fmt;
12use std::sync::Arc;
13use std::sync::atomic::AtomicU64;
14use std::time::Duration;
15
16use reqwest::Method;
17use reqwest::header::HeaderMap;
18
19pub use builder::ClientBuilder;
20pub use call::{Call, RawResponse, WithResponse};
21pub use env::{ENV_API_KEY, ENV_BASE_URL, ENV_DEFAULT_MODEL};
22pub use models::Models;
23
24use crate::answers::SystemOneResult;
25use crate::credentials::Credentials;
26use crate::request::SystemOneRequest;
27use crate::retry::RetryPolicy;
28
29/// Default API root.
30pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
31
32/// Default model.
33pub const DEFAULT_MODEL: &str = "jev-latest";
34
35/// Client for typed decision APIs.
36///
37/// Cloning is cheap and clones share one connection pool.
38#[derive(Clone)]
39pub struct Client {
40    inner: Arc<Inner>,
41}
42
43struct Inner {
44    provider: Provider,
45    credentials: Credentials,
46    base_url: String,
47    log_bodies: bool,
48    default_model: String,
49    retry: RetryPolicy,
50    timeout: Duration,
51    total_timeout: Option<Duration>,
52    /// Concurrency slots and the configured limit.
53    limiter: Option<(tokio::sync::Semaphore, usize)>,
54    default_headers: HeaderMap,
55    http: reqwest::Client,
56    /// Numbers requests so concurrent calls, and the attempts within one, can be told apart in logs.
57    request_count: AtomicU64,
58}
59
60impl Client {
61    /// A client configured from the environment.
62    ///
63    /// Reads `TYPESAFE_API_KEY` (required), `TYPESAFE_BASE_URL` and
64    /// `TYPESAFE_DEFAULT_MODEL`. Use [`Client::builder`] to set values in code
65    /// or to authenticate with a [`CredentialProvider`](crate::CredentialProvider).
66    pub fn new() -> crate::Result<Self> {
67        Self::builder().build()
68    }
69
70    /// A builder for a client. Values set in code take precedence over the environment.
71    pub fn builder() -> ClientBuilder {
72        ClientBuilder::default()
73    }
74
75    /// Selected access provider.
76    pub fn provider(&self) -> &Provider {
77        &self.inner.provider
78    }
79
80    /// API root without trailing slashes.
81    pub fn base_url(&self) -> &str {
82        &self.inner.base_url
83    }
84
85    /// Model used when a request does not set one.
86    pub fn default_model(&self) -> &str {
87        &self.inner.default_model
88    }
89
90    /// Retry policy for calls that do not override it.
91    pub fn retry(&self) -> &RetryPolicy {
92        &self.inner.retry
93    }
94
95    /// Timeout per attempt for calls that do not override it.
96    pub fn timeout(&self) -> Duration {
97        self.inner.timeout
98    }
99
100    /// Upper bound for a whole call, for calls that do not override it.
101    pub fn total_timeout(&self) -> Option<Duration> {
102        self.inner.total_timeout
103    }
104
105    /// The concurrency limit shared by this client and its clones, if any.
106    pub fn max_concurrent_requests(&self) -> Option<usize> {
107        self.inner.limiter.as_ref().map(|(_, max)| *max)
108    }
109
110    /// Headers sent with every request.
111    pub fn default_headers(&self) -> &HeaderMap {
112        &self.inner.default_headers
113    }
114
115    /// Answer named questions about text or structured state.
116    ///
117    /// Validation errors are returned when the call is awaited, before any request is sent.
118    ///
119    /// ```no_run
120    /// # async fn example() -> kunobi_decision::Result<()> {
121    /// use kunobi_decision::{Client, Questions, SystemOneRequest, noul};
122    ///
123    /// let client = Client::new()?;
124    /// let mut questions = Questions::new();
125    /// let billing = questions.add("billing", noul("Is this about billing?"));
126    ///
127    /// let result = client
128    ///     .system_one(SystemOneRequest::new("I was charged twice.", questions))
129    ///     .await?;
130    /// println!("{}", result.answer(&billing)?.noul);
131    /// # Ok(()) }
132    /// ```
133    pub fn system_one(&self, request: SystemOneRequest) -> Call<SystemOneResult> {
134        let prepared = self.provider().prepare(request, self.default_model());
135        let (path, body) = match prepared {
136            Ok((path, body)) => (path, Ok(Some(body))),
137            Err(err) => (String::new(), Err(err)),
138        };
139        let parse = if matches!(self.provider(), Provider::Cloudflare { .. }) {
140            provider::unwrap_cloudflare::<SystemOneResult>
141        } else {
142            call::parse_json
143        };
144        Call::new(self.clone(), Method::POST, path, body, parse)
145    }
146
147    /// Check credentials and return the decision model catalog.
148    ///
149    /// OpenRouter's catalog is public, so first check `/v1/key`. Key metadata
150    /// is discarded. Liquid uses a small inference probe, consuming input tokens.
151    /// The client's total timeout bounds all requests in this operation.
152    pub async fn check(&self) -> crate::Result<Vec<crate::ModelCard>> {
153        let check = async {
154            if matches!(self.provider(), Provider::OpenRouter) {
155                Call::new(
156                    self.clone(),
157                    Method::GET,
158                    "/v1/key",
159                    Ok(None),
160                    parse_openrouter_key,
161                )
162                .await?;
163            }
164            self.models().list().await
165        };
166        match self.total_timeout() {
167            Some(timeout) => tokio::time::timeout(timeout, check)
168                .await
169                .unwrap_or(Err(crate::Error::Timeout { timeout })),
170            None => check.await,
171        }
172    }
173
174    /// The Models API resource.
175    pub fn models(&self) -> Models {
176        Models::new(self.clone())
177    }
178}
179
180impl fmt::Debug for Client {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        f.debug_struct("Client")
183            .field("provider", &self.inner.provider)
184            .field("base_url", &self.inner.base_url)
185            .field("default_model", &self.inner.default_model)
186            .field("retry", &self.inner.retry)
187            .field("timeout", &self.inner.timeout)
188            .field("total_timeout", &self.inner.total_timeout)
189            .field("max_concurrent_requests", &self.max_concurrent_requests())
190            .field("credentials", &self.inner.credentials)
191            .field("log_bodies", &self.inner.log_bodies)
192            .finish_non_exhaustive()
193    }
194}
195
196fn parse_openrouter_key(body: &[u8]) -> Result<(), call::DecodeFailure> {
197    #[derive(serde::Deserialize)]
198    struct Key {
199        data: serde_json::Map<String, serde_json::Value>,
200    }
201    let key: Key = call::parse_json(body)?;
202    drop(key.data);
203    Ok(())
204}