use crate::decision_port::{
DecisionEngine, DecisionRequest, EngineDescriptor, QuestionKind, RawAnswer, RawDecision,
};
use crate::error::{KernelError, Result};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::fmt;
use std::time::{Duration, Instant};
pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
pub const DEFAULT_MODEL: &str = "jev-latest";
pub const DEFAULT_TIMEOUT_MS: u64 = 5_000;
pub trait Transport: Send + Sync {
fn post_json(
&self,
url: &str,
api_key: &str,
body: &Value,
timeout: Duration,
) -> std::result::Result<(u16, Value), String>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct UreqTransport;
impl Transport for UreqTransport {
fn post_json(
&self,
url: &str,
api_key: &str,
body: &Value,
timeout: Duration,
) -> std::result::Result<(u16, Value), String> {
use ureq::tls::{RootCerts, TlsConfig};
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.http_status_as_error(false)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.build()
.into();
let mut response = agent
.post(url)
.header("authorization", &format!("Bearer {api_key}"))
.header("content-type", "application/json")
.send_json(body)
.map_err(|error| format!("request failed: {error}"))?;
let status = response.status().as_u16();
let value = response
.body_mut()
.read_json::<Value>()
.unwrap_or(Value::Null);
Ok((status, value))
}
}
pub struct JevEngine {
api_key: String,
base_url: String,
model: String,
timeout: Duration,
max_retries: u32,
transport: Box<dyn Transport>,
}
impl fmt::Debug for JevEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JevEngine")
.field("api_key", &"<redacted>")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("timeout", &self.timeout)
.finish()
}
}
impl JevEngine {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: DEFAULT_BASE_URL.into(),
model: DEFAULT_MODEL.into(),
timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
max_retries: 2,
transport: Box::new(UreqTransport),
}
}
pub fn from_env() -> Option<Self> {
let key = std::env::var("TYPESAFE_API_KEY").ok()?;
if key.trim().is_empty() {
return None;
}
let mut engine = Self::new(key.trim());
if let Ok(url) = std::env::var("TYPESAFE_BASE_URL") {
if !url.trim().is_empty() {
engine.base_url = url.trim().trim_end_matches('/').to_string();
}
}
if let Ok(model) = std::env::var("HIKMAH_JEV_MODEL") {
if !model.trim().is_empty() {
engine.model = model.trim().to_string();
}
}
if let Some(ms) = std::env::var("HIKMAH_JEV_TIMEOUT_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
{
engine.timeout = Duration::from_millis(ms.clamp(100, 60_000));
}
Some(engine)
}
pub fn with_transport(mut self, transport: Box<dyn Transport>) -> Self {
self.transport = transport;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout.clamp(Duration::from_millis(100), Duration::from_secs(60));
self
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_string();
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_max_retries(mut self, retries: u32) -> Self {
self.max_retries = retries;
self
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn payload(&self, request: &DecisionRequest) -> Value {
let mut questions = Map::new();
for question in &request.questions {
let mut spec = Map::new();
spec.insert("instructions".into(), json!(question.instructions));
match &question.kind {
QuestionKind::Choice { options } => {
spec.insert("type".into(), json!("choice"));
spec.insert("criteria".into(), json!(options));
}
QuestionKind::Score { levels } => {
spec.insert("type".into(), json!("score"));
spec.insert("criteria".into(), json!(levels));
}
QuestionKind::Noul { if_true, if_false } => {
spec.insert("type".into(), json!("noul"));
if let (Some(t), Some(f)) = (if_true, if_false) {
spec.insert("criteria".into(), json!({"true": t, "false": f}));
}
}
}
questions.insert(question.id.clone(), Value::Object(spec));
}
json!({
"model": self.model,
"state": request.state,
"questions": Value::Object(questions),
})
}
fn post_with_retries(&self, body: &Value) -> Result<Value> {
let url = format!("{}/v1/systemone", self.base_url);
let deadline = Instant::now()
.checked_add(self.timeout)
.unwrap_or_else(|| Instant::now() + Duration::from_secs(60));
let mut attempt = 0_u32;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(KernelError::Engine("jev: time budget exhausted".into()));
}
let (status, value) = self
.transport
.post_json(&url, &self.api_key, body, remaining)
.map_err(|error| KernelError::Engine(format!("jev: {error}")))?;
if (200..300).contains(&status) {
return Ok(value);
}
let retryable = status == 429 || status == 529 || status >= 500;
if retryable && attempt < self.max_retries {
let backoff = Duration::from_millis(250 * 2_u64.pow(attempt));
if Instant::now() + backoff < deadline {
std::thread::sleep(backoff);
attempt += 1;
continue;
}
}
let kind = value
.pointer("/detail/error_type")
.and_then(Value::as_str)
.unwrap_or("error");
return Err(KernelError::Engine(format!("jev: http {status} ({kind})")));
}
}
}
impl DecisionEngine for JevEngine {
fn descriptor(&self) -> EngineDescriptor {
EngineDescriptor {
name: "jev".into(),
version: self.model.clone(),
}
}
fn decide(&self, request: &DecisionRequest) -> Result<RawDecision> {
let started = Instant::now();
let body = self.payload(request);
let response = self.post_with_retries(&body)?;
let answers_value = response
.get("answers")
.and_then(Value::as_object)
.ok_or_else(|| KernelError::Engine("jev: response has no answers object".into()))?;
let mut answers = BTreeMap::new();
for (id, value) in answers_value {
let answer =
serde_json::from_value::<RawAnswer>(value.clone()).unwrap_or_else(|error| {
RawAnswer::Malformed {
detail: format!("{error}"),
}
});
answers.insert(id.clone(), answer);
}
let version = response
.get("model")
.and_then(Value::as_str)
.unwrap_or(&self.model)
.to_string();
Ok(RawDecision {
request_id: request.request_id(),
engine: EngineDescriptor {
name: "jev".into(),
version,
},
answers,
latency_ms: started.elapsed().as_millis() as u64,
})
}
}