locus_sdk/infrastructure/system1/
http.rs1use 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#[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 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 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 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
75pub 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}