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        // Serialize once here; the transport sends the bytes directly so the
120        // request is not serialized a second time inside the HTTP client.
121        let body = if self.inner.is_cloudflare {
122            serde_json::to_vec(&serde_json::json!({
123                "model": &request.model,
124                "input": {"state": &request.state, "questions": &request.questions},
125            }))
126        } else {
127            serde_json::to_vec(&request)
128        }
129        .map_err(|e| Error::Config(format!("request could not be serialized: {e}")))?;
130        let path = if self.inner.is_cloudflare {
131            CLOUDFLARE_RUN_PATH
132        } else {
133            SYSTEM_ONE_PATH
134        };
135        let response = if self.inner.is_cloudflare {
136            let envelope: CloudflareResponse = self
137                .send_json(
138                    Method::POST,
139                    &self.inner.urls.system_one,
140                    path,
141                    Some(body),
142                    opts,
143                )
144                .await?;
145            envelope.result
146        } else {
147            self.send_json(
148                Method::POST,
149                &self.inner.urls.system_one,
150                path,
151                Some(body),
152                opts,
153            )
154            .await?
155        };
156        validate_response_for_request(&response, &request)
157            .map_err(|source| Error::InvalidResponse { source })?;
158        Ok(response)
159    }
160
161    /// Evaluate ad-hoc questions against a state, mirroring the Python SDK's
162    /// `client.system_one(...)`. The client's default model is used.
163    ///
164    /// # Examples
165    /// ```
166    /// use openkind_client::{question, Client};
167    ///
168    /// # async fn demo(client: &Client) -> Result<(), openkind_client::Error> {
169    /// let response = client
170    ///     .system_one(
171    ///         "I was charged twice. Please help.",
172    ///         [
173    ///             ("billing", question::noul("Is this about billing?")),
174    ///             ("tone", question::choice("What is the tone?", [("calm", None), ("angry", None)])),
175    ///         ],
176    ///     )
177    ///     .await?;
178    /// assert!(response.answers.contains_key("billing"));
179    /// # Ok(())
180    /// # }
181    /// ```
182    pub async fn system_one<S, I, K>(&self, state: S, questions: I) -> Result<SystemResponse, Error>
183    where
184        S: IntoState,
185        I: IntoIterator<Item = (K, Question)>,
186        K: Into<String>,
187    {
188        self.system_one_with(state, questions, &RequestOptions::new())
189            .await
190    }
191
192    /// [`system_one`](Self::system_one) with per-call [`RequestOptions`].
193    pub async fn system_one_with<S, I, K>(
194        &self,
195        state: S,
196        questions: I,
197        opts: &RequestOptions,
198    ) -> Result<SystemResponse, Error>
199    where
200        S: IntoState,
201        I: IntoIterator<Item = (K, Question)>,
202        K: Into<String>,
203    {
204        let request = SystemRequest {
205            state: state.into_state(),
206            model: self.inner.default_model.clone(),
207            questions: questions
208                .into_iter()
209                .map(|(id, question)| (id.into(), question))
210                .collect(),
211        };
212        self.evaluate_with(request, opts).await
213    }
214
215    /// `GET /v1/models` — list model aliases registered on the server.
216    pub async fn list_models(&self) -> Result<ModelsResponse, Error> {
217        self.list_models_with(&RequestOptions::new()).await
218    }
219
220    /// [`list_models`](Self::list_models) with per-call [`RequestOptions`].
221    pub async fn list_models_with(&self, opts: &RequestOptions) -> Result<ModelsResponse, Error> {
222        if self.inner.is_cloudflare {
223            return Err(Error::Config(
224                "list_models is unavailable for the Cloudflare Workers AI surface".into(),
225            ));
226        }
227        self.send_json(
228            Method::GET,
229            &self.inner.urls.models,
230            MODELS_PATH,
231            None,
232            opts,
233        )
234        .await
235    }
236
237    /// `GET /health` — liveness probe. Unauthenticated, so it works even
238    /// with a wrong API key.
239    pub async fn health(&self) -> Result<Health, Error> {
240        self.health_with(&RequestOptions::new()).await
241    }
242
243    /// [`health`](Self::health) with per-call [`RequestOptions`].
244    pub async fn health_with(&self, opts: &RequestOptions) -> Result<Health, Error> {
245        if self.inner.is_cloudflare {
246            return Err(Error::Config(
247                "health is unavailable for the Cloudflare Workers AI surface".into(),
248            ));
249        }
250        self.send_json(
251            Method::GET,
252            &self.inner.urls.health,
253            HEALTH_PATH,
254            None,
255            opts,
256        )
257        .await
258    }
259}
260
261#[derive(serde::Deserialize)]
262struct CloudflareResponse {
263    #[serde(rename = "success", deserialize_with = "require_cloudflare_success")]
264    _success: (),
265    result: SystemResponse,
266}
267
268fn require_cloudflare_success<'de, D: serde::Deserializer<'de>>(
269    deserializer: D,
270) -> Result<(), D::Error> {
271    // A failure envelope must not release a result that happens to match the Jev schema.
272    if <bool as serde::Deserialize>::deserialize(deserializer)? {
273        Ok(())
274    } else {
275        Err(serde::de::Error::custom(
276            "Cloudflare response reports failure",
277        ))
278    }
279}