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