1use 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#[derive(Clone)]
23pub(crate) struct EndpointUrls {
24 pub(crate) system_one: reqwest::Url,
26 pub(crate) models: reqwest::Url,
28 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 pub(crate) fast_h1: Option<super::http1::H1Pool>,
63}
64
65#[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 pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
87 Self::builder().api_key(api_key).build()
88 }
89
90 pub fn builder() -> ClientBuilder {
92 ClientBuilder::new()
93 }
94
95 pub fn base_url(&self) -> &str {
97 &self.inner.base_url
98 }
99
100 pub fn default_model(&self) -> &str {
102 &self.inner.default_model
103 }
104
105 pub async fn evaluate(&self, request: SystemRequest) -> Result<SystemResponse, Error> {
110 self.evaluate_with(request, &RequestOptions::new()).await
111 }
112
113 pub async fn evaluate_with(
115 &self,
116 request: SystemRequest,
117 opts: &RequestOptions,
118 ) -> Result<SystemResponse, Error> {
119 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 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 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 pub async fn list_models(&self) -> Result<ModelsResponse, Error> {
217 self.list_models_with(&RequestOptions::new()).await
218 }
219
220 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 pub async fn health(&self) -> Result<Health, Error> {
240 self.health_with(&RequestOptions::new()).await
241 }
242
243 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 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}