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 self.evaluate_ref_with(&request, opts).await
120 }
121
122 pub async fn evaluate_ref(&self, request: &SystemRequest) -> Result<SystemResponse, Error> {
125 self.evaluate_ref_with(request, &RequestOptions::new())
126 .await
127 }
128
129 pub async fn evaluate_ref_with(
131 &self,
132 request: &SystemRequest,
133 opts: &RequestOptions,
134 ) -> Result<SystemResponse, Error> {
135 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 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 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 pub async fn list_models(&self) -> Result<ModelsResponse, Error> {
233 self.list_models_with(&RequestOptions::new()).await
234 }
235
236 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 pub async fn health(&self) -> Result<Health, Error> {
256 self.health_with(&RequestOptions::new()).await
257 }
258
259 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 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}