Skip to main content

basal_client/
lib.rs

1//! Async HTTP client for the native Basal endpoint (`POST /v1/basal`).
2//!
3//! Reuse a [`Client`] to share its connection pool. The server validates requests and computes decisions;
4//! this crate preserves native probabilities and confidence. No GPU runtime or automatic retries are needed.
5//! See `examples/decide.rs` for usage. Dropping an in-flight future cancels the client-side request.
6
7mod 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/// A native question type.
32#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
33#[non_exhaustive]
34#[serde(rename_all = "lowercase")]
35pub enum QuestionType {
36    /// Choose one of 2–255 options.
37    Choice,
38    /// Probability of "yes".
39    Noul,
40    /// Weighted mean of 2–255 ordered levels, indexed from zero.
41    Score,
42    /// Select labels independently (basal-1.5).
43    Multi,
44    /// Select the action with minimum expected cost (basal-1.5).
45    Act,
46    /// Select a tool or a set of tools without executing them.
47    Tool,
48    /// Experimental document classification.
49    Classify,
50    /// Experimental claim verification.
51    Verify,
52    /// Experimental extractive compression.
53    Compress,
54    /// Experimental schema extraction.
55    Extract,
56}
57
58/// Native question. Criteria and cost matrices are JSON objects or arrays; preserve their order.
59/// Additional native fields (including future extensions) go in `extra`.
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct Question {
62    /// Question type, serialized as `type`.
63    #[serde(rename = "type")]
64    pub kind: QuestionType,
65    /// Instruction text, or another JSON value accepted by the native API.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub instructions: Option<Value>,
68    /// Native criteria object or ordered list.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub criteria: Option<Value>,
71    /// Additional question fields, such as `costs`, `threshold`, `evidence`, or `option_keys`.
72    /// Do not repeat `type`, `instructions`, or `criteria` here.
73    #[serde(flatten)]
74    pub extra: Map<String, Value>,
75}
76
77impl Question {
78    /// Start a question with instruction text; add criteria and extension fields as needed.
79    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/// Native inference request. A missing model uses the server's default; basal-rs accepts a model name.
85#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct DecisionRequest {
87    /// Any non-null JSON state; its rendering belongs to the server.
88    pub state: Value,
89    /// Question IDs mapped to native questions, in insertion order.
90    pub questions: Map<String, Value>,
91    /// Exact model name advertised by `/v1/models`, or omitted for the server default.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub model: Option<String>,
94    /// Explicit prompt language.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub lang: Option<Language>,
97    /// Inline media in processing order.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub media: Option<Vec<MediaInput>>,
100    /// Image detail budget.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub detail: Option<MediaDetail>,
103    /// Optional native fields such as `facts: "auto"`. Do not repeat the fields above.
104    #[serde(flatten)]
105    pub extra: Map<String, Value>,
106}
107
108impl DecisionRequest {
109    /// Create a request for the server's default model. Insert questions with [`Self::insert_question`].
110    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    /// Insert a typed question, preserving insertion order. Replaces an existing question of the same ID.
123    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/// Native answer. Fields used by other question types are absent. Unknown fields are retained in `extra`.
130#[derive(Clone, Debug, Deserialize)]
131pub struct Answer {
132    /// Question type.
133    #[serde(rename = "type")]
134    pub kind: QuestionType,
135    /// Native probabilities (for multi: independent P(yes) values).
136    #[serde(default)]
137    pub probabilities: indexmap::IndexMap<String, f64>,
138    /// Native max probability for choice, noul, score and act. Multi uses `set_confidence`.
139    pub confidence: Option<f64>,
140    /// Chosen key for choice.
141    pub choice: Option<String>,
142    /// P(yes) for noul.
143    pub noul: Option<f64>,
144    /// Weighted zero-based score.
145    pub score: Option<f64>,
146    /// Displayed option texts for score, indexed by key.
147    pub legend: Option<indexmap::IndexMap<String, String>>,
148    /// Selected keys for multi.
149    pub selected: Option<Vec<String>>,
150    /// Multi selection threshold.
151    pub threshold: Option<f64>,
152    /// Probability of precisely the selected set for multi.
153    pub set_confidence: Option<f64>,
154    /// Chosen action for act.
155    pub action: Option<String>,
156    /// Most probable outcome for act.
157    pub answer: Option<String>,
158    /// Expected costs by action for act.
159    pub expected_costs: Option<indexmap::IndexMap<String, f64>>,
160    /// Calibration status for act.
161    pub calibration: Option<String>,
162    /// Refused automatic action for act.
163    pub refused: Option<String>,
164    /// Message when the requested certified threshold is unavailable.
165    pub max_error: Option<String>,
166    /// Evidence offsets count Unicode characters, not bytes or UTF-16 code units.
167    pub evidence: Option<Vec<EvidenceSpan>>,
168    /// Multi selection objective; absent for legacy threshold responses.
169    pub objective: Option<MultiObjective>,
170    /// Expected score of the selected set.
171    pub expected_score: Option<f64>,
172    /// Constraint diagnostics preserved from the server.
173    pub constraints_applied: Option<Value>,
174    /// Selected single tool, null on certified deferral.
175    pub tool: Option<String>,
176    /// Selected tools in multi mode.
177    pub tools: Option<Vec<String>>,
178    /// Whether the result selects an actual tool.
179    pub call: Option<bool>,
180    /// Whether this is a multi-tool result.
181    pub multi: Option<bool>,
182    /// Ranked tool candidates.
183    pub ranking: Option<Vec<ToolRanking>>,
184    /// Whether the automatic decision was deferred.
185    pub deferred: Option<bool>,
186    /// Experimental task marker.
187    pub experimental: Option<bool>,
188    /// Single classification label.
189    pub label: Option<String>,
190    /// Top classification label.
191    pub top_label: Option<String>,
192    /// Selected classification labels.
193    pub labels: Option<Vec<String>>,
194    /// Classification probabilities.
195    pub scores: Option<indexmap::IndexMap<String, f64>>,
196    /// Task-specific certified automation; null values mean unavailable.
197    pub automated: Option<Automation>,
198    /// Per-claim verification results.
199    pub claims: Option<indexmap::IndexMap<String, ClaimResult>>,
200    /// Whether every claim is supported.
201    pub all_supported: Option<bool>,
202    /// Flagged claim keys.
203    pub not_supported: Option<Vec<String>>,
204    /// Source-preserving compressed text.
205    pub compressed: Option<String>,
206    /// Kept source unit indices.
207    pub kept: Option<Vec<usize>>,
208    /// Source unit spans in Unicode code points.
209    pub units: Option<Vec<CompressionUnit>>,
210    /// Compression segmentation.
211    pub unit: Option<CompressionUnitKind>,
212    /// Source Unicode code point count.
213    pub original_chars: Option<usize>,
214    /// Compressed Unicode code point count.
215    pub compressed_chars: Option<usize>,
216    /// Compressed/source character ratio.
217    pub ratio: Option<f64>,
218    /// Nested extracted data; missing values are null.
219    pub data: Option<indexmap::IndexMap<String, Value>>,
220    /// Extraction field provenance and probabilities.
221    pub fields: Option<indexmap::IndexMap<String, ExtractField>>,
222    /// Future native fields.
223    #[serde(flatten)]
224    pub extra: Map<String, Value>,
225}
226
227/// A ranked tool candidate.
228#[derive(Debug, Clone, Deserialize)]
229pub struct ToolRanking {
230    /// Candidate key.
231    pub tool: String,
232    /// Calibrated probability.
233    pub probability: f64,
234}
235
236/// Supporting span in the original state.
237#[derive(Clone, Debug, Deserialize)]
238pub struct EvidenceSpan {
239    /// Verbatim span text.
240    pub text: String,
241    /// Inclusive start in Unicode characters.
242    pub start: usize,
243    /// Exclusive end in Unicode characters.
244    pub end: usize,
245    /// Span probability.
246    pub probability: f64,
247}
248
249/// Server-reported usage, with native extension fields retained.
250#[derive(Clone, Debug, Deserialize)]
251pub struct Usage {
252    /// Tokens in the option-order prompts.
253    pub input_tokens: u64,
254    /// Zero: the model does not generate text.
255    pub output_tokens: u64,
256    /// Optional question count.
257    pub questions: Option<u64>,
258    /// Optional branch count.
259    pub branches: Option<u64>,
260    /// Server-side latency, in milliseconds.
261    pub latency_ms: Option<f64>,
262    /// Longest executed full prompt.
263    pub longest_prompt_tokens: Option<u64>,
264    /// Number of branches executed in the second order.
265    pub second_order_branches: Option<u64>,
266    /// Inline media accounting, when present.
267    pub media: Option<MediaUsage>,
268    /// Future usage fields.
269    #[serde(flatten)]
270    pub extra: Map<String, Value>,
271}
272
273/// Native inference result keyed by question ID.
274#[derive(Clone, Debug, Deserialize)]
275pub struct DecisionResponse {
276    /// Model actually used.
277    pub model: String,
278    /// Answers in server order.
279    pub answers: indexmap::IndexMap<String, Answer>,
280    /// Server-reported usage.
281    pub usage: Usage,
282    /// Future envelope fields.
283    #[serde(flatten)]
284    pub extra: Map<String, Value>,
285}
286
287/// Model metadata. The exact `name` is accepted by basal-rs as a request's model.
288#[derive(Clone, Debug, Deserialize)]
289pub struct ModelMetadata {
290    /// Model name.
291    pub name: String,
292    /// Prompt protocol version.
293    pub prompt_format: Option<u8>,
294    /// Fixed numeric or adaptive string order policy.
295    pub orders: Option<Value>,
296    /// Supported media kinds when enabled.
297    pub media: Option<Vec<MediaKind>>,
298    /// Calibration temperature by primitive.
299    pub temperature_per_prim: Option<indexmap::IndexMap<String, f64>>,
300    /// Native metadata fields, retained without interpretation.
301    #[serde(flatten)]
302    pub extra: Map<String, Value>,
303}
304
305/// Model discovery result.
306#[derive(Clone, Debug, Deserialize)]
307pub struct ModelsResponse {
308    /// Models served by this process.
309    pub models: Vec<ModelMetadata>,
310    /// Future discovery fields.
311    #[serde(flatten)]
312    pub extra: Map<String, Value>,
313}
314
315/// Typed SDK failures. Display and Debug omit response bodies and transport details.
316#[non_exhaustive]
317pub enum Error {
318    /// Invalid URL, timeout, response limit or bearer token.
319    InvalidConfig(&'static str),
320    /// Invalid typed question or ergonomic request.
321    InvalidRequest(&'static str),
322    /// A typed response is missing required fields or contradicts its question schema.
323    InvalidResponse(&'static str),
324    /// Connection or response-body failure. Original details are available explicitly through Error::source.
325    Transport(Box<dyn std::error::Error + Send + Sync>),
326    /// Request JSON serialization failure.
327    Encode(serde_json::Error),
328    /// Successful response did not match the native JSON contract.
329    Protocol(serde_json::Error),
330    /// Entire request (including body read) exceeded its deadline.
331    Timeout,
332    /// Actual streamed response exceeds the configured limit.
333    ResponseTooLarge,
334    /// Non-success response, including native validation 422 and overload 529.
335    Http {
336        /// HTTP status code.
337        status: u16,
338        /// JSON error body, or a JSON string holding a non-JSON response. Explicit access only.
339        body: Value,
340        /// Unmodified Retry-After; no automatic retry.
341        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        // Error bodies and nested transport diagnostics can contain reflected documents or credentials.
364        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
378/// Connection configuration. The API key is intentionally not printable through `Debug`.
379pub struct ClientOptions<'a> {
380    /// Server root, optionally with a proxy prefix, without `/v1`.
381    pub base_url: &'a str,
382    /// Optional bearer token; required by the server's Batch/Files API or an authenticating proxy.
383    pub api_key: Option<&'a str>,
384    /// Whole HTTP request timeout, including response-body reading.
385    pub timeout: Duration,
386    /// Maximum response bytes (default 8 MiB), including error bodies.
387    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/// Cloneable, pooled HTTP/1 client with verified TLS. No redirects, automatic retries or environment proxies.
402/// DNS hostnames must be ASCII (use punycode for IDN). A Tokio runtime is required for requests.
403#[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    /// Connect to a server root with the default timeout and no authentication.
414    pub fn new(base_url: &str) -> Result<Self, Error> {
415        Self::with_options(ClientOptions { base_url, ..ClientOptions::default() })
416    }
417
418    /// Validate configuration and build a connection pool. TLS uses an explicit per-client ring provider
419    /// and the platform's certificate verifier; no process-global crypto provider is installed.
420    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            // SDK paths are relative constants with validated resource ids and encoded query values.
488            // They cannot select another origin or remove a configured proxy prefix.
489            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    /// Decide from a JSON state and typed question builder.
533    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    /// Typed decision with explicit model/facts options.
542    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    /// POST `/v1/basal`. Request fields and capability limits are validated by the server.
554    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    /// GET `/v1/models`.
559    pub async fn models(&self) -> Result<ModelsResponse, Error> {
560        self.send(Method::GET, "v1/models", None).await
561    }
562}