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