Skip to main content

hikmah_kernel/
jev.rs

1//! TypeSafe Jev (System One) adapter for the typed decision port.
2//!
3//! API contract: `POST {base}/v1/systemone` with bearer auth and
4//! `{ model, state, questions: { id: { type, instructions, criteria } } }`; answers come back as
5//! `{ model, answers: { id: { type, ... } }, usage }` (see <https://docs.typesafe.ai/api.md>).
6//!
7//! - Opt-in: nothing here runs unless an engine is selected and `TYPESAFE_API_KEY` is set.
8//! - The key is never logged; `Debug` redacts it and errors never include request headers.
9//! - The kernel's [`crate::decision_port::ask`] refuses credential-bearing state before this
10//!   adapter is called, and [`crate::decision_port::admit`] validates every answer afterwards.
11//! - 429/529/5xx are retried with backoff inside the time budget; other failures return an error,
12//!   which `ask` turns into an all-abstain decision so callers fall back to deterministic rules.
13use 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
26/// HTTP transport, injectable for tests. Returns `(status, json_body)`.
27pub 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/// Production transport: `ureq` with the platform certificate verifier (honours system roots,
38/// `SSL_CERT_FILE`, and corporate proxies) and proxy settings from the environment.
39#[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    /// Build from `TYPESAFE_API_KEY` (required), `TYPESAFE_BASE_URL`, `HIKMAH_JEV_MODEL`, and
109    /// `HIKMAH_JEV_TIMEOUT_MS`. Returns `None` when no key is configured.
110    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    /// Total time budget including retries, clamped to 100 ms..=60 s.
141    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    /// The System One request body for a kernel request.
166    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}