1use crate::decision_port::{
14 DecisionEngine, DecisionRequest, EngineDescriptor, QuestionKind, RawAnswer, RawDecision,
15};
16use crate::error::{KernelError, Result};
17use serde_json::{json, Map, Value};
18use std::collections::BTreeMap;
19use std::fmt;
20use std::time::{Duration, Instant};
21
22pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
23pub const DEFAULT_MODEL: &str = "jev-latest";
24pub const DEFAULT_TIMEOUT_MS: u64 = 5_000;
25
26pub trait Transport: Send + Sync {
28 fn post_json(
29 &self,
30 url: &str,
31 api_key: &str,
32 body: &Value,
33 timeout: Duration,
34 ) -> std::result::Result<(u16, Value), String>;
35}
36
37#[derive(Debug, Default, Clone, Copy)]
40pub struct UreqTransport;
41
42impl Transport for UreqTransport {
43 fn post_json(
44 &self,
45 url: &str,
46 api_key: &str,
47 body: &Value,
48 timeout: Duration,
49 ) -> std::result::Result<(u16, Value), String> {
50 use ureq::tls::{RootCerts, TlsConfig};
51 let agent: ureq::Agent = ureq::Agent::config_builder()
52 .timeout_global(Some(timeout))
53 .http_status_as_error(false)
54 .tls_config(
55 TlsConfig::builder()
56 .root_certs(RootCerts::PlatformVerifier)
57 .build(),
58 )
59 .build()
60 .into();
61 let mut response = agent
62 .post(url)
63 .header("authorization", &format!("Bearer {api_key}"))
64 .header("content-type", "application/json")
65 .send_json(body)
66 .map_err(|error| format!("request failed: {error}"))?;
67 let status = response.status().as_u16();
68 let value = response
69 .body_mut()
70 .read_json::<Value>()
71 .unwrap_or(Value::Null);
72 Ok((status, value))
73 }
74}
75
76pub struct JevEngine {
77 api_key: String,
78 base_url: String,
79 model: String,
80 timeout: Duration,
81 max_retries: u32,
82 transport: Box<dyn Transport>,
83}
84
85impl fmt::Debug for JevEngine {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 f.debug_struct("JevEngine")
88 .field("api_key", &"<redacted>")
89 .field("base_url", &self.base_url)
90 .field("model", &self.model)
91 .field("timeout", &self.timeout)
92 .finish()
93 }
94}
95
96impl JevEngine {
97 pub fn new(api_key: impl Into<String>) -> Self {
98 Self {
99 api_key: api_key.into(),
100 base_url: DEFAULT_BASE_URL.into(),
101 model: DEFAULT_MODEL.into(),
102 timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
103 max_retries: 2,
104 transport: Box::new(UreqTransport),
105 }
106 }
107
108 pub fn from_env() -> Option<Self> {
111 let key = std::env::var("TYPESAFE_API_KEY").ok()?;
112 if key.trim().is_empty() {
113 return None;
114 }
115 let mut engine = Self::new(key.trim());
116 if let Ok(url) = std::env::var("TYPESAFE_BASE_URL") {
117 if !url.trim().is_empty() {
118 engine.base_url = url.trim().trim_end_matches('/').to_string();
119 }
120 }
121 if let Ok(model) = std::env::var("HIKMAH_JEV_MODEL") {
122 if !model.trim().is_empty() {
123 engine.model = model.trim().to_string();
124 }
125 }
126 if let Some(ms) = std::env::var("HIKMAH_JEV_TIMEOUT_MS")
127 .ok()
128 .and_then(|v| v.trim().parse::<u64>().ok())
129 {
130 engine.timeout = Duration::from_millis(ms.clamp(100, 60_000));
131 }
132 Some(engine)
133 }
134
135 pub fn with_transport(mut self, transport: Box<dyn Transport>) -> Self {
136 self.transport = transport;
137 self
138 }
139
140 pub fn with_timeout(mut self, timeout: Duration) -> Self {
142 self.timeout = timeout.clamp(Duration::from_millis(100), Duration::from_secs(60));
143 self
144 }
145
146 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
147 self.base_url = base_url.into().trim_end_matches('/').to_string();
148 self
149 }
150
151 pub fn with_model(mut self, model: impl Into<String>) -> Self {
152 self.model = model.into();
153 self
154 }
155
156 pub fn with_max_retries(mut self, retries: u32) -> Self {
157 self.max_retries = retries;
158 self
159 }
160
161 pub fn timeout(&self) -> Duration {
162 self.timeout
163 }
164
165 pub fn payload(&self, request: &DecisionRequest) -> Value {
167 let mut questions = Map::new();
168 for question in &request.questions {
169 let mut spec = Map::new();
170 spec.insert("instructions".into(), json!(question.instructions));
171 match &question.kind {
172 QuestionKind::Choice { options } => {
173 spec.insert("type".into(), json!("choice"));
174 spec.insert("criteria".into(), json!(options));
175 }
176 QuestionKind::Score { levels } => {
177 spec.insert("type".into(), json!("score"));
178 spec.insert("criteria".into(), json!(levels));
179 }
180 QuestionKind::Noul { if_true, if_false } => {
181 spec.insert("type".into(), json!("noul"));
182 if let (Some(t), Some(f)) = (if_true, if_false) {
183 spec.insert("criteria".into(), json!({"true": t, "false": f}));
184 }
185 }
186 }
187 questions.insert(question.id.clone(), Value::Object(spec));
188 }
189 json!({
190 "model": self.model,
191 "state": request.state,
192 "questions": Value::Object(questions),
193 })
194 }
195
196 fn post_with_retries(&self, body: &Value) -> Result<Value> {
197 let url = format!("{}/v1/systemone", self.base_url);
198 let deadline = Instant::now()
199 .checked_add(self.timeout)
200 .unwrap_or_else(|| Instant::now() + Duration::from_secs(60));
201 let mut attempt = 0_u32;
202 loop {
203 let remaining = deadline.saturating_duration_since(Instant::now());
204 if remaining.is_zero() {
205 return Err(KernelError::Engine("jev: time budget exhausted".into()));
206 }
207 let (status, value) = self
208 .transport
209 .post_json(&url, &self.api_key, body, remaining)
210 .map_err(|error| KernelError::Engine(format!("jev: {error}")))?;
211 if (200..300).contains(&status) {
212 return Ok(value);
213 }
214 let retryable = status == 429 || status == 529 || status >= 500;
215 if retryable && attempt < self.max_retries {
216 let backoff = Duration::from_millis(250 * 2_u64.pow(attempt));
217 if Instant::now() + backoff < deadline {
218 std::thread::sleep(backoff);
219 attempt += 1;
220 continue;
221 }
222 }
223 let kind = value
224 .pointer("/detail/error_type")
225 .and_then(Value::as_str)
226 .unwrap_or("error");
227 return Err(KernelError::Engine(format!("jev: http {status} ({kind})")));
228 }
229 }
230}
231
232impl DecisionEngine for JevEngine {
233 fn descriptor(&self) -> EngineDescriptor {
234 EngineDescriptor {
235 name: "jev".into(),
236 version: self.model.clone(),
237 }
238 }
239
240 fn decide(&self, request: &DecisionRequest) -> Result<RawDecision> {
241 let started = Instant::now();
242 let body = self.payload(request);
243 let response = self.post_with_retries(&body)?;
244 let answers_value = response
245 .get("answers")
246 .and_then(Value::as_object)
247 .ok_or_else(|| KernelError::Engine("jev: response has no answers object".into()))?;
248 let mut answers = BTreeMap::new();
249 for (id, value) in answers_value {
250 let answer =
251 serde_json::from_value::<RawAnswer>(value.clone()).unwrap_or_else(|error| {
252 RawAnswer::Malformed {
253 detail: format!("{error}"),
254 }
255 });
256 answers.insert(id.clone(), answer);
257 }
258 let version = response
259 .get("model")
260 .and_then(Value::as_str)
261 .unwrap_or(&self.model)
262 .to_string();
263 Ok(RawDecision {
264 request_id: request.request_id(),
265 engine: EngineDescriptor {
266 name: "jev".into(),
267 version,
268 },
269 answers,
270 latency_ms: started.elapsed().as_millis() as u64,
271 })
272 }
273}