use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{Env, describe_transport_error};
pub const MAX_CHOICE_OPTIONS: usize = 255;
pub const MAX_SCORE_LEVELS: usize = 10;
const DECISION_DEADLINE: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
pub enum Question {
Noul {
instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
criteria: Option<NoulCriteria>,
},
Choice {
instructions: String,
criteria: BTreeMap<String, String>,
},
Score {
instructions: String,
criteria: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct NoulCriteria {
#[serde(rename = "true")]
pub yes: String,
#[serde(rename = "false")]
pub no: String,
}
pub type Questions = BTreeMap<String, Question>;
impl Question {
pub fn kind(&self) -> &'static str {
match self {
Question::Noul { .. } => "noul",
Question::Choice { .. } => "choice",
Question::Score { .. } => "score",
}
}
pub fn instructions(&self) -> &str {
match self {
Question::Noul { instructions, .. }
| Question::Choice { instructions, .. }
| Question::Score { instructions, .. } => instructions,
}
}
}
pub fn check_questions(questions: &Questions) -> Result<(), String> {
if questions.is_empty() {
return Err("asks no questions".to_string());
}
for (name, question) in questions {
if name.trim().is_empty() {
return Err("has a question with a blank name".to_string());
}
if question.instructions().trim().is_empty() {
return Err(format!("question `{name}` has no instructions"));
}
match question {
Question::Noul { .. } => {}
Question::Choice { criteria, .. } => {
if !(2..=MAX_CHOICE_OPTIONS).contains(&criteria.len()) {
return Err(format!(
"choice `{name}` offers {} options; it needs 2 to {MAX_CHOICE_OPTIONS}",
criteria.len()
));
}
}
Question::Score { criteria, .. } => {
if !(2..=MAX_SCORE_LEVELS).contains(&criteria.len()) {
return Err(format!(
"score `{name}` has {} levels; it needs 2 to {MAX_SCORE_LEVELS}",
criteria.len()
));
}
}
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Answer {
Noul {
noul: f64,
},
Choice {
choice: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
confidence: Option<f64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
probabilities: BTreeMap<String, f64>,
},
Score {
score: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
confidence: Option<f64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
probabilities: BTreeMap<String, f64>,
},
}
impl Answer {
pub fn kind(&self) -> &'static str {
match self {
Answer::Noul { .. } => "noul",
Answer::Choice { .. } => "choice",
Answer::Score { .. } => "score",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DecisionUsage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Decision {
pub model: String,
pub answers: BTreeMap<String, Answer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<DecisionUsage>,
}
impl Decision {
pub fn check(&self, questions: &Questions) -> Result<(), DecisionError> {
for (name, question) in questions {
let answer = self
.answers
.get(name)
.ok_or_else(|| DecisionError::Mismatch(format!("no answer to `{name}`")))?;
if answer.kind() != question.kind() {
return Err(DecisionError::Mismatch(format!(
"`{name}` is a {} question and the answer is a {}",
question.kind(),
answer.kind()
)));
}
if let (Question::Choice { criteria, .. }, Answer::Choice { choice, .. }) =
(question, answer)
&& !criteria.contains_key(choice)
{
return Err(DecisionError::Mismatch(format!(
"`{name}` chose {choice:?}, which is not one of its options"
)));
}
}
if let Some(extra) = self.answers.keys().find(|k| !questions.contains_key(*k)) {
return Err(DecisionError::Mismatch(format!(
"an answer to `{extra}`, which was not asked"
)));
}
Ok(())
}
}
pub fn answer_schema(questions: &Questions) -> Value {
let probabilities = |keys: Vec<String>| {
let properties: serde_json::Map<String, Value> = keys
.into_iter()
.map(|key| (key, json!({"type": "number", "minimum": 0, "maximum": 1})))
.collect();
json!({"type": "object", "properties": properties})
};
let confidence = json!({"type": "number", "minimum": 0, "maximum": 1});
let answers: serde_json::Map<String, Value> = questions
.iter()
.map(|(name, question)| {
let schema = match question {
Question::Noul { instructions, .. } => json!({
"type": "object",
"description": format!("Probability of yes: {instructions}"),
"required": ["type", "noul"],
"properties": {
"type": {"const": "noul"},
"noul": {"type": "number", "minimum": 0, "maximum": 1},
},
}),
Question::Choice {
instructions,
criteria,
} => json!({
"type": "object",
"description": instructions,
"required": ["type", "choice"],
"properties": {
"type": {"const": "choice"},
"choice": {"enum": criteria.keys().collect::<Vec<_>>()},
"confidence": confidence,
"probabilities": probabilities(criteria.keys().cloned().collect()),
},
}),
Question::Score {
instructions,
criteria,
} => json!({
"type": "object",
"description": format!(
"{instructions}. Levels, lowest first: {}",
criteria.join("; ")
),
"required": ["type", "score"],
"properties": {
"type": {"const": "score"},
"score": {
"type": "number",
"minimum": 0,
"maximum": criteria.len().saturating_sub(1),
},
"confidence": confidence,
"probabilities": probabilities(
(0..criteria.len()).map(|i| i.to_string()).collect()
),
},
}),
};
(name.clone(), schema)
})
.collect();
json!({
"type": "object",
"required": ["model", "answers"],
"properties": {
"model": {"type": "string"},
"answers": {
"type": "object",
"required": questions.keys().collect::<Vec<_>>(),
"properties": answers,
},
"usage": {
"type": "object",
"properties": {
"input_tokens": {"type": "integer"},
"output_tokens": {"type": "integer"},
"cost": {"type": "number"},
},
},
},
})
}
#[derive(Debug, thiserror::Error)]
pub enum DecisionError {
#[error("could not reach the decision model: {0}")]
Transport(String),
#[error("the decision model refused ({status}): {message}")]
Refused {
status: u16,
message: String,
},
#[error("the decision model's reply did not parse: {0}")]
Malformed(String),
#[error("the decision model did not answer what was asked: {0}")]
Mismatch(String),
}
#[async_trait]
pub trait DecisionModel: Send + Sync {
async fn decide(&self, state: &Value, questions: &Questions)
-> Result<Decision, DecisionError>;
}
#[derive(Debug, Clone)]
pub struct FixedDecision(pub Decision);
#[async_trait]
impl DecisionModel for FixedDecision {
async fn decide(
&self,
_state: &Value,
_questions: &Questions,
) -> Result<Decision, DecisionError> {
Ok(self.0.clone())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionProvider {
OpenRouter,
Perplexity,
SystemOne,
}
pub const DECISION_PROVIDERS: [&str; 3] = ["openrouter", "perplexity", "systemone"];
impl DecisionProvider {
pub fn from_name(name: &str) -> Option<Self> {
match name {
"openrouter" => Some(Self::OpenRouter),
"perplexity" => Some(Self::Perplexity),
"systemone" => Some(Self::SystemOne),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::OpenRouter => "openrouter",
Self::Perplexity => "perplexity",
Self::SystemOne => "systemone",
}
}
pub fn default_url(self) -> Option<&'static str> {
match self {
Self::OpenRouter => Some("https://openrouter.ai/api/alpha/decisions"),
Self::Perplexity => Some("https://api.perplexity.ai/v1/decisions"),
Self::SystemOne => None,
}
}
pub fn key_var(self) -> Option<&'static str> {
match self {
Self::OpenRouter => Some("OPENROUTER_API_KEY"),
Self::Perplexity => Some("PERPLEXITY_API_KEY"),
Self::SystemOne => None,
}
}
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct DecisionSettings {
pub provider: String,
pub model: String,
pub api_key: Option<String>,
pub url: Option<String>,
}
impl std::fmt::Debug for DecisionSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DecisionSettings")
.field("provider", &self.provider)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("url", &self.url)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DecisionConfigError {
#[error("the {provider} decision provider is not usable: {detail}")]
Unusable {
provider: &'static str,
detail: String,
},
#[error(
"unsupported decision provider {name:?}; expected one of: {}",
DECISION_PROVIDERS.join(", ")
)]
Unsupported {
name: String,
},
}
#[derive(Clone, PartialEq, Eq)]
pub struct DecisionEndpoint {
pub provider: DecisionProvider,
pub model: String,
pub url: String,
pub api_key: Option<String>,
pub key_from: Option<&'static str>,
}
impl std::fmt::Debug for DecisionEndpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DecisionEndpoint")
.field("provider", &self.provider)
.field("model", &self.model)
.field("url", &self.url)
.field("key_from", &self.key_from)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct SelectedDecision {
pub model: Arc<dyn DecisionModel>,
pub endpoint: DecisionEndpoint,
}
pub fn decision_from_settings(
settings: &DecisionSettings,
) -> Result<SelectedDecision, DecisionConfigError> {
let endpoint = resolve_endpoint(settings, Env::os())?;
let model =
HttpDecisionModel::new(endpoint.clone()).map_err(|e| DecisionConfigError::Unusable {
provider: endpoint.provider.name(),
detail: e.to_string(),
})?;
Ok(SelectedDecision {
model: Arc::new(model),
endpoint,
})
}
fn resolve_endpoint(
settings: &DecisionSettings,
env: Env<'_>,
) -> Result<DecisionEndpoint, DecisionConfigError> {
let provider = DecisionProvider::from_name(&settings.provider).ok_or_else(|| {
DecisionConfigError::Unsupported {
name: settings.provider.clone(),
}
})?;
let unusable = |detail: String| DecisionConfigError::Unusable {
provider: provider.name(),
detail,
};
if settings.model.trim().is_empty() {
return Err(unusable("no model is named".to_string()));
}
let url = match (settings.url.as_deref(), provider.default_url()) {
(Some(url), _) | (None, Some(url)) => url.to_string(),
(None, None) => {
return Err(unusable(
"it needs the full endpoint URL, such as http://127.0.0.1:8000/v1/systemone"
.to_string(),
));
}
};
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(unusable(format!("{url:?} is not an http(s) address")));
}
let configured = settings.api_key.as_deref().filter(|k| !k.trim().is_empty());
let (api_key, key_from) = match (configured, provider.key_var()) {
(Some(key), _) => (Some(key.to_string()), Some("config")),
(None, Some(var)) => match env.get(var) {
Some(key) => (Some(key), Some(var)),
None => return Err(unusable(format!("no key is configured and {var} is unset"))),
},
(None, None) => (None, None),
};
Ok(DecisionEndpoint {
provider,
model: settings.model.clone(),
url,
api_key,
key_from,
})
}
pub struct HttpDecisionModel {
client: reqwest::Client,
endpoint: DecisionEndpoint,
}
impl HttpDecisionModel {
pub fn new(endpoint: DecisionEndpoint) -> Result<Self, DecisionError> {
let client = reqwest::Client::builder()
.timeout(DECISION_DEADLINE)
.build()
.map_err(|e| {
DecisionError::Transport(format!(
"building the HTTP client: {}",
describe_transport_error(&e)
))
})?;
Ok(Self { client, endpoint })
}
pub fn endpoint(&self) -> &DecisionEndpoint {
&self.endpoint
}
}
#[derive(Deserialize)]
struct ErrorBody {
error: ErrorDetail,
}
#[derive(Deserialize)]
struct ErrorDetail {
message: String,
}
#[async_trait]
impl DecisionModel for HttpDecisionModel {
async fn decide(
&self,
state: &Value,
questions: &Questions,
) -> Result<Decision, DecisionError> {
let body = serde_json::json!({
"model": self.endpoint.model,
"state": state,
"questions": questions,
});
let mut request = self.client.post(&self.endpoint.url).json(&body);
if let Some(key) = &self.endpoint.api_key {
request = request.bearer_auth(key);
}
let response = request
.send()
.await
.map_err(|e| DecisionError::Transport(describe_transport_error(&e)))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| DecisionError::Transport(describe_transport_error(&e)))?;
if !status.is_success() {
let message = serde_json::from_str::<ErrorBody>(&text)
.map(|b| b.error.message)
.unwrap_or(text);
return Err(DecisionError::Refused {
status: status.as_u16(),
message,
});
}
let decision: Decision =
serde_json::from_str(&text).map_err(|e| DecisionError::Malformed(e.to_string()))?;
decision.check(questions)?;
Ok(decision)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn questions(value: Value) -> Questions {
serde_json::from_value(value).expect("questions parse")
}
fn triage() -> Questions {
questions(json!({
"refund": {"type": "noul", "instructions": "a refund is requested"},
"team": {"type": "choice", "instructions": "who",
"criteria": {"billing": "money", "technical": "bugs"}},
}))
}
#[test]
fn a_noul_criteria_is_sent_under_true_and_false() {
let q = questions(json!({
"refund": {"type": "noul", "instructions": "a refund is requested",
"criteria": {"true": "asks for money back", "false": "anything else"}},
}));
assert_eq!(
serde_json::to_value(&q["refund"]).unwrap()["criteria"],
json!({"true": "asks for money back", "false": "anything else"})
);
}
#[test]
fn a_misspelled_field_is_refused() {
let err = serde_json::from_value::<Questions>(json!({
"team": {"type": "choice", "instructions": "who", "options": {"a": "x", "b": "y"}},
}))
.unwrap_err();
assert!(err.to_string().contains("options"), "{err}");
}
#[test]
fn a_choice_of_one_and_a_scale_of_eleven_are_refused() {
let one = questions(json!({
"team": {"type": "choice", "instructions": "who", "criteria": {"billing": "money"}},
}));
assert!(check_questions(&one).unwrap_err().contains("1 options"));
let levels: Vec<String> = (0..11).map(|i| format!("level {i}")).collect();
let eleven = questions(json!({
"urgency": {"type": "score", "instructions": "how soon", "criteria": levels},
}));
assert!(check_questions(&eleven).unwrap_err().contains("11 levels"));
assert!(check_questions(&Questions::new()).is_err());
check_questions(&triage()).unwrap();
}
fn decision(answers: Value) -> Decision {
serde_json::from_value(json!({"model": "m", "answers": answers})).unwrap()
}
#[test]
fn a_decision_that_answers_what_was_asked_passes_and_drops_extra_fields() {
let d = decision(json!({
"refund": {"type": "noul", "noul": 0.97},
"team": {"type": "choice", "choice": "billing", "confidence": 0.9,
"probabilities": {"billing": 0.95, "technical": 0.05},
"x_p_max": 0.95},
}));
d.check(&triage()).unwrap();
assert!(serde_json::to_string(&d).unwrap().find("x_p_max").is_none());
}
#[test]
fn a_decision_that_drops_mistypes_or_invents_is_refused() {
let dropped = decision(json!({"refund": {"type": "noul", "noul": 0.9}}));
assert!(
dropped
.check(&triage())
.unwrap_err()
.to_string()
.contains("no answer to `team`")
);
let mistyped = decision(json!({
"refund": {"type": "score", "score": 1.0},
"team": {"type": "choice", "choice": "billing"},
}));
assert!(
mistyped
.check(&triage())
.unwrap_err()
.to_string()
.contains("noul question")
);
let invented = decision(json!({
"refund": {"type": "noul", "noul": 0.9},
"team": {"type": "choice", "choice": "sales"},
}));
assert!(
invented
.check(&triage())
.unwrap_err()
.to_string()
.contains("\"sales\"")
);
}
#[test]
fn the_answer_schema_describes_the_decision_that_answers() {
let mut asked = triage();
asked.insert(
"urgency".into(),
Question::Score {
instructions: "how soon".into(),
criteria: vec!["week".into(), "today".into(), "now".into()],
},
);
let schema = answer_schema(&asked);
let answers = &schema["properties"]["answers"];
assert_eq!(answers["required"], json!(["refund", "team", "urgency"]));
assert_eq!(
answers["properties"]["team"]["properties"]["choice"]["enum"],
json!(["billing", "technical"])
);
assert_eq!(
answers["properties"]["urgency"]["properties"]["score"]["maximum"],
json!(2)
);
let decision = decision(json!({
"refund": {"type": "noul", "noul": 0.9},
"team": {"type": "choice", "choice": "billing", "confidence": 0.8,
"probabilities": {"billing": 0.9, "technical": 0.1}},
"urgency": {"type": "score", "score": 1.2, "probabilities": {"1": 0.8, "2": 0.2}},
}));
let value = serde_json::to_value(&decision).unwrap();
for (name, answer) in value["answers"].as_object().unwrap() {
let declared = &answers["properties"][name]["properties"];
for key in answer.as_object().unwrap().keys() {
assert!(
declared.get(key).is_some(),
"`{name}.{key}` is not in the schema"
);
}
}
}
fn settings(provider: &str, url: Option<&str>) -> DecisionSettings {
DecisionSettings {
provider: provider.to_string(),
model: "typesafe/jev-1.13".to_string(),
api_key: None,
url: url.map(str::to_string),
}
}
#[test]
fn openrouter_reads_its_key_from_the_environment_and_names_where_it_came_from() {
let lookup = |var: &str| (var == "OPENROUTER_API_KEY").then(|| "sk-or".to_string());
let resolved = resolve_endpoint(&settings("openrouter", None), Env::new(&lookup)).unwrap();
assert_eq!(resolved.url, "https://openrouter.ai/api/alpha/decisions");
assert_eq!(resolved.api_key.as_deref(), Some("sk-or"));
assert_eq!(resolved.key_from, Some("OPENROUTER_API_KEY"));
assert!(!format!("{resolved:?}").contains("sk-or"));
}
#[test]
fn a_hosted_provider_without_a_key_is_refused() {
let none = |_: &str| None;
let err = resolve_endpoint(&settings("perplexity", None), Env::new(&none)).unwrap_err();
assert!(err.to_string().contains("PERPLEXITY_API_KEY"), "{err}");
}
#[test]
fn systemone_needs_a_url_and_reads_no_key_ambiently() {
let any = |_: &str| Some("a key for some other host".to_string());
assert!(resolve_endpoint(&settings("systemone", None), Env::new(&any)).is_err());
let local = resolve_endpoint(
&settings("systemone", Some("http://127.0.0.1:8000/v1/systemone")),
Env::new(&any),
)
.unwrap();
assert_eq!(local.api_key, None);
}
#[test]
fn an_unknown_provider_lists_the_known_ones() {
let none = |_: &str| None;
let err = resolve_endpoint(&settings("typesafe", None), Env::new(&none)).unwrap_err();
assert!(
err.to_string()
.contains("openrouter, perplexity, systemone"),
"{err}"
);
}
}