1mod media;
8pub use media::*;
9mod questions;
10pub use questions::*;
11
12use std::{fmt, net::IpAddr, time::Duration};
13
14use bytes::Bytes;
15use http::{Method, Request, Uri};
16use http_body_util::{BodyExt, Full};
17use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
18use hyper_util::{
19 client::legacy::connect::HttpConnector,
20 rt::{TokioExecutor, TokioTimer},
21};
22
23use http::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER, USER_AGENT};
24use serde::{de::DeserializeOwned, Deserialize, Serialize};
25use serde_json::{Map, Value};
26
27#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
29#[non_exhaustive]
30#[serde(rename_all = "lowercase")]
31pub enum QuestionType {
32 Choice,
34 Noul,
36 Score,
38 Multi,
40 Act,
42 Tool,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct Question {
50 #[serde(rename = "type")]
52 pub kind: QuestionType,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub instructions: Option<Value>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub criteria: Option<Value>,
59 #[serde(flatten)]
62 pub extra: Map<String, Value>,
63}
64
65impl Question {
66 pub fn new(kind: QuestionType, instructions: impl Into<String>) -> Self {
68 Self { kind, instructions: Some(Value::String(instructions.into())), criteria: None, extra: Map::new() }
69 }
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
74pub struct DecisionRequest {
75 pub state: Value,
77 pub questions: Map<String, Value>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub model: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub lang: Option<Language>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub media: Option<Vec<MediaInput>>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub detail: Option<MediaDetail>,
91 #[serde(flatten)]
93 pub extra: Map<String, Value>,
94}
95
96impl DecisionRequest {
97 pub fn new(state: impl Into<Value>) -> Self {
99 Self {
100 state: state.into(),
101 questions: Map::new(),
102 model: None,
103 lang: None,
104 media: None,
105 detail: None,
106 extra: Map::new(),
107 }
108 }
109
110 pub fn insert_question(&mut self, id: impl Into<String>, question: &Question) -> Result<(), Error> {
112 self.questions.insert(id.into(), serde_json::to_value(question).map_err(Error::Encode)?);
113 Ok(())
114 }
115}
116
117#[derive(Clone, Debug, Deserialize)]
119pub struct Answer {
120 #[serde(rename = "type")]
122 pub kind: QuestionType,
123 pub probabilities: indexmap::IndexMap<String, f64>,
125 pub confidence: Option<f64>,
127 pub choice: Option<String>,
129 pub noul: Option<f64>,
131 pub score: Option<f64>,
133 pub legend: Option<indexmap::IndexMap<String, String>>,
135 pub selected: Option<Vec<String>>,
137 pub threshold: Option<f64>,
139 pub set_confidence: Option<f64>,
141 pub action: Option<String>,
143 pub answer: Option<String>,
145 pub expected_costs: Option<indexmap::IndexMap<String, f64>>,
147 pub calibration: Option<String>,
149 pub refused: Option<String>,
151 pub max_error: Option<String>,
153 pub evidence: Option<Vec<EvidenceSpan>>,
155 pub objective: Option<MultiObjective>,
157 pub expected_score: Option<f64>,
159 pub constraints_applied: Option<Value>,
161 pub tool: Option<String>,
163 pub tools: Option<Vec<String>>,
165 pub call: Option<bool>,
167 pub multi: Option<bool>,
169 pub ranking: Option<Vec<ToolRanking>>,
171 pub deferred: Option<bool>,
173 #[serde(flatten)]
175 pub extra: Map<String, Value>,
176}
177
178#[derive(Debug, Clone, Deserialize)]
180pub struct ToolRanking {
181 pub tool: String,
183 pub probability: f64,
185}
186
187#[derive(Clone, Debug, Deserialize)]
189pub struct EvidenceSpan {
190 pub text: String,
192 pub start: usize,
194 pub end: usize,
196 pub probability: f64,
198}
199
200#[derive(Clone, Debug, Deserialize)]
202pub struct Usage {
203 pub input_tokens: u64,
205 pub output_tokens: u64,
207 pub questions: Option<u64>,
209 pub branches: Option<u64>,
211 pub latency_ms: Option<f64>,
213 pub longest_prompt_tokens: Option<u64>,
215 pub second_order_branches: Option<u64>,
217 pub media: Option<MediaUsage>,
219 #[serde(flatten)]
221 pub extra: Map<String, Value>,
222}
223
224#[derive(Clone, Debug, Deserialize)]
226pub struct DecisionResponse {
227 pub model: String,
229 pub answers: indexmap::IndexMap<String, Answer>,
231 pub usage: Usage,
233 #[serde(flatten)]
235 pub extra: Map<String, Value>,
236}
237
238#[derive(Clone, Debug, Deserialize)]
240pub struct ModelMetadata {
241 pub name: String,
243 pub prompt_format: Option<u8>,
245 pub orders: Option<Value>,
247 pub media: Option<Vec<MediaKind>>,
249 pub temperature_per_prim: Option<indexmap::IndexMap<String, f64>>,
251 #[serde(flatten)]
253 pub extra: Map<String, Value>,
254}
255
256#[derive(Clone, Debug, Deserialize)]
258pub struct ModelsResponse {
259 pub models: Vec<ModelMetadata>,
261 #[serde(flatten)]
263 pub extra: Map<String, Value>,
264}
265
266#[non_exhaustive]
268pub enum Error {
269 InvalidConfig(&'static str),
271 InvalidRequest(&'static str),
273 InvalidResponse(&'static str),
275 Transport(Box<dyn std::error::Error + Send + Sync>),
277 Encode(serde_json::Error),
279 Protocol(serde_json::Error),
281 Timeout,
283 ResponseTooLarge,
285 Http {
287 status: u16,
289 body: Value,
291 retry_after: Option<String>,
293 },
294}
295
296impl fmt::Display for Error {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 match self {
299 Self::InvalidConfig(message) => write!(f, "invalid Basal client configuration: {message}"),
300 Self::InvalidRequest(message) => write!(f, "invalid Basal request: {message}"),
301 Self::InvalidResponse(message) => write!(f, "invalid Basal answer: {message}"),
302 Self::Transport(_) => f.write_str("Basal HTTP transport failed"),
303 Self::Encode(_) => f.write_str("could not encode Basal request"),
304 Self::Protocol(_) => f.write_str("invalid Basal JSON response"),
305 Self::Timeout => f.write_str("Basal HTTP request timed out"),
306 Self::ResponseTooLarge => f.write_str("Basal response exceeded the configured byte limit"),
307 Self::Http { status, .. } => write!(f, "Basal HTTP {status}"),
308 }
309 }
310}
311
312impl fmt::Debug for Error {
313 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314 fmt::Display::fmt(self, f)
316 }
317}
318
319impl std::error::Error for Error {
320 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
321 match self {
322 Self::Transport(source) => Some(source.as_ref()),
323 Self::Encode(source) | Self::Protocol(source) => Some(source),
324 _ => None,
325 }
326 }
327}
328
329pub struct ClientOptions<'a> {
331 pub base_url: &'a str,
333 pub api_key: Option<&'a str>,
335 pub timeout: Duration,
337 pub max_response_bytes: usize,
339}
340
341impl Default for ClientOptions<'_> {
342 fn default() -> Self {
343 Self {
344 base_url: "http://127.0.0.1:8000",
345 api_key: None,
346 timeout: Duration::from_secs(120),
347 max_response_bytes: 8 * 1024 * 1024,
348 }
349 }
350}
351
352#[derive(Clone)]
355pub struct Client {
356 http: hyper_util::client::legacy::Client<HttpsConnector<HttpConnector>, Full<Bytes>>,
357 base_url: String,
358 headers: HeaderMap,
359 timeout: Duration,
360 max_response_bytes: usize,
361}
362
363impl Client {
364 pub fn new(base_url: &str) -> Result<Self, Error> {
366 Self::with_options(ClientOptions { base_url, ..ClientOptions::default() })
367 }
368
369 pub fn with_options(options: ClientOptions<'_>) -> Result<Self, Error> {
372 let url: Uri = options.base_url.parse().map_err(|_| Error::InvalidConfig("invalid URL"))?;
373 let scheme = url.scheme_str().unwrap_or_default();
374 let host = url.host().unwrap_or_default();
375 if !matches!(scheme, "http" | "https")
376 || host.is_empty()
377 || options.base_url.contains(['?', '#'])
378 || url.authority().is_some_and(|a| a.as_str().contains('@'))
379 {
380 return Err(Error::InvalidConfig("expected an HTTP(S) URL without credentials, query or fragment"));
381 }
382 let loopback = host.eq_ignore_ascii_case("localhost")
383 || host.trim_matches(['[', ']']).parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback());
384 if scheme != "https" && !loopback {
385 return Err(Error::InvalidConfig("base_url requires HTTPS or loopback HTTP"));
386 }
387 if options.timeout.is_zero() || options.max_response_bytes == 0 {
388 return Err(Error::InvalidConfig("timeout and max_response_bytes must be positive"));
389 }
390 let mut headers = HeaderMap::new();
391 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
392 headers.insert(USER_AGENT, HeaderValue::from_static(concat!("basal-client-rust/", env!("CARGO_PKG_VERSION"))));
393 if let Some(key) = options.api_key {
394 if key.is_empty() || key.contains(['\r', '\n']) {
395 return Err(Error::InvalidConfig("api_key must be a nonempty, single-line token"));
396 }
397 let mut value = HeaderValue::from_str(&format!("Bearer {key}"))
398 .map_err(|_| Error::InvalidConfig("invalid bearer token"))?;
399 value.set_sensitive(true);
400 headers.insert(AUTHORIZATION, value);
401 }
402 let connector = HttpsConnectorBuilder::new()
403 .with_provider_and_platform_verifier(rustls::crypto::ring::default_provider())
404 .map_err(|source| Error::Transport(Box::new(source)))?
405 .https_or_http()
406 .enable_http1()
407 .build();
408 let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new())
409 .retry_canceled_requests(false)
410 .pool_max_idle_per_host(8)
411 .pool_timer(TokioTimer::new())
412 .pool_idle_timeout(Duration::from_secs(90))
413 .build(connector);
414 Ok(Self {
415 http,
416 base_url: format!("{}/", options.base_url.trim_end_matches('/')),
417 headers,
418 timeout: options.timeout,
419 max_response_bytes: options.max_response_bytes,
420 })
421 }
422
423 async fn send<T: DeserializeOwned>(&self, method: Method, path: &str, body: Option<Vec<u8>>) -> Result<T, Error> {
424 let work = async {
425 let mut request = Request::builder().method(method).uri(format!("{}{path}", self.base_url));
427 if let Some(headers) = request.headers_mut() {
428 *headers = self.headers.clone();
429 }
430 if body.is_some() {
431 request = request.header(CONTENT_TYPE, "application/json");
432 }
433 let request = request
434 .body(Full::new(Bytes::from(body.unwrap_or_default())))
435 .map_err(|source| Error::Transport(Box::new(source)))?;
436 let response = self.http.request(request).await.map_err(|source| Error::Transport(Box::new(source)))?;
437 let status = response.status();
438 let retry_after = response.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()).map(String::from);
439 let mut body = response.into_body();
440 let mut bytes = Vec::new();
441 while let Some(frame) = body.frame().await {
442 let frame = frame.map_err(|source| Error::Transport(Box::new(source)))?;
443 if let Ok(data) = frame.into_data() {
444 if data.len() > self.max_response_bytes.saturating_sub(bytes.len()) {
445 return Err(Error::ResponseTooLarge);
446 }
447 bytes.extend_from_slice(&data);
448 }
449 }
450 if !status.is_success() {
451 let body = serde_json::from_slice(&bytes)
452 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()));
453 return Err(Error::Http { status: status.as_u16(), body, retry_after });
454 }
455 serde_json::from_slice(&bytes).map_err(Error::Protocol)
456 };
457 tokio::time::timeout(self.timeout, work).await.map_err(|_| Error::Timeout)?
458 }
459
460 pub async fn decide<Q: QuestionSet>(
462 &self,
463 state: impl Into<Value>,
464 questions: &Q,
465 ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
466 self.decide_with_options(state, questions, DecisionOptions::default()).await
467 }
468
469 pub async fn decide_with_options<Q: QuestionSet>(
471 &self,
472 state: impl Into<Value>,
473 questions: &Q,
474 options: DecisionOptions<'_>,
475 ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
476 let request = questions.as_questions().request(state.into(), options)?;
477 let response = self.decide_request(&request).await?;
478 questions.as_questions().clone().parse(response, questions.schema())
479 }
480
481 pub async fn decide_request(&self, request: &DecisionRequest) -> Result<DecisionResponse, Error> {
483 self.send(Method::POST, "v1/basal", Some(serde_json::to_vec(request).map_err(Error::Encode)?)).await
484 }
485
486 pub async fn models(&self) -> Result<ModelsResponse, Error> {
488 self.send(Method::GET, "v1/models", None).await
489 }
490}