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 questions;
10pub use questions::*;
11
12use std::{fmt, net::IpAddr, time::Duration};
13
14use bytes::Bytes;
15use http::{Method, Request, Uri};
16use http_body_util::{BodyExt, Full};
17use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
18use hyper_util::{
19    client::legacy::connect::HttpConnector,
20    rt::{TokioExecutor, TokioTimer},
21};
22
23use http::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER, USER_AGENT};
24use serde::{de::DeserializeOwned, Deserialize, Serialize};
25use serde_json::{Map, Value};
26
27/// A native question type.
28#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
29#[non_exhaustive]
30#[serde(rename_all = "lowercase")]
31pub enum QuestionType {
32    /// Choose one of 2–255 options.
33    Choice,
34    /// Probability of "yes".
35    Noul,
36    /// Weighted mean of 2–255 ordered levels, indexed from zero.
37    Score,
38    /// Select labels independently (basal-1.5).
39    Multi,
40    /// Select the action with minimum expected cost (basal-1.5).
41    Act,
42    /// Select a tool or a set of tools without executing them.
43    Tool,
44}
45
46/// Native question. Criteria and cost matrices are JSON objects or arrays; preserve their order.
47/// Additional native fields (including future extensions) go in `extra`.
48#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct Question {
50    /// Question type, serialized as `type`.
51    #[serde(rename = "type")]
52    pub kind: QuestionType,
53    /// Instruction text, or another JSON value accepted by the native API.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub instructions: Option<Value>,
56    /// Native criteria object or ordered list.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub criteria: Option<Value>,
59    /// Additional question fields, such as `costs`, `threshold`, `evidence`, or `option_keys`.
60    /// Do not repeat `type`, `instructions`, or `criteria` here.
61    #[serde(flatten)]
62    pub extra: Map<String, Value>,
63}
64
65impl Question {
66    /// Start a question with instruction text; add criteria and extension fields as needed.
67    pub fn new(kind: QuestionType, instructions: impl Into<String>) -> Self {
68        Self { kind, instructions: Some(Value::String(instructions.into())), criteria: None, extra: Map::new() }
69    }
70}
71
72/// Native inference request. A missing model uses the server's default; basal-rs accepts a model name.
73#[derive(Clone, Debug, Serialize, Deserialize)]
74pub struct DecisionRequest {
75    /// Any non-null JSON state; its rendering belongs to the server.
76    pub state: Value,
77    /// Question IDs mapped to native questions, in insertion order.
78    pub questions: Map<String, Value>,
79    /// Exact model name advertised by `/v1/models`, or omitted for the server default.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub model: Option<String>,
82    /// Explicit prompt language.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub lang: Option<Language>,
85    /// Inline media in processing order.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub media: Option<Vec<MediaInput>>,
88    /// Image detail budget.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub detail: Option<MediaDetail>,
91    /// Optional native fields such as `facts: "auto"`. Do not repeat the fields above.
92    #[serde(flatten)]
93    pub extra: Map<String, Value>,
94}
95
96impl DecisionRequest {
97    /// Create a request for the server's default model. Insert questions with [`Self::insert_question`].
98    pub fn new(state: impl Into<Value>) -> Self {
99        Self {
100            state: state.into(),
101            questions: Map::new(),
102            model: None,
103            lang: None,
104            media: None,
105            detail: None,
106            extra: Map::new(),
107        }
108    }
109
110    /// Insert a typed question, preserving insertion order. Replaces an existing question of the same ID.
111    pub fn insert_question(&mut self, id: impl Into<String>, question: &Question) -> Result<(), Error> {
112        self.questions.insert(id.into(), serde_json::to_value(question).map_err(Error::Encode)?);
113        Ok(())
114    }
115}
116
117/// Native answer. Fields used by other question types are absent. Unknown fields are retained in `extra`.
118#[derive(Clone, Debug, Deserialize)]
119pub struct Answer {
120    /// Question type.
121    #[serde(rename = "type")]
122    pub kind: QuestionType,
123    /// Native probabilities (for multi: independent P(yes) values).
124    pub probabilities: indexmap::IndexMap<String, f64>,
125    /// Native max probability for choice, noul, score and act. Multi uses `set_confidence`.
126    pub confidence: Option<f64>,
127    /// Chosen key for choice.
128    pub choice: Option<String>,
129    /// P(yes) for noul.
130    pub noul: Option<f64>,
131    /// Weighted zero-based score.
132    pub score: Option<f64>,
133    /// Displayed option texts for score, indexed by key.
134    pub legend: Option<indexmap::IndexMap<String, String>>,
135    /// Selected keys for multi.
136    pub selected: Option<Vec<String>>,
137    /// Multi selection threshold.
138    pub threshold: Option<f64>,
139    /// Probability of precisely the selected set for multi.
140    pub set_confidence: Option<f64>,
141    /// Chosen action for act.
142    pub action: Option<String>,
143    /// Most probable outcome for act.
144    pub answer: Option<String>,
145    /// Expected costs by action for act.
146    pub expected_costs: Option<indexmap::IndexMap<String, f64>>,
147    /// Calibration status for act.
148    pub calibration: Option<String>,
149    /// Refused automatic action for act.
150    pub refused: Option<String>,
151    /// Message when the requested certified threshold is unavailable.
152    pub max_error: Option<String>,
153    /// Evidence offsets count Unicode characters, not bytes or UTF-16 code units.
154    pub evidence: Option<Vec<EvidenceSpan>>,
155    /// Multi selection objective; absent for legacy threshold responses.
156    pub objective: Option<MultiObjective>,
157    /// Expected score of the selected set.
158    pub expected_score: Option<f64>,
159    /// Constraint diagnostics preserved from the server.
160    pub constraints_applied: Option<Value>,
161    /// Selected single tool, null on certified deferral.
162    pub tool: Option<String>,
163    /// Selected tools in multi mode.
164    pub tools: Option<Vec<String>>,
165    /// Whether the result selects an actual tool.
166    pub call: Option<bool>,
167    /// Whether this is a multi-tool result.
168    pub multi: Option<bool>,
169    /// Ranked tool candidates.
170    pub ranking: Option<Vec<ToolRanking>>,
171    /// Whether the automatic decision was deferred.
172    pub deferred: Option<bool>,
173    /// Future native fields.
174    #[serde(flatten)]
175    pub extra: Map<String, Value>,
176}
177
178/// A ranked tool candidate.
179#[derive(Debug, Clone, Deserialize)]
180pub struct ToolRanking {
181    /// Candidate key.
182    pub tool: String,
183    /// Calibrated probability.
184    pub probability: f64,
185}
186
187/// Supporting span in the original state.
188#[derive(Clone, Debug, Deserialize)]
189pub struct EvidenceSpan {
190    /// Verbatim span text.
191    pub text: String,
192    /// Inclusive start in Unicode characters.
193    pub start: usize,
194    /// Exclusive end in Unicode characters.
195    pub end: usize,
196    /// Span probability.
197    pub probability: f64,
198}
199
200/// Server-reported usage, with native extension fields retained.
201#[derive(Clone, Debug, Deserialize)]
202pub struct Usage {
203    /// Tokens in the option-order prompts.
204    pub input_tokens: u64,
205    /// Zero: the model does not generate text.
206    pub output_tokens: u64,
207    /// Optional question count.
208    pub questions: Option<u64>,
209    /// Optional branch count.
210    pub branches: Option<u64>,
211    /// Server-side latency, in milliseconds.
212    pub latency_ms: Option<f64>,
213    /// Longest executed full prompt.
214    pub longest_prompt_tokens: Option<u64>,
215    /// Number of branches executed in the second order.
216    pub second_order_branches: Option<u64>,
217    /// Inline media accounting, when present.
218    pub media: Option<MediaUsage>,
219    /// Future usage fields.
220    #[serde(flatten)]
221    pub extra: Map<String, Value>,
222}
223
224/// Native inference result keyed by question ID.
225#[derive(Clone, Debug, Deserialize)]
226pub struct DecisionResponse {
227    /// Model actually used.
228    pub model: String,
229    /// Answers in server order.
230    pub answers: indexmap::IndexMap<String, Answer>,
231    /// Server-reported usage.
232    pub usage: Usage,
233    /// Future envelope fields.
234    #[serde(flatten)]
235    pub extra: Map<String, Value>,
236}
237
238/// Model metadata. The exact `name` is accepted by basal-rs as a request's model.
239#[derive(Clone, Debug, Deserialize)]
240pub struct ModelMetadata {
241    /// Model name.
242    pub name: String,
243    /// Prompt protocol version.
244    pub prompt_format: Option<u8>,
245    /// Fixed numeric or adaptive string order policy.
246    pub orders: Option<Value>,
247    /// Supported media kinds when enabled.
248    pub media: Option<Vec<MediaKind>>,
249    /// Calibration temperature by primitive.
250    pub temperature_per_prim: Option<indexmap::IndexMap<String, f64>>,
251    /// Native metadata fields, retained without interpretation.
252    #[serde(flatten)]
253    pub extra: Map<String, Value>,
254}
255
256/// Model discovery result.
257#[derive(Clone, Debug, Deserialize)]
258pub struct ModelsResponse {
259    /// Models served by this process.
260    pub models: Vec<ModelMetadata>,
261    /// Future discovery fields.
262    #[serde(flatten)]
263    pub extra: Map<String, Value>,
264}
265
266/// Typed SDK failures. Display and Debug omit response bodies and transport details.
267#[non_exhaustive]
268pub enum Error {
269    /// Invalid URL, timeout, response limit or bearer token.
270    InvalidConfig(&'static str),
271    /// Invalid typed question or ergonomic request.
272    InvalidRequest(&'static str),
273    /// A typed response is missing required fields or contradicts its question schema.
274    InvalidResponse(&'static str),
275    /// Connection or response-body failure. Original details are available explicitly through Error::source.
276    Transport(Box<dyn std::error::Error + Send + Sync>),
277    /// Request JSON serialization failure.
278    Encode(serde_json::Error),
279    /// Successful response did not match the native JSON contract.
280    Protocol(serde_json::Error),
281    /// Entire request (including body read) exceeded its deadline.
282    Timeout,
283    /// Actual streamed response exceeds the configured limit.
284    ResponseTooLarge,
285    /// Non-success response, including native validation 422 and overload 529.
286    Http {
287        /// HTTP status code.
288        status: u16,
289        /// JSON error body, or a JSON string holding a non-JSON response. Explicit access only.
290        body: Value,
291        /// Unmodified Retry-After; no automatic retry.
292        retry_after: Option<String>,
293    },
294}
295
296impl fmt::Display for Error {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        match self {
299            Self::InvalidConfig(message) => write!(f, "invalid Basal client configuration: {message}"),
300            Self::InvalidRequest(message) => write!(f, "invalid Basal request: {message}"),
301            Self::InvalidResponse(message) => write!(f, "invalid Basal answer: {message}"),
302            Self::Transport(_) => f.write_str("Basal HTTP transport failed"),
303            Self::Encode(_) => f.write_str("could not encode Basal request"),
304            Self::Protocol(_) => f.write_str("invalid Basal JSON response"),
305            Self::Timeout => f.write_str("Basal HTTP request timed out"),
306            Self::ResponseTooLarge => f.write_str("Basal response exceeded the configured byte limit"),
307            Self::Http { status, .. } => write!(f, "Basal HTTP {status}"),
308        }
309    }
310}
311
312impl fmt::Debug for Error {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        // Error bodies and nested transport diagnostics can contain reflected documents or credentials.
315        fmt::Display::fmt(self, f)
316    }
317}
318
319impl std::error::Error for Error {
320    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
321        match self {
322            Self::Transport(source) => Some(source.as_ref()),
323            Self::Encode(source) | Self::Protocol(source) => Some(source),
324            _ => None,
325        }
326    }
327}
328
329/// Connection configuration. The API key is intentionally not printable through `Debug`.
330pub struct ClientOptions<'a> {
331    /// Server root, optionally with a proxy prefix, without `/v1`.
332    pub base_url: &'a str,
333    /// Optional bearer token for a reverse proxy; basal-rs itself does not authenticate.
334    pub api_key: Option<&'a str>,
335    /// Whole HTTP request timeout, including response-body reading.
336    pub timeout: Duration,
337    /// Maximum response bytes (default 8 MiB), including error bodies.
338    pub max_response_bytes: usize,
339}
340
341impl Default for ClientOptions<'_> {
342    fn default() -> Self {
343        Self {
344            base_url: "http://127.0.0.1:8000",
345            api_key: None,
346            timeout: Duration::from_secs(120),
347            max_response_bytes: 8 * 1024 * 1024,
348        }
349    }
350}
351
352/// Cloneable, pooled HTTP/1 client with verified TLS. No redirects, automatic retries or environment proxies.
353/// DNS hostnames must be ASCII (use punycode for IDN). A Tokio runtime is required for requests.
354#[derive(Clone)]
355pub struct Client {
356    http: hyper_util::client::legacy::Client<HttpsConnector<HttpConnector>, Full<Bytes>>,
357    base_url: String,
358    headers: HeaderMap,
359    timeout: Duration,
360    max_response_bytes: usize,
361}
362
363impl Client {
364    /// Connect to a server root with the default timeout and no authentication.
365    pub fn new(base_url: &str) -> Result<Self, Error> {
366        Self::with_options(ClientOptions { base_url, ..ClientOptions::default() })
367    }
368
369    /// Validate configuration and build a connection pool. TLS uses an explicit per-client ring provider
370    /// and the platform's certificate verifier; no process-global crypto provider is installed.
371    pub fn with_options(options: ClientOptions<'_>) -> Result<Self, Error> {
372        let url: Uri = options.base_url.parse().map_err(|_| Error::InvalidConfig("invalid URL"))?;
373        let scheme = url.scheme_str().unwrap_or_default();
374        let host = url.host().unwrap_or_default();
375        if !matches!(scheme, "http" | "https")
376            || host.is_empty()
377            || options.base_url.contains(['?', '#'])
378            || url.authority().is_some_and(|a| a.as_str().contains('@'))
379        {
380            return Err(Error::InvalidConfig("expected an HTTP(S) URL without credentials, query or fragment"));
381        }
382        let loopback = host.eq_ignore_ascii_case("localhost")
383            || host.trim_matches(['[', ']']).parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback());
384        if scheme != "https" && !loopback {
385            return Err(Error::InvalidConfig("base_url requires HTTPS or loopback HTTP"));
386        }
387        if options.timeout.is_zero() || options.max_response_bytes == 0 {
388            return Err(Error::InvalidConfig("timeout and max_response_bytes must be positive"));
389        }
390        let mut headers = HeaderMap::new();
391        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
392        headers.insert(USER_AGENT, HeaderValue::from_static(concat!("basal-client-rust/", env!("CARGO_PKG_VERSION"))));
393        if let Some(key) = options.api_key {
394            if key.is_empty() || key.contains(['\r', '\n']) {
395                return Err(Error::InvalidConfig("api_key must be a nonempty, single-line token"));
396            }
397            let mut value = HeaderValue::from_str(&format!("Bearer {key}"))
398                .map_err(|_| Error::InvalidConfig("invalid bearer token"))?;
399            value.set_sensitive(true);
400            headers.insert(AUTHORIZATION, value);
401        }
402        let connector = HttpsConnectorBuilder::new()
403            .with_provider_and_platform_verifier(rustls::crypto::ring::default_provider())
404            .map_err(|source| Error::Transport(Box::new(source)))?
405            .https_or_http()
406            .enable_http1()
407            .build();
408        let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new())
409            .retry_canceled_requests(false)
410            .pool_max_idle_per_host(8)
411            .pool_timer(TokioTimer::new())
412            .pool_idle_timeout(Duration::from_secs(90))
413            .build(connector);
414        Ok(Self {
415            http,
416            base_url: format!("{}/", options.base_url.trim_end_matches('/')),
417            headers,
418            timeout: options.timeout,
419            max_response_bytes: options.max_response_bytes,
420        })
421    }
422
423    async fn send<T: DeserializeOwned>(&self, method: Method, path: &str, body: Option<Vec<u8>>) -> Result<T, Error> {
424        let work = async {
425            // Endpoint paths are fixed SDK constants; question data cannot choose another origin.
426            let mut request = Request::builder().method(method).uri(format!("{}{path}", self.base_url));
427            if let Some(headers) = request.headers_mut() {
428                *headers = self.headers.clone();
429            }
430            if body.is_some() {
431                request = request.header(CONTENT_TYPE, "application/json");
432            }
433            let request = request
434                .body(Full::new(Bytes::from(body.unwrap_or_default())))
435                .map_err(|source| Error::Transport(Box::new(source)))?;
436            let response = self.http.request(request).await.map_err(|source| Error::Transport(Box::new(source)))?;
437            let status = response.status();
438            let retry_after = response.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()).map(String::from);
439            let mut body = response.into_body();
440            let mut bytes = Vec::new();
441            while let Some(frame) = body.frame().await {
442                let frame = frame.map_err(|source| Error::Transport(Box::new(source)))?;
443                if let Ok(data) = frame.into_data() {
444                    if data.len() > self.max_response_bytes.saturating_sub(bytes.len()) {
445                        return Err(Error::ResponseTooLarge);
446                    }
447                    bytes.extend_from_slice(&data);
448                }
449            }
450            if !status.is_success() {
451                let body = serde_json::from_slice(&bytes)
452                    .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()));
453                return Err(Error::Http { status: status.as_u16(), body, retry_after });
454            }
455            serde_json::from_slice(&bytes).map_err(Error::Protocol)
456        };
457        tokio::time::timeout(self.timeout, work).await.map_err(|_| Error::Timeout)?
458    }
459
460    /// Decide from a JSON state and typed question builder.
461    pub async fn decide<Q: QuestionSet>(
462        &self,
463        state: impl Into<Value>,
464        questions: &Q,
465    ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
466        self.decide_with_options(state, questions, DecisionOptions::default()).await
467    }
468
469    /// Typed decision with explicit model/facts options.
470    pub async fn decide_with_options<Q: QuestionSet>(
471        &self,
472        state: impl Into<Value>,
473        questions: &Q,
474        options: DecisionOptions<'_>,
475    ) -> Result<TypedDecisionResponse<Q::Schema>, Error> {
476        let request = questions.as_questions().request(state.into(), options)?;
477        let response = self.decide_request(&request).await?;
478        questions.as_questions().clone().parse(response, questions.schema())
479    }
480
481    /// POST `/v1/basal`. Request fields and capability limits are validated by the server.
482    pub async fn decide_request(&self, request: &DecisionRequest) -> Result<DecisionResponse, Error> {
483        self.send(Method::POST, "v1/basal", Some(serde_json::to_vec(request).map_err(Error::Encode)?)).await
484    }
485
486    /// GET `/v1/models`.
487    pub async fn models(&self) -> Result<ModelsResponse, Error> {
488        self.send(Method::GET, "v1/models", None).await
489    }
490}