Skip to main content

locus_sdk/infrastructure/system1/
http.rs

1//! HTTP client for a System 1 server that speaks `POST /v1/systemone`.
2//!
3//! That is the wire Laya's `laya-serve`, the Rust `sys1` server, and
4//! Jev-compatible hosts share. This client does not load a checkpoint.
5
6use std::time::Duration;
7
8use anyhow::{Result, bail};
9use async_trait::async_trait;
10use serde_json::Value;
11
12use crate::domain::system1::{System1Decider, System1Request, System1Response};
13
14/// Posts [`System1Request`] values to a remote System 1 endpoint.
15#[derive(Debug, Clone)]
16pub struct HttpSystem1 {
17    client: reqwest::Client,
18    endpoint: String,
19    model: Option<String>,
20    api_key: Option<String>,
21}
22
23impl HttpSystem1 {
24    /// `endpoint` may be a base URL or a full `/v1/systemone` or `/v1/decide` URL.
25    pub fn new(endpoint: impl Into<String>) -> Self {
26        let client = reqwest::Client::builder()
27            .timeout(Duration::from_secs(30))
28            .build()
29            .unwrap_or_else(|_| reqwest::Client::new());
30        Self {
31            client,
32            endpoint: join_system1_endpoint(&endpoint.into()),
33            model: None,
34            api_key: None,
35        }
36    }
37
38    pub fn with_client(mut self, client: reqwest::Client) -> Self {
39        self.client = client;
40        self
41    }
42
43    /// Pin a checkpoint such as `typed-decisions`. Unset lets the server route.
44    pub fn with_model(mut self, model: impl Into<String>) -> Self {
45        self.model = Some(model.into());
46        self
47    }
48
49    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
50        let api_key = api_key.into();
51        self.api_key = if api_key.is_empty() {
52            None
53        } else {
54            Some(api_key)
55        };
56        self
57    }
58
59    pub fn endpoint(&self) -> &str {
60        &self.endpoint
61    }
62
63    /// JSON body this client will POST. Useful when a host wants to log the forward pass.
64    pub fn request_json(&self, request: &System1Request) -> Value {
65        let mut body = serde_json::to_value(request).unwrap_or_else(|_| serde_json::json!({}));
66        if request.model.is_none() {
67            if let Some(model) = &self.model {
68                body["model"] = Value::String(model.clone());
69            }
70        }
71        body
72    }
73}
74
75/// Append `/v1/systemone` unless the URL already names a System 1 route.
76pub fn join_system1_endpoint(endpoint: &str) -> String {
77    let trimmed = endpoint.trim().trim_end_matches('/');
78    if trimmed.ends_with("/v1/systemone") || trimmed.ends_with("/v1/decide") {
79        trimmed.to_string()
80    } else if trimmed.is_empty() {
81        "/v1/systemone".to_string()
82    } else {
83        format!("{trimmed}/v1/systemone")
84    }
85}
86
87#[async_trait]
88impl System1Decider for HttpSystem1 {
89    fn decider_id(&self) -> &str {
90        "http-system1"
91    }
92
93    async fn predict(&self, request: &System1Request) -> Result<System1Response> {
94        let mut call = self
95            .client
96            .post(&self.endpoint)
97            .json(&self.request_json(request));
98        if let Some(api_key) = &self.api_key {
99            call = call.bearer_auth(api_key);
100        }
101        let response = call.send().await?;
102        let status = response.status();
103        let body = response.text().await?;
104        if !status.is_success() {
105            bail!(
106                "system 1 endpoint returned {status}: {}",
107                truncate(&body, 300)
108            );
109        }
110        let value: Value = serde_json::from_str(&body)?;
111        let mut parsed = System1Response::parse_wire(&request.questions, &value)?;
112        parsed.decider_id = self.decider_id().to_string();
113        if parsed.checkpoint.is_none() {
114            parsed.checkpoint = self.model.clone();
115        }
116        Ok(parsed)
117    }
118}
119
120fn truncate(text: &str, max: usize) -> String {
121    let mut out = text.chars().take(max).collect::<String>();
122    if text.chars().count() > max {
123        out.push('…');
124    }
125    out
126}
127
128#[cfg(test)]
129mod tests {
130    use super::{HttpSystem1, join_system1_endpoint};
131    use crate::domain::system1::{DecisionQuestion, System1Request};
132    use std::collections::BTreeMap;
133
134    #[test]
135    fn endpoint_join_keeps_existing_system1_routes() {
136        assert_eq!(
137            join_system1_endpoint("http://127.0.0.1:8000"),
138            "http://127.0.0.1:8000/v1/systemone"
139        );
140        assert_eq!(
141            join_system1_endpoint("http://127.0.0.1:8000/"),
142            "http://127.0.0.1:8000/v1/systemone"
143        );
144        assert_eq!(
145            join_system1_endpoint("http://127.0.0.1:8000/v1/systemone"),
146            "http://127.0.0.1:8000/v1/systemone"
147        );
148        assert_eq!(
149            join_system1_endpoint("http://127.0.0.1:3000/v1/decide/"),
150            "http://127.0.0.1:3000/v1/decide"
151        );
152    }
153
154    #[test]
155    fn request_json_pins_the_configured_model() {
156        let decider = HttpSystem1::new("http://127.0.0.1:8000").with_model("typed-decisions");
157        let request = System1Request {
158            state: serde_json::json!({"text": "remember that I prefer tea"}),
159            questions: BTreeMap::from([(
160                "should_persist".to_string(),
161                DecisionQuestion::Noul {
162                    instructions: "store this?".to_string(),
163                },
164            )]),
165            model: None,
166        };
167        let body = decider.request_json(&request);
168        assert_eq!(body["model"], "typed-decisions");
169        assert_eq!(body["questions"]["should_persist"]["type"], "noul");
170        assert_eq!(decider.endpoint(), "http://127.0.0.1:8000/v1/systemone");
171    }
172}