use std::sync::Arc;
use std::time::Duration;
use openkind_core::{
validate_response_for_request, ModelsResponse, Question, SystemRequest, SystemResponse,
};
use reqwest::header::HeaderMap;
use reqwest::Method;
use super::builder::ClientBuilder;
use super::options::{
Health, IntoState, RequestOptions, CLOUDFLARE_RUN_PATH, HEALTH_PATH, MODELS_PATH,
SYSTEM_ONE_PATH,
};
use crate::error::Error;
use crate::retry::RetryPolicy;
#[derive(Clone)]
pub(crate) struct EndpointUrls {
pub(crate) system_one: reqwest::Url,
pub(crate) models: reqwest::Url,
pub(crate) health: reqwest::Url,
}
impl EndpointUrls {
pub(crate) fn new(base_url: &str, is_cloudflare: bool) -> Result<Self, Error> {
let join = |path: &str| {
reqwest::Url::parse(&format!("{base_url}{path}"))
.map_err(|e| Error::Config(format!("invalid base URL `{base_url}`: {e}")))
};
Ok(Self {
system_one: join(if is_cloudflare {
CLOUDFLARE_RUN_PATH
} else {
SYSTEM_ONE_PATH
})?,
models: join(MODELS_PATH)?,
health: join(HEALTH_PATH)?,
})
}
}
pub(crate) struct ClientInner {
pub(crate) http: reqwest::Client,
pub(crate) base_url: String,
pub(crate) is_cloudflare: bool,
pub(crate) urls: EndpointUrls,
pub(crate) default_model: String,
pub(crate) timeout: Duration,
pub(crate) retry: RetryPolicy,
pub(crate) base_headers: HeaderMap,
pub(crate) fast_h1: Option<super::http1::H1Pool>,
}
#[derive(Clone)]
pub struct Client {
pub(crate) inner: Arc<ClientInner>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.inner.base_url)
.field("default_model", &self.inner.default_model)
.field("timeout", &self.inner.timeout)
.finish_non_exhaustive()
}
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
Self::builder().api_key(api_key).build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub fn base_url(&self) -> &str {
&self.inner.base_url
}
pub fn default_model(&self) -> &str {
&self.inner.default_model
}
pub async fn evaluate(&self, request: SystemRequest) -> Result<SystemResponse, Error> {
self.evaluate_with(request, &RequestOptions::new()).await
}
pub async fn evaluate_with(
&self,
request: SystemRequest,
opts: &RequestOptions,
) -> Result<SystemResponse, Error> {
let body = if self.inner.is_cloudflare {
serde_json::to_vec(&serde_json::json!({
"model": &request.model,
"input": {"state": &request.state, "questions": &request.questions},
}))
} else {
serde_json::to_vec(&request)
}
.map_err(|e| Error::Config(format!("request could not be serialized: {e}")))?;
let path = if self.inner.is_cloudflare {
CLOUDFLARE_RUN_PATH
} else {
SYSTEM_ONE_PATH
};
let response = if self.inner.is_cloudflare {
let envelope: CloudflareResponse = self
.send_json(
Method::POST,
&self.inner.urls.system_one,
path,
Some(body),
opts,
)
.await?;
envelope.result
} else {
self.send_json(
Method::POST,
&self.inner.urls.system_one,
path,
Some(body),
opts,
)
.await?
};
validate_response_for_request(&response, &request)
.map_err(|source| Error::InvalidResponse { source })?;
Ok(response)
}
pub async fn system_one<S, I, K>(&self, state: S, questions: I) -> Result<SystemResponse, Error>
where
S: IntoState,
I: IntoIterator<Item = (K, Question)>,
K: Into<String>,
{
self.system_one_with(state, questions, &RequestOptions::new())
.await
}
pub async fn system_one_with<S, I, K>(
&self,
state: S,
questions: I,
opts: &RequestOptions,
) -> Result<SystemResponse, Error>
where
S: IntoState,
I: IntoIterator<Item = (K, Question)>,
K: Into<String>,
{
let request = SystemRequest {
state: state.into_state(),
model: self.inner.default_model.clone(),
questions: questions
.into_iter()
.map(|(id, question)| (id.into(), question))
.collect(),
};
self.evaluate_with(request, opts).await
}
pub async fn list_models(&self) -> Result<ModelsResponse, Error> {
self.list_models_with(&RequestOptions::new()).await
}
pub async fn list_models_with(&self, opts: &RequestOptions) -> Result<ModelsResponse, Error> {
if self.inner.is_cloudflare {
return Err(Error::Config(
"list_models is unavailable for the Cloudflare Workers AI surface".into(),
));
}
self.send_json(
Method::GET,
&self.inner.urls.models,
MODELS_PATH,
None,
opts,
)
.await
}
pub async fn health(&self) -> Result<Health, Error> {
self.health_with(&RequestOptions::new()).await
}
pub async fn health_with(&self, opts: &RequestOptions) -> Result<Health, Error> {
if self.inner.is_cloudflare {
return Err(Error::Config(
"health is unavailable for the Cloudflare Workers AI surface".into(),
));
}
self.send_json(
Method::GET,
&self.inner.urls.health,
HEALTH_PATH,
None,
opts,
)
.await
}
}
#[derive(serde::Deserialize)]
struct CloudflareResponse {
#[serde(rename = "success", deserialize_with = "require_cloudflare_success")]
_success: (),
result: SystemResponse,
}
fn require_cloudflare_success<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<(), D::Error> {
if <bool as serde::Deserialize>::deserialize(deserializer)? {
Ok(())
} else {
Err(serde::de::Error::custom(
"Cloudflare response reports failure",
))
}
}