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