use super::answer::validate_response;
use super::question::{Declared, validate_questions};
use super::screen::{screen_json, screen_text};
use super::secret;
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
pub(crate) const DEFAULT_MODEL: &str = "jev-1.13.0";
pub(crate) const DEFAULT_TIMEOUT_SECS: u64 = 20;
const ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
const RETRY_BACKOFF: Duration = Duration::from_secs(1);
pub(crate) const KEY_SETUP: &str = "security add-generic-password -a \"$USER\" -s typesafe-api-key -U -w";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ErrorKind {
NoKey,
Api,
Invalid,
Refused,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct ClassifyError {
pub kind: ErrorKind,
pub message: String,
}
impl ClassifyError {
pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into() }
}
pub(crate) fn exit_code(&self) -> i32 {
match self.kind {
ErrorKind::NoKey => 2,
ErrorKind::Api => 3,
ErrorKind::Invalid => 4,
ErrorKind::Refused => 5,
}
}
}
impl std::fmt::Display for ClassifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ClassifyState {
Text(String),
Json(Value),
}
#[derive(Clone, Debug)]
pub(crate) struct ClassifyRequest {
pub state: ClassifyState,
pub questions: Map<String, Value>,
pub model: String,
pub timeout_secs: u64,
pub allow_secret_like: bool,
}
pub(crate) fn classify(request: &ClassifyRequest) -> Result<Value, ClassifyError> {
prepare(request)?;
let key = required_key()?;
execute(request, &key, |post| send_with_retry(post, std::thread::sleep))
}
fn required_key() -> Result<String, ClassifyError> {
resolve_key().ok_or_else(|| {
ClassifyError::new(
ErrorKind::NoKey,
format!("no TypeSafe key in the login keychain; run once in a real terminal: {KEY_SETUP}"),
)
})
}
pub(crate) struct BatchClassifier {
key: String,
}
pub(crate) type Attempt = Result<Value, (ClassifyError, bool)>;
impl BatchClassifier {
pub(crate) fn new() -> Result<Self, ClassifyError> {
Ok(Self { key: required_key()? })
}
pub(crate) fn classify(&self, request: &ClassifyRequest) -> Attempt {
let mut throttled = false;
let result = execute(request, &self.key, |post| {
let response = post().map_err(network_error)?;
throttled = matches!(response.0, 429 | 529);
Ok(response)
});
result.map_err(|error| (error, throttled))
}
}
fn execute(
request: &ClassifyRequest,
key: &str,
send: impl FnOnce(&mut dyn FnMut() -> anyhow::Result<(u16, String)>) -> Result<(u16, String), ClassifyError>,
) -> Result<Value, ClassifyError> {
let declared = prepare(request)?;
let state = match &request.state {
ClassifyState::Text(text) => json!(text),
ClassifyState::Json(value) => value.clone(),
};
let body = json!({ "state": state, "model": request.model, "questions": request.questions }).to_string();
let started = Instant::now();
let mut post = || secret::post_json(ENDPOINT, key, &body, request.timeout_secs);
let (status, text) = send(&mut post)?;
finish(status, &text, &declared, started.elapsed())
}
pub(crate) fn prepare(request: &ClassifyRequest) -> Result<BTreeMap<String, Declared>, ClassifyError> {
let invalid = |message: String| ClassifyError::new(ErrorKind::Invalid, message);
if !is_model_name(&request.model) {
return Err(invalid("model must be jev-<version> or a jev- alias".into()));
}
if !(1..=300).contains(&request.timeout_secs) {
return Err(invalid("timeout must be 1-300 seconds".into()));
}
let declared = validate_questions(&request.questions).map_err(invalid)?;
let screened = match &request.state {
ClassifyState::Text(text) => screen_text(text, request.allow_secret_like),
ClassifyState::Json(value) => screen_json(value, request.allow_secret_like),
};
screened.map_err(|refusal| match refusal {
super::screen::StateRefusal::Empty => invalid(refusal.message()),
_ => ClassifyError::new(ErrorKind::Refused, refusal.message()),
})?;
Ok(declared)
}
fn is_model_name(model: &str) -> bool {
model.len() <= 64
&& model.strip_prefix("jev-").is_some_and(|rest| {
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
})
}
fn resolve_key() -> Option<String> {
#[cfg(debug_assertions)]
match std::env::var("AID_TYPESAFE_TEST_KEY").as_deref() {
Ok("fake") => return Some("ts_fake_key_for_tests".to_string()),
Ok("absent") => return None,
_ => {}
}
secret::api_key()
}
fn send_with_retry(
mut post: impl FnMut() -> anyhow::Result<(u16, String)>,
sleep: impl Fn(Duration),
) -> Result<(u16, String), ClassifyError> {
let first = post().map_err(network_error)?;
if !matches!(first.0, 429 | 529) {
return Ok(first);
}
sleep(RETRY_BACKOFF);
post().map_err(network_error)
}
fn network_error(_: anyhow::Error) -> ClassifyError {
ClassifyError::new(ErrorKind::Api, "TypeSafe request failed: no response (network error, timeout, or curl missing)")
}
fn finish(
status: u16,
body: &str,
declared: &BTreeMap<String, Declared>,
latency: Duration,
) -> Result<Value, ClassifyError> {
if !(200..300).contains(&status) {
let detail = api_error_type(body).map(|kind| format!(" ({kind})")).unwrap_or_default();
return Err(ClassifyError::new(ErrorKind::Api, format!("TypeSafe API error: HTTP {status}{detail}")));
}
let mut out = validate_response(body, declared)
.map_err(|reason| ClassifyError::new(ErrorKind::Api, format!("TypeSafe returned an invalid response: {reason}")))?;
out["latency_ms"] = json!(u64::try_from(latency.as_millis()).unwrap_or(u64::MAX));
Ok(out)
}
fn api_error_type(body: &str) -> Option<String> {
let value: Value = serde_json::from_str(body).ok()?;
let kind = value
.pointer("/error/type")
.or_else(|| value.get("type"))
.and_then(Value::as_str)?;
let plain = !kind.is_empty() && kind.len() <= 64
&& kind.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
plain.then(|| kind.to_string())
}
#[cfg(test)]
#[path = "classify_tests.rs"]
mod tests;