1mod media;
8pub use media::*;
9mod batches;
10pub use batches::*;
11mod tasks;
12pub use tasks::*;
13mod questions;
14pub use questions::*;
15
16use std::{fmt, net::IpAddr, time::Duration};
17
18use bytes::Bytes;
19use http::{Method, Request, Uri};
20use http_body_util::{BodyExt, Full};
21use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
22use hyper_util::{
23 client::legacy::connect::HttpConnector,
24 rt::{TokioExecutor, TokioTimer},
25};
26
27use http::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER, USER_AGENT};
28use serde::{de::DeserializeOwned, Deserialize, Serialize};
29use serde_json::{Map, Value};
30
31#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
33#[non_exhaustive]
34#[serde(rename_all = "lowercase")]
35pub enum QuestionType {
36 Choice,
38 Noul,
40 Score,
42 Multi,
44 Act,
46 Tool,
48 Classify,
50 Verify,
52 Compress,
54 Extract,
56}
57
58#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct Question {
62 #[serde(rename = "type")]
64 pub kind: QuestionType,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub instructions: Option<Value>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub criteria: Option<Value>,
71 #[serde(flatten)]
74 pub extra: Map<String, Value>,
75}
76
77impl Question {
78 pub fn new(kind: QuestionType, instructions: impl Into<String>) -> Self {
80 Self { kind, instructions: Some(Value::String(instructions.into())), criteria: None, extra: Map::new() }
81 }
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct DecisionRequest {
87 pub state: Value,
89 pub questions: Map<String, Value>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub model: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub lang: Option<Language>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub media: Option<Vec<MediaInput>>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub detail: Option<MediaDetail>,
103 #[serde(flatten)]
105 pub extra: Map<String, Value>,
106}
107
108impl DecisionRequest {
109 pub fn new(state: impl Into<Value>) -> Self {
111 Self {
112 state: state.into(),
113 questions: Map::new(),
114 model: None,
115 lang: None,
116 media: None,
117 detail: None,
118 extra: Map::new(),
119 }
120 }
121
122 pub fn insert_question(&mut self, id: impl Into<String>, question: &Question) -> Result<(), Error> {
124 self.questions.insert(id.into(), serde_json::to_value(question).map_err(Error::Encode)?);
125 Ok(())
126 }
127}
128
129#[derive(Clone, Debug, Deserialize)]
131pub struct Answer {
132 #[serde(rename = "type")]
134 pub kind: QuestionType,
135 #[serde(default)]
137 pub probabilities: indexmap::IndexMap<String, f64>,
138 pub confidence: Option<f64>,
140 pub choice: Option<String>,
142 pub noul: Option<f64>,
144 pub score: Option<f64>,
146 pub legend: Option<indexmap::IndexMap<String, String>>,
148 pub selected: Option<Vec<String>>,
150 pub threshold: Option<f64>,
152 pub set_confidence: Option<f64>,
154 pub action: Option<String>,
156 pub answer: Option<String>,
158 pub expected_costs: Option<indexmap::IndexMap<String, f64>>,
160 pub calibration: Option<String>,
162 pub refused: Option<String>,
164 pub max_error: Option<String>,
166 pub evidence: Option<Vec<EvidenceSpan>>,
168 pub objective: Option<MultiObjective>,
170 pub expected_score: Option<f64>,
172 pub constraints_applied: Option<Value>,
174 pub tool: Option<String>,
176 pub tools: Option<Vec<String>>,
178 pub call: Option<bool>,
180 pub multi: Option<bool>,
182 pub ranking: Option<Vec<ToolRanking>>,
184 pub deferred: Option<bool>,
186 pub experimental: Option<bool>,
188 pub label: Option<String>,
190 pub top_label: Option<String>,
192 pub labels: Option<Vec<String>>,
194 pub scores: Option<indexmap::IndexMap<String, f64>>,
196 pub automated: Option<Automation>,
198 pub claims: Option<indexmap::IndexMap<String, ClaimResult>>,
200 pub all_supported: Option<bool>,
202 pub not_supported: Option<Vec<String>>,
204 pub compressed: Option<String>,
206 pub kept: Option<Vec<usize>>,
208 pub units: Option<Vec<CompressionUnit>>,
210 pub unit: Option<CompressionUnitKind>,
212 pub original_chars: Option<usize>,
214 pub compressed_chars: Option<usize>,
216 pub ratio: Option<f64>,
218 pub data: Option<indexmap::IndexMap<String, Value>>,
220 pub fields: Option<indexmap::IndexMap<String, ExtractField>>,
222 #[serde(flatten)]
224 pub extra: Map<String, Value>,
225}
226
227#[derive(Debug, Clone, Deserialize)]
229pub struct ToolRanking {
230 pub tool: String,
232 pub probability: f64,
234}
235
236#[derive(Clone, Debug, Deserialize)]
238pub struct EvidenceSpan {
239 pub text: String,
241 pub start: usize,
243 pub end: usize,
245 pub probability: f64,
247}
248
249#[derive(Clone, Debug, Deserialize)]
251pub struct Usage {
252 pub input_tokens: u64,
254 pub output_tokens: u64,
256 pub questions: Option<u64>,
258 pub branches: Option<u64>,
260 pub latency_ms: Option<f64>,
262 pub longest_prompt_tokens: Option<u64>,
264 pub second_order_branches: Option<u64>,
266 pub media: Option<MediaUsage>,
268 #[serde(flatten)]
270 pub extra: Map<String, Value>,
271}
272
273#[derive(Clone, Debug, Deserialize)]
275pub struct DecisionResponse {
276 pub model: String,
278 pub answers: indexmap::IndexMap<String, Answer>,
280 pub usage: Usage,
282 #[serde(flatten)]
284 pub extra: Map<String, Value>,
285}
286
287#[derive(Clone, Debug, Deserialize)]
289pub struct ModelMetadata {
290 pub name: String,
292 pub prompt_format: Option<u8>,
294 pub orders: Option<Value>,
296 pub media: Option<Vec<MediaKind>>,
298 pub temperature_per_prim: Option<indexmap::IndexMap<String, f64>>,
300 #[serde(flatten)]
302 pub extra: Map<String, Value>,
303}
304
305#[derive(Clone, Debug, Deserialize)]
307pub struct ModelsResponse {
308 pub models: Vec<ModelMetadata>,
310 #[serde(flatten)]
312 pub extra: Map<String, Value>,
313}
314
315#[non_exhaustive]
317pub enum Error {
318 InvalidConfig(&'static str),
320 InvalidRequest(&'static str),
322 InvalidResponse(&'static str),
324 Transport(Box<dyn std::error::Error + Send + Sync>),
326 Encode(serde_json::Error),
328 Protocol(serde_json::Error),
330 Timeout,
332 ResponseTooLarge,
334 Http {
336 status: u16,
338 body: Value,
340 retry_after: Option<String>,
342 },
343}
344
345impl fmt::Display for Error {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 match self {
348 Self::InvalidConfig(message) => write!(f, "invalid Basal client configuration: {message}"),
349 Self::InvalidRequest(message) => write!(f, "invalid Basal request: {message}"),
350 Self::InvalidResponse(message) => write!(f, "invalid Basal answer: {message}"),
351 Self::Transport(_) => f.write_str("Basal HTTP transport failed"),
352 Self::Encode(_) => f.write_str("could not encode Basal request"),
353 Self::Protocol(_) => f.write_str("invalid Basal JSON response"),
354 Self::Timeout => f.write_str("Basal HTTP request timed out"),
355 Self::ResponseTooLarge => f.write_str("Basal response exceeded the configured byte limit"),
356 Self::Http { status, .. } => write!(f, "Basal HTTP {status}"),
357 }
358 }
359}
360
361impl fmt::Debug for Error {
362 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363 fmt::Display::fmt(self, f)
365 }
366}
367
368impl std::error::Error for Error {
369 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
370 match self {
371 Self::Transport(source) => Some(source.as_ref()),
372 Self::Encode(source) | Self::Protocol(source) => Some(source),
373 _ => None,
374 }
375 }
376}
377
378pub struct ClientOptions<'a> {
380 pub base_url: &'a str,
382 pub api_key: Option<&'a str>,
384 pub timeout: Duration,
386 pub max_response_bytes: usize,
388}
389
390impl Default for ClientOptions<'_> {
391 fn default() -> Self {
392 Self {
393 base_url: "http://127.0.0.1:8000",
394 api_key: None,
395 timeout: Duration::from_secs(120),
396 max_response_bytes: 8 * 1024 * 1024,
397 }
398 }
399}
400
401#[derive(Clone)]
404pub struct Client {
405 http: hyper_util::client::legacy::Client<HttpsConnector<HttpConnector>, Full<Bytes>>,
406 base_url: String,
407 headers: HeaderMap,
408 timeout: Duration,
409 max_response_bytes: usize,
410}
411
412impl Client {
413 pub fn new(base_url: &str) -> Result<Self, Error> {
415 Self::with_options(ClientOptions { base_url, ..ClientOptions::default() })
416 }
417
418 pub fn with_options(options: ClientOptions<'_>) -> Result<Self, Error> {
421 let url: Uri = options.base_url.parse().map_err(|_| Error::InvalidConfig("invalid URL"))?;
422 let scheme = url.scheme_str().unwrap_or_default();
423 let host = url.host().unwrap_or_default();
424 if !matches!(scheme, "http" | "https")
425 || host.is_empty()
426 || options.base_url.contains(['?', '#'])
427 || url.authority().is_some_and(|a| a.as_str().contains('@'))
428 {
429 return Err(Error::InvalidConfig("expected an HTTP(S) URL without credentials, query or fragment"));
430 }
431 let loopback = host.eq_ignore_ascii_case("localhost")
432 || host.trim_matches(['[', ']']).parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback());
433 if scheme != "https" && !loopback {
434 return Err(Error::InvalidConfig("base_url requires HTTPS or loopback HTTP"));
435 }
436 if options.timeout.is_zero() || options.max_response_bytes == 0 {
437 return Err(Error::InvalidConfig("timeout and max_response_bytes must be positive"));
438 }
439 let mut headers = HeaderMap::new();
440 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
441 headers.insert(USER_AGENT, HeaderValue::from_static(concat!("basal-client-rust/", env!("CARGO_PKG_VERSION"))));
442 if let Some(key) = options.api_key {
443 if key.is_empty() || key.contains(['\r', '\n']) {
444 return Err(Error::InvalidConfig("api_key must be a nonempty, single-line token"));
445 }
446 let mut value = HeaderValue::from_str(&format!("Bearer {key}"))
447 .map_err(|_| Error::InvalidConfig("invalid bearer token"))?;
448 value.set_sensitive(true);
449 headers.insert(AUTHORIZATION, value);
450 }
451 let connector = HttpsConnectorBuilder::new()
452 .with_provider_and_platform_verifier(rustls::crypto::ring::default_provider())
453 .map_err(|source| Error::Transport(Box::new(source)))?
454 .https_or_http()
455 .enable_http1()
456 .build();
457 let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new())
458 .retry_canceled_requests(false)
459 .pool_max_idle_per_host(8)
460 .pool_timer(TokioTimer::new())
461 .pool_idle_timeout(Duration::from_secs(90))
462 .build(connector);
463 Ok(Self {
464 http,
465 base_url: format!("{}/", options.base_url.trim_end_matches('/')),
466 headers,
467 timeout: options.timeout,
468 max_response_bytes: options.max_response_bytes,
469 })
470 }
471
472 async fn send<T: DeserializeOwned>(&self, method: Method, path: &str, body: Option<Vec<u8>>) -> Result<T, Error> {
473 let content_type = body.as_ref().map(|_| "application/json");
474 let bytes = self.request_bytes(method, path, body, content_type, HeaderMap::new()).await?;
475 serde_json::from_slice(&bytes).map_err(Error::Protocol)
476 }
477
478 async fn request_bytes(
479 &self,
480 method: Method,
481 path: &str,
482 body: Option<Vec<u8>>,
483 content_type: Option<&str>,
484 extra_headers: HeaderMap,
485 ) -> Result<Vec<u8>, Error> {
486 let work = async {
487 let mut request = Request::builder().method(method).uri(format!("{}{path}", self.base_url));
490 if let Some(headers) = request.headers_mut() {
491 *headers = self.headers.clone();
492 headers.extend(extra_headers);
493 }
494 request = request.header(http::header::ACCEPT_ENCODING, "identity");
495 if let Some(content_type) = content_type {
496 request = request.header(CONTENT_TYPE, content_type);
497 }
498 let request = request
499 .body(Full::new(Bytes::from(body.unwrap_or_default())))
500 .map_err(|source| Error::Transport(Box::new(source)))?;
501 let response = self.http.request(request).await.map_err(|source| Error::Transport(Box::new(source)))?;
502 let status = response.status();
503 let retry_after = response.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()).map(String::from);
504 if response
505 .headers()
506 .get(http::header::CONTENT_ENCODING)
507 .is_some_and(|v| !v.as_bytes().is_empty() && !v.as_bytes().eq_ignore_ascii_case(b"identity"))
508 {
509 return Err(Error::InvalidResponse("unsupported response content encoding"));
510 }
511 let mut body = response.into_body();
512 let mut bytes = Vec::new();
513 while let Some(frame) = body.frame().await {
514 let frame = frame.map_err(|source| Error::Transport(Box::new(source)))?;
515 if let Ok(data) = frame.into_data() {
516 if data.len() > self.max_response_bytes.saturating_sub(bytes.len()) {
517 return Err(Error::ResponseTooLarge);
518 }
519 bytes.extend_from_slice(&data);
520 }
521 }
522 if !status.is_success() {
523 let body = serde_json::from_slice(&bytes)
524 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()));
525 return Err(Error::Http { status: status.as_u16(), body, retry_after });
526 }
527 Ok(bytes)
528 };
529 tokio::time::timeout(self.timeout, work).await.map_err(|_| Error::Timeout)?
530 }
531
532 pub async fn decide<Q: QuestionSet>(
534 &self,
535 state: impl Into<Value>,
536 questions: &Q,
537 ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
538 self.decide_with_options(state, questions, DecisionOptions::default()).await
539 }
540
541 pub async fn decide_with_options<Q: QuestionSet>(
543 &self,
544 state: impl Into<Value>,
545 questions: &Q,
546 options: DecisionOptions<'_>,
547 ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
548 let request = questions.as_questions().request(state.into(), options)?;
549 let response = self.decide_request(&request).await?;
550 questions.as_questions().clone().parse(response, questions.schema())
551 }
552
553 pub async fn decide_request(&self, request: &DecisionRequest) -> Result<DecisionResponse, Error> {
555 self.send(Method::POST, "v1/basal", Some(serde_json::to_vec(request).map_err(Error::Encode)?)).await
556 }
557
558 pub async fn models(&self) -> Result<ModelsResponse, Error> {
560 self.send(Method::GET, "v1/models", None).await
561 }
562}