Skip to main content

openkind_client/client/
core.rs

1//! Core `Client` struct and public endpoint methods.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use openkind_core::{
7    validate_response_for_request, ModelsResponse, Question, SystemRequest, SystemResponse,
8};
9use reqwest::header::HeaderMap;
10use reqwest::Method;
11
12use super::builder::ClientBuilder;
13use super::options::{
14    Health, IntoState, RequestOptions, CLOUDFLARE_RUN_PATH, HEALTH_PATH, MODELS_PATH,
15    SYSTEM_ONE_PATH,
16};
17use crate::error::Error;
18use crate::retry::RetryPolicy;
19
20/// The endpoints this SDK talks to, with per-endpoint request URLs resolved
21/// once at client construction instead of formatted and parsed per call.
22#[derive(Clone)]
23pub(crate) struct EndpointUrls {
24    /// `POST` target for evaluation requests.
25    pub(crate) system_one: reqwest::Url,
26    /// `GET` target for model discovery.
27    pub(crate) models: reqwest::Url,
28    /// `GET` target for liveness probes.
29    pub(crate) health: reqwest::Url,
30}
31
32impl EndpointUrls {
33    pub(crate) fn new(base_url: &str, is_cloudflare: bool) -> Result<Self, Error> {
34        let join = |path: &str| {
35            reqwest::Url::parse(&format!("{base_url}{path}"))
36                .map_err(|e| Error::Config(format!("invalid base URL `{base_url}`: {e}")))
37        };
38        Ok(Self {
39            system_one: join(if is_cloudflare {
40                CLOUDFLARE_RUN_PATH
41            } else {
42                SYSTEM_ONE_PATH
43            })?,
44            models: join(MODELS_PATH)?,
45            health: join(HEALTH_PATH)?,
46        })
47    }
48}
49
50pub(crate) struct ClientInner {
51    pub(crate) http: reqwest::Client,
52    pub(crate) base_url: String,
53    pub(crate) is_cloudflare: bool,
54    pub(crate) urls: EndpointUrls,
55    pub(crate) default_model: String,
56    pub(crate) timeout: Duration,
57    pub(crate) retry: RetryPolicy,
58    pub(crate) base_headers: HeaderMap,
59    /// Direct HTTP/1.1 pool used for plain-`http` base URLs. `None` keeps
60    /// every request on the reqwest transport (https base URLs, caller
61    /// supplied HTTP clients, or proxy environments).
62    pub(crate) fast_h1: Option<super::http1::H1Pool>,
63}
64
65/// An async client for the SystemOne HTTP API.
66///
67/// Cheap to clone; clones share one connection pool. All methods are safe to
68/// call concurrently from many tasks.
69#[derive(Clone)]
70pub struct Client {
71    pub(crate) inner: Arc<ClientInner>,
72}
73
74impl std::fmt::Debug for Client {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("Client")
77            .field("base_url", &self.inner.base_url)
78            .field("default_model", &self.inner.default_model)
79            .field("timeout", &self.inner.timeout)
80            .finish_non_exhaustive()
81    }
82}
83
84impl Client {
85    /// `Client::builder().api_key(key).build()?` with all other defaults.
86    pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
87        Self::builder().api_key(api_key).build()
88    }
89
90    /// Start building a customized client.
91    pub fn builder() -> ClientBuilder {
92        ClientBuilder::new()
93    }
94
95    /// The resolved base URL (no trailing slash).
96    pub fn base_url(&self) -> &str {
97        &self.inner.base_url
98    }
99
100    /// The resolved default model alias.
101    pub fn default_model(&self) -> &str {
102        &self.inner.default_model
103    }
104
105    /// Evaluate a [`SystemRequest`], `POST`ing it to `/v1/systemone`.
106    ///
107    /// Retries and rate-limit backoff follow the client's [`RetryPolicy`];
108    /// see [`evaluate_with`](Self::evaluate_with) for per-call overrides.
109    pub async fn evaluate(&self, request: SystemRequest) -> Result<SystemResponse, Error> {
110        self.evaluate_with(request, &RequestOptions::new()).await
111    }
112
113    /// [`evaluate`](Self::evaluate) with per-call [`RequestOptions`].
114    pub async fn evaluate_with(
115        &self,
116        request: SystemRequest,
117        opts: &RequestOptions,
118    ) -> Result<SystemResponse, Error> {
119        self.evaluate_ref_with(&request, opts).await
120    }
121
122    /// [`evaluate`](Self::evaluate) for callers that keep the request, such as
123    /// a proxy that records it afterwards, so it need not be cloned per call.
124    pub async fn evaluate_ref(&self, request: &SystemRequest) -> Result<SystemResponse, Error> {
125        self.evaluate_ref_with(request, &RequestOptions::new())
126            .await
127    }
128
129    /// [`evaluate_ref`](Self::evaluate_ref) with per-call [`RequestOptions`].
130    pub async fn evaluate_ref_with(
131        &self,
132        request: &SystemRequest,
133        opts: &RequestOptions,
134    ) -> Result<SystemResponse, Error> {
135        // Serialize once here; the transport sends the bytes directly so the
136        // request is not serialized a second time inside the HTTP client.
137        let body = if self.inner.is_cloudflare {
138            serde_json::to_vec(&serde_json::json!({
139                "model": &request.model,
140                "input": {"state": &request.state, "questions": &request.questions},
141            }))
142        } else {
143            serde_json::to_vec(request)
144        }
145        .map_err(|e| Error::Config(format!("request could not be serialized: {e}")))?;
146        let path = if self.inner.is_cloudflare {
147            CLOUDFLARE_RUN_PATH
148        } else {
149            SYSTEM_ONE_PATH
150        };
151        let response = if self.inner.is_cloudflare {
152            let envelope: CloudflareResponse = self
153                .send_json(
154                    Method::POST,
155                    &self.inner.urls.system_one,
156                    path,
157                    Some(body),
158                    opts,
159                )
160                .await?;
161            envelope.result
162        } else {
163            self.send_json(
164                Method::POST,
165                &self.inner.urls.system_one,
166                path,
167                Some(body),
168                opts,
169            )
170            .await?
171        };
172        validate_response_for_request(&response, request)
173            .map_err(|source| Error::InvalidResponse { source })?;
174        Ok(response)
175    }
176
177    /// Evaluate ad-hoc questions against a state, mirroring the Python SDK's
178    /// `client.system_one(...)`. The client's default model is used.
179    ///
180    /// # Examples
181    /// ```
182    /// use openkind_client::{question, Client};
183    ///
184    /// # async fn demo(client: &Client) -> Result<(), openkind_client::Error> {
185    /// let response = client
186    ///     .system_one(
187    ///         "I was charged twice. Please help.",
188    ///         [
189    ///             ("billing", question::noul("Is this about billing?")),
190    ///             ("tone", question::choice("What is the tone?", [("calm", None), ("angry", None)])),
191    ///         ],
192    ///     )
193    ///     .await?;
194    /// assert!(response.answers.contains_key("billing"));
195    /// # Ok(())
196    /// # }
197    /// ```
198    pub async fn system_one<S, I, K>(&self, state: S, questions: I) -> Result<SystemResponse, Error>
199    where
200        S: IntoState,
201        I: IntoIterator<Item = (K, Question)>,
202        K: Into<String>,
203    {
204        self.system_one_with(state, questions, &RequestOptions::new())
205            .await
206    }
207
208    /// [`system_one`](Self::system_one) with per-call [`RequestOptions`].
209    pub async fn system_one_with<S, I, K>(
210        &self,
211        state: S,
212        questions: I,
213        opts: &RequestOptions,
214    ) -> Result<SystemResponse, Error>
215    where
216        S: IntoState,
217        I: IntoIterator<Item = (K, Question)>,
218        K: Into<String>,
219    {
220        let request = SystemRequest {
221            state: state.into_state(),
222            model: self.inner.default_model.clone(),
223            questions: questions
224                .into_iter()
225                .map(|(id, question)| (id.into(), question))
226                .collect(),
227        };
228        self.evaluate_with(request, opts).await
229    }
230
231    /// `GET /v1/models` — list model aliases registered on the server.
232    pub async fn list_models(&self) -> Result<ModelsResponse, Error> {
233        self.list_models_with(&RequestOptions::new()).await
234    }
235
236    /// [`list_models`](Self::list_models) with per-call [`RequestOptions`].
237    pub async fn list_models_with(&self, opts: &RequestOptions) -> Result<ModelsResponse, Error> {
238        if self.inner.is_cloudflare {
239            return Err(Error::Config(
240                "list_models is unavailable for the Cloudflare Workers AI surface".into(),
241            ));
242        }
243        self.send_json(
244            Method::GET,
245            &self.inner.urls.models,
246            MODELS_PATH,
247            None,
248            opts,
249        )
250        .await
251    }
252
253    /// `GET /health` — liveness probe. Unauthenticated, so it works even
254    /// with a wrong API key.
255    pub async fn health(&self) -> Result<Health, Error> {
256        self.health_with(&RequestOptions::new()).await
257    }
258
259    /// [`health`](Self::health) with per-call [`RequestOptions`].
260    pub async fn health_with(&self, opts: &RequestOptions) -> Result<Health, Error> {
261        if self.inner.is_cloudflare {
262            return Err(Error::Config(
263                "health is unavailable for the Cloudflare Workers AI surface".into(),
264            ));
265        }
266        self.send_json(
267            Method::GET,
268            &self.inner.urls.health,
269            HEALTH_PATH,
270            None,
271            opts,
272        )
273        .await
274    }
275}
276
277#[derive(serde::Deserialize)]
278struct CloudflareResponse {
279    #[serde(rename = "success", deserialize_with = "require_cloudflare_success")]
280    _success: (),
281    result: SystemResponse,
282}
283
284fn require_cloudflare_success<'de, D: serde::Deserializer<'de>>(
285    deserializer: D,
286) -> Result<(), D::Error> {
287    // A failure envelope must not release a result that happens to match the Jev schema.
288    if <bool as serde::Deserialize>::deserialize(deserializer)? {
289        Ok(())
290    } else {
291        Err(serde::de::Error::custom(
292            "Cloudflare response reports failure",
293        ))
294    }
295}