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 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, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER, USER_AGENT};
30use serde::{de::DeserializeOwned, Deserialize, Serialize};
31use serde_json::{Map, Value};
32
33/// A native question type.
34#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
35#[non_exhaustive]
36#[serde(rename_all = "lowercase")]
37pub enum QuestionType {
38    /// Choose one of 2–255 options.
39    Choice,
40    /// Probability of "yes".
41    Noul,
42    /// Weighted mean of 2–255 ordered levels, indexed from zero.
43    Score,
44    /// Select labels independently (basal-1.5).
45    Multi,
46    /// Select the action with minimum expected cost (basal-1.5).
47    Act,
48    /// Select a tool or a set of tools without executing them.
49    Tool,
50    /// Experimental document classification.
51    Classify,
52    /// Experimental claim verification.
53    Verify,
54    /// Experimental extractive compression.
55    Compress,
56    /// Experimental schema extraction.
57    Extract,
58}
59
60/// Native question. Criteria and cost matrices are JSON objects or arrays; preserve their order.
61/// Additional native fields (including future extensions) go in `extra`.
62#[derive(Clone, Debug, Serialize, Deserialize)]
63pub struct Question {
64    /// Question type, serialized as `type`.
65    #[serde(rename = "type")]
66    pub kind: QuestionType,
67    /// Instruction text, or another JSON value accepted by the native API.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub instructions: Option<Value>,
70    /// Native criteria object or ordered list.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub criteria: Option<Value>,
73    /// Additional question fields, such as `costs`, `threshold`, `evidence`, or `option_keys`.
74    /// Do not repeat `type`, `instructions`, or `criteria` here.
75    #[serde(flatten)]
76    pub extra: Map<String, Value>,
77}
78
79impl Question {
80    /// Start a question with instruction text; add criteria and extension fields as needed.
81    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/// Native inference request. A missing model uses the server's default; basal-rs accepts a model name.
87#[derive(Clone, Debug, Serialize, Deserialize)]
88pub struct DecisionRequest {
89    /// Any non-null JSON state; its rendering belongs to the server.
90    pub state: Value,
91    /// Question IDs mapped to native questions, in insertion order.
92    pub questions: Map<String, Value>,
93    /// Exact model name advertised by `/v1/models`, or omitted for the server default.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub model: Option<String>,
96    /// Explicit prompt language.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub lang: Option<Language>,
99    /// Inline media in processing order.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub media: Option<Vec<MediaInput>>,
102    /// Image detail budget.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub detail: Option<MediaDetail>,
105    /// Optional native fields such as `facts: "auto"`. Do not repeat the fields above.
106    #[serde(flatten)]
107    pub extra: Map<String, Value>,
108}
109
110impl DecisionRequest {
111    /// Create a request for the server's default model. Insert questions with [`Self::insert_question`].
112    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    /// Insert a typed question, preserving insertion order. Replaces an existing question of the same ID.
125    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/// Native answer. Fields used by other question types are absent. Unknown fields are retained in `extra`.
132#[derive(Clone, Debug, Deserialize)]
133pub struct Answer {
134    /// Question type.
135    #[serde(rename = "type")]
136    pub kind: QuestionType,
137    /// Native probabilities (for multi: independent P(yes) values).
138    #[serde(default)]
139    pub probabilities: indexmap::IndexMap<String, f64>,
140    /// Native max probability for choice, noul, score and act. Multi uses `set_confidence`.
141    pub confidence: Option<f64>,
142    /// Chosen key for choice.
143    pub choice: Option<String>,
144    /// P(yes) for noul.
145    pub noul: Option<f64>,
146    /// Weighted zero-based score.
147    pub score: Option<f64>,
148    /// Displayed option texts for score, indexed by key.
149    pub legend: Option<indexmap::IndexMap<String, String>>,
150    /// Selected keys for multi.
151    pub selected: Option<Vec<String>>,
152    /// Multi selection threshold.
153    pub threshold: Option<f64>,
154    /// Probability of precisely the selected set for multi.
155    pub set_confidence: Option<f64>,
156    /// Chosen action for act.
157    pub action: Option<String>,
158    /// Most probable outcome for act.
159    pub answer: Option<String>,
160    /// Expected costs by action for act.
161    pub expected_costs: Option<indexmap::IndexMap<String, f64>>,
162    /// Calibration status for act.
163    pub calibration: Option<String>,
164    /// Refused automatic action for act.
165    pub refused: Option<String>,
166    /// Message when the requested certified threshold is unavailable.
167    pub max_error: Option<String>,
168    /// Evidence offsets count Unicode characters, not bytes or UTF-16 code units.
169    pub evidence: Option<Vec<EvidenceSpan>>,
170    /// Multi selection objective; absent for legacy threshold responses.
171    pub objective: Option<MultiObjective>,
172    /// Expected score of the selected set.
173    pub expected_score: Option<f64>,
174    /// Constraint diagnostics preserved from the server.
175    pub constraints_applied: Option<Value>,
176    /// Selected single tool, null on certified deferral.
177    pub tool: Option<String>,
178    /// Selected tools in multi mode.
179    pub tools: Option<Vec<String>>,
180    /// Whether the result selects an actual tool.
181    pub call: Option<bool>,
182    /// Whether this is a multi-tool result.
183    pub multi: Option<bool>,
184    /// Ranked tool candidates.
185    pub ranking: Option<Vec<ToolRanking>>,
186    /// Whether the automatic decision was deferred.
187    pub deferred: Option<bool>,
188    /// Experimental task marker.
189    pub experimental: Option<bool>,
190    /// Single classification label.
191    pub label: Option<String>,
192    /// Top classification label.
193    pub top_label: Option<String>,
194    /// Selected classification labels.
195    pub labels: Option<Vec<String>>,
196    /// Classification probabilities.
197    pub scores: Option<indexmap::IndexMap<String, f64>>,
198    /// Task-specific certified automation; null values mean unavailable.
199    pub automated: Option<Automation>,
200    /// Per-claim verification results.
201    pub claims: Option<indexmap::IndexMap<String, ClaimResult>>,
202    /// Whether every claim is supported.
203    pub all_supported: Option<bool>,
204    /// Flagged claim keys.
205    pub not_supported: Option<Vec<String>>,
206    /// Source-preserving compressed text.
207    pub compressed: Option<String>,
208    /// Kept source unit indices.
209    pub kept: Option<Vec<usize>>,
210    /// Source unit spans in Unicode code points.
211    pub units: Option<Vec<CompressionUnit>>,
212    /// Compression segmentation.
213    pub unit: Option<CompressionUnitKind>,
214    /// Source Unicode code point count.
215    pub original_chars: Option<usize>,
216    /// Compressed Unicode code point count.
217    pub compressed_chars: Option<usize>,
218    /// Compressed/source character ratio.
219    pub ratio: Option<f64>,
220    /// Nested extracted data; missing values are null.
221    pub data: Option<indexmap::IndexMap<String, Value>>,
222    /// Extraction field provenance and probabilities.
223    pub fields: Option<indexmap::IndexMap<String, ExtractField>>,
224    /// Future native fields.
225    #[serde(flatten)]
226    pub extra: Map<String, Value>,
227}
228
229/// A ranked tool candidate.
230#[derive(Debug, Clone, Deserialize)]
231pub struct ToolRanking {
232    /// Candidate key.
233    pub tool: String,
234    /// Calibrated probability.
235    pub probability: f64,
236}
237
238/// Supporting span in the original state.
239#[derive(Clone, Debug, Deserialize)]
240pub struct EvidenceSpan {
241    /// Verbatim span text.
242    pub text: String,
243    /// Inclusive start in Unicode characters.
244    pub start: usize,
245    /// Exclusive end in Unicode characters.
246    pub end: usize,
247    /// Span probability.
248    pub probability: f64,
249}
250
251/// Server-reported usage, with native extension fields retained.
252#[derive(Clone, Debug, Deserialize)]
253pub struct Usage {
254    /// Tokens in the option-order prompts.
255    pub input_tokens: u64,
256    /// Zero: the model does not generate text.
257    pub output_tokens: u64,
258    /// Optional question count.
259    pub questions: Option<u64>,
260    /// Optional branch count.
261    pub branches: Option<u64>,
262    /// Server-side latency, in milliseconds.
263    pub latency_ms: Option<f64>,
264    /// Longest executed full prompt.
265    pub longest_prompt_tokens: Option<u64>,
266    /// Number of branches executed in the second order.
267    pub second_order_branches: Option<u64>,
268    /// Inline media accounting, when present.
269    pub media: Option<MediaUsage>,
270    /// Future usage fields.
271    #[serde(flatten)]
272    pub extra: Map<String, Value>,
273}
274
275/// Native inference result keyed by question ID.
276#[derive(Clone, Debug, Deserialize)]
277pub struct DecisionResponse {
278    /// Model actually used.
279    pub model: String,
280    /// Answers in server order.
281    pub answers: indexmap::IndexMap<String, Answer>,
282    /// Server-reported usage.
283    pub usage: Usage,
284    /// Future envelope fields.
285    #[serde(flatten)]
286    pub extra: Map<String, Value>,
287}
288
289/// Model metadata. The exact `name` is accepted by basal-rs as a request's model.
290#[derive(Clone, Debug, Deserialize)]
291pub struct ModelMetadata {
292    /// Model name.
293    pub name: String,
294    /// Prompt protocol version.
295    pub prompt_format: Option<u8>,
296    /// Fixed numeric or adaptive string order policy.
297    pub orders: Option<Value>,
298    /// Supported media kinds when enabled.
299    pub media: Option<Vec<MediaKind>>,
300    /// Calibration temperature by primitive.
301    pub temperature_per_prim: Option<indexmap::IndexMap<String, f64>>,
302    /// Native metadata fields, retained without interpretation.
303    #[serde(flatten)]
304    pub extra: Map<String, Value>,
305}
306
307/// Model discovery result.
308#[derive(Clone, Debug, Deserialize)]
309pub struct ModelsResponse {
310    /// Models served by this process.
311    pub models: Vec<ModelMetadata>,
312    /// Future discovery fields.
313    #[serde(flatten)]
314    pub extra: Map<String, Value>,
315}
316
317/// Typed SDK failures. Display and Debug omit response bodies and transport details.
318#[non_exhaustive]
319pub enum Error {
320    /// Invalid URL, timeout, response limit or bearer token.
321    InvalidConfig(&'static str),
322    /// Invalid typed question or ergonomic request.
323    InvalidRequest(&'static str),
324    /// A typed response is missing required fields or contradicts its question schema.
325    InvalidResponse(&'static str),
326    /// Connection or response-body failure. Original details are available explicitly through Error::source.
327    Transport(Box<dyn std::error::Error + Send + Sync>),
328    /// Request JSON serialization failure.
329    Encode(serde_json::Error),
330    /// Successful response did not match the native JSON contract.
331    Protocol(serde_json::Error),
332    /// Entire request (including body read) exceeded its deadline.
333    Timeout,
334    /// Actual streamed response exceeds the configured limit.
335    ResponseTooLarge,
336    /// Non-success response, including native validation 422 and overload 529.
337    Http {
338        /// HTTP status code.
339        status: u16,
340        /// JSON error body, or a JSON string holding a non-JSON response. Explicit access only.
341        body: Value,
342        /// Unmodified Retry-After; no automatic retry.
343        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        // Error bodies and nested transport diagnostics can contain reflected documents or credentials.
366        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
380/// Connection configuration. The API key is intentionally not printable through `Debug`.
381pub struct ClientOptions<'a> {
382    /// Server root, optionally with a proxy prefix, without `/v1`.
383    pub base_url: &'a str,
384    /// Optional bearer token; required by the server's Batch/Files API or an authenticating proxy.
385    pub api_key: Option<&'a str>,
386    /// Whole HTTP request timeout, including response-body reading.
387    pub timeout: Duration,
388    /// Maximum response bytes (default 8 MiB), including error bodies.
389    pub max_response_bytes: usize,
390}
391
392impl Default for ClientOptions<'_> {
393    fn default() -> Self {
394        Self {
395            base_url: "http://127.0.0.1:8000",
396            api_key: None,
397            timeout: Duration::from_secs(120),
398            max_response_bytes: 8 * 1024 * 1024,
399        }
400    }
401}
402
403/// Cloneable, pooled HTTP/1 client with verified TLS. No redirects, automatic retries or environment proxies.
404/// DNS hostnames must be ASCII (use punycode for IDN). A Tokio runtime is required for requests.
405#[derive(Clone)]
406pub struct Client {
407    http: hyper_util::client::legacy::Client<HttpsConnector<HttpConnector>, Full<Bytes>>,
408    base_url: String,
409    headers: HeaderMap,
410    timeout: Duration,
411    max_response_bytes: usize,
412}
413
414impl Client {
415    /// Connect to a server root with the default timeout and no authentication.
416    pub fn new(base_url: &str) -> Result<Self, Error> {
417        Self::with_options(ClientOptions { base_url, ..ClientOptions::default() })
418    }
419
420    /// Validate configuration and build a connection pool. TLS uses an explicit per-client ring provider
421    /// and the platform's certificate verifier; no process-global crypto provider is installed.
422    pub fn with_options(options: ClientOptions<'_>) -> Result<Self, Error> {
423        let url: Uri = options.base_url.parse().map_err(|_| Error::InvalidConfig("invalid URL"))?;
424        let scheme = url.scheme_str().unwrap_or_default();
425        let host = url.host().unwrap_or_default();
426        if !matches!(scheme, "http" | "https")
427            || host.is_empty()
428            || options.base_url.contains(['?', '#'])
429            || url.authority().is_some_and(|a| a.as_str().contains('@'))
430        {
431            return Err(Error::InvalidConfig("expected an HTTP(S) URL without credentials, query or fragment"));
432        }
433        let loopback = host.eq_ignore_ascii_case("localhost")
434            || host.trim_matches(['[', ']']).parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback());
435        if scheme != "https" && !loopback {
436            return Err(Error::InvalidConfig("base_url requires HTTPS or loopback HTTP"));
437        }
438        if options.timeout.is_zero() || options.max_response_bytes == 0 {
439            return Err(Error::InvalidConfig("timeout and max_response_bytes must be positive"));
440        }
441        let mut headers = HeaderMap::new();
442        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
443        headers.insert(USER_AGENT, HeaderValue::from_static(concat!("basal-client-rust/", env!("CARGO_PKG_VERSION"))));
444        if let Some(key) = options.api_key {
445            if key.is_empty() || key.contains(['\r', '\n']) {
446                return Err(Error::InvalidConfig("api_key must be a nonempty, single-line token"));
447            }
448            let mut value = HeaderValue::from_str(&format!("Bearer {key}"))
449                .map_err(|_| Error::InvalidConfig("invalid bearer token"))?;
450            value.set_sensitive(true);
451            headers.insert(AUTHORIZATION, value);
452        }
453        let connector = HttpsConnectorBuilder::new()
454            .with_provider_and_platform_verifier(rustls::crypto::ring::default_provider())
455            .map_err(|source| Error::Transport(Box::new(source)))?
456            .https_or_http()
457            .enable_http1()
458            .build();
459        let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new())
460            .retry_canceled_requests(false)
461            .pool_max_idle_per_host(8)
462            .pool_timer(TokioTimer::new())
463            .pool_idle_timeout(Duration::from_secs(90))
464            .build(connector);
465        Ok(Self {
466            http,
467            base_url: format!("{}/", options.base_url.trim_end_matches('/')),
468            headers,
469            timeout: options.timeout,
470            max_response_bytes: options.max_response_bytes,
471        })
472    }
473
474    async fn send<T: DeserializeOwned>(&self, method: Method, path: &str, body: Option<Vec<u8>>) -> Result<T, Error> {
475        let content_type = body.as_ref().map(|_| "application/json");
476        let bytes = self.request_bytes(method, path, body, content_type, HeaderMap::new()).await?;
477        serde_json::from_slice(&bytes).map_err(Error::Protocol)
478    }
479
480    async fn request_bytes(
481        &self,
482        method: Method,
483        path: &str,
484        body: Option<Vec<u8>>,
485        content_type: Option<&str>,
486        extra_headers: HeaderMap,
487    ) -> Result<Vec<u8>, Error> {
488        self.request_bytes_bounded(method, path, (body, content_type), extra_headers, self.max_response_bytes).await
489    }
490
491    async fn request_bytes_bounded(
492        &self,
493        method: Method,
494        path: &str,
495        payload: (Option<Vec<u8>>, Option<&str>),
496        extra_headers: HeaderMap,
497        success_limit: usize,
498    ) -> Result<Vec<u8>, Error> {
499        let (body, content_type) = payload;
500        let work = async {
501            // SDK paths are relative constants with validated resource ids and encoded query values.
502            // They cannot select another origin or remove a configured proxy prefix.
503            let mut request = Request::builder().method(method).uri(format!("{}{path}", self.base_url));
504            if let Some(headers) = request.headers_mut() {
505                *headers = self.headers.clone();
506                headers.extend(extra_headers);
507            }
508            request = request.header(http::header::ACCEPT_ENCODING, "identity");
509            if let Some(content_type) = content_type {
510                request = request.header(CONTENT_TYPE, content_type);
511            }
512            let request = request
513                .body(Full::new(Bytes::from(body.unwrap_or_default())))
514                .map_err(|source| Error::Transport(Box::new(source)))?;
515            let response = self.http.request(request).await.map_err(|source| Error::Transport(Box::new(source)))?;
516            let status = response.status();
517            // A larger artifact allowance never enlarges the bound on an HTTP error body.
518            let limit = if status.is_success() { success_limit } else { self.max_response_bytes };
519            let retry_after = response.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()).map(String::from);
520            if response
521                .headers()
522                .get(http::header::CONTENT_ENCODING)
523                .is_some_and(|v| !v.as_bytes().is_empty() && !v.as_bytes().eq_ignore_ascii_case(b"identity"))
524            {
525                return Err(Error::InvalidResponse("unsupported response content encoding"));
526            }
527            let mut body = response.into_body();
528            let mut bytes = Vec::new();
529            while let Some(frame) = body.frame().await {
530                let frame = frame.map_err(|source| Error::Transport(Box::new(source)))?;
531                if let Ok(data) = frame.into_data() {
532                    if data.len() > limit.saturating_sub(bytes.len()) {
533                        return Err(Error::ResponseTooLarge);
534                    }
535                    bytes.extend_from_slice(&data);
536                }
537            }
538            if !status.is_success() {
539                let body = serde_json::from_slice(&bytes)
540                    .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()));
541                return Err(Error::Http { status: status.as_u16(), body, retry_after });
542            }
543            Ok(bytes)
544        };
545        tokio::time::timeout(self.timeout, work).await.map_err(|_| Error::Timeout)?
546    }
547
548    /// Decide from a JSON state and typed question builder.
549    pub async fn decide<Q: QuestionSet>(
550        &self,
551        state: impl Into<Value>,
552        questions: &Q,
553    ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
554        self.decide_with_options(state, questions, DecisionOptions::default()).await
555    }
556
557    /// Typed decision with explicit model/facts options.
558    pub async fn decide_with_options<Q: QuestionSet>(
559        &self,
560        state: impl Into<Value>,
561        questions: &Q,
562        options: DecisionOptions<'_>,
563    ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
564        let request = questions.as_questions().request(state.into(), options)?;
565        let response = self.decide_request(&request).await?;
566        questions.as_questions().clone().parse(response, questions.schema())
567    }
568
569    /// POST `/v1/basal`. Request fields and capability limits are validated by the server.
570    pub async fn decide_request(&self, request: &DecisionRequest) -> Result<DecisionResponse, Error> {
571        self.send(Method::POST, "v1/basal", Some(serde_json::to_vec(request).map_err(Error::Encode)?)).await
572    }
573
574    /// GET `/v1/models`.
575    pub async fn models(&self) -> Result<ModelsResponse, Error> {
576        self.send(Method::GET, "v1/models", None).await
577    }
578}