1use std::collections::BTreeMap;
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use serde_json::{Value, json};
26
27use crate::{Env, describe_transport_error};
28
29pub const MAX_CHOICE_OPTIONS: usize = 255;
32
33pub const MAX_SCORE_LEVELS: usize = 10;
36
37const DECISION_DEADLINE: Duration = Duration::from_secs(60);
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
44#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
45pub enum Question {
46 Noul {
48 instructions: String,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 criteria: Option<NoulCriteria>,
55 },
56 Choice {
59 instructions: String,
61 criteria: BTreeMap<String, String>,
63 },
64 Score {
67 instructions: String,
69 criteria: Vec<String>,
71 },
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
77#[serde(deny_unknown_fields)]
78pub struct NoulCriteria {
79 #[serde(rename = "true")]
81 pub yes: String,
82 #[serde(rename = "false")]
84 pub no: String,
85}
86
87pub type Questions = BTreeMap<String, Question>;
90
91impl Question {
92 pub fn kind(&self) -> &'static str {
94 match self {
95 Question::Noul { .. } => "noul",
96 Question::Choice { .. } => "choice",
97 Question::Score { .. } => "score",
98 }
99 }
100
101 pub fn instructions(&self) -> &str {
103 match self {
104 Question::Noul { instructions, .. }
105 | Question::Choice { instructions, .. }
106 | Question::Score { instructions, .. } => instructions,
107 }
108 }
109}
110
111pub fn check_questions(questions: &Questions) -> Result<(), String> {
116 if questions.is_empty() {
117 return Err("asks no questions".to_string());
118 }
119 for (name, question) in questions {
120 if name.trim().is_empty() {
121 return Err("has a question with a blank name".to_string());
122 }
123 if question.instructions().trim().is_empty() {
124 return Err(format!("question `{name}` has no instructions"));
125 }
126 match question {
127 Question::Noul { .. } => {}
128 Question::Choice { criteria, .. } => {
131 if !(2..=MAX_CHOICE_OPTIONS).contains(&criteria.len()) {
132 return Err(format!(
133 "choice `{name}` offers {} options; it needs 2 to {MAX_CHOICE_OPTIONS}",
134 criteria.len()
135 ));
136 }
137 }
138 Question::Score { criteria, .. } => {
139 if !(2..=MAX_SCORE_LEVELS).contains(&criteria.len()) {
140 return Err(format!(
141 "score `{name}` has {} levels; it needs 2 to {MAX_SCORE_LEVELS}",
142 criteria.len()
143 ));
144 }
145 }
146 }
147 }
148 Ok(())
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156#[serde(tag = "type", rename_all = "lowercase")]
157pub enum Answer {
158 Noul {
160 noul: f64,
162 },
163 Choice {
165 choice: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 confidence: Option<f64>,
171 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
173 probabilities: BTreeMap<String, f64>,
174 },
175 Score {
177 score: f64,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 confidence: Option<f64>,
182 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
184 probabilities: BTreeMap<String, f64>,
185 },
186}
187
188impl Answer {
189 pub fn kind(&self) -> &'static str {
191 match self {
192 Answer::Noul { .. } => "noul",
193 Answer::Choice { .. } => "choice",
194 Answer::Score { .. } => "score",
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct DecisionUsage {
205 pub input_tokens: u64,
207 pub output_tokens: u64,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub cost: Option<f64>,
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct Decision {
217 pub model: String,
220 pub answers: BTreeMap<String, Answer>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub usage: Option<DecisionUsage>,
225}
226
227impl Decision {
228 pub fn check(&self, questions: &Questions) -> Result<(), DecisionError> {
235 for (name, question) in questions {
236 let answer = self
237 .answers
238 .get(name)
239 .ok_or_else(|| DecisionError::Mismatch(format!("no answer to `{name}`")))?;
240 if answer.kind() != question.kind() {
241 return Err(DecisionError::Mismatch(format!(
242 "`{name}` is a {} question and the answer is a {}",
243 question.kind(),
244 answer.kind()
245 )));
246 }
247 if let (Question::Choice { criteria, .. }, Answer::Choice { choice, .. }) =
248 (question, answer)
249 && !criteria.contains_key(choice)
250 {
251 return Err(DecisionError::Mismatch(format!(
252 "`{name}` chose {choice:?}, which is not one of its options"
253 )));
254 }
255 }
256 if let Some(extra) = self.answers.keys().find(|k| !questions.contains_key(*k)) {
257 return Err(DecisionError::Mismatch(format!(
258 "an answer to `{extra}`, which was not asked"
259 )));
260 }
261 Ok(())
262 }
263}
264
265pub fn answer_schema(questions: &Questions) -> Value {
272 let probabilities = |keys: Vec<String>| {
273 let properties: serde_json::Map<String, Value> = keys
274 .into_iter()
275 .map(|key| (key, json!({"type": "number", "minimum": 0, "maximum": 1})))
276 .collect();
277 json!({"type": "object", "properties": properties})
278 };
279 let confidence = json!({"type": "number", "minimum": 0, "maximum": 1});
280 let answers: serde_json::Map<String, Value> = questions
281 .iter()
282 .map(|(name, question)| {
283 let schema = match question {
284 Question::Noul { instructions, .. } => json!({
285 "type": "object",
286 "description": format!("Probability of yes: {instructions}"),
287 "required": ["type", "noul"],
288 "properties": {
289 "type": {"const": "noul"},
290 "noul": {"type": "number", "minimum": 0, "maximum": 1},
291 },
292 }),
293 Question::Choice {
294 instructions,
295 criteria,
296 } => json!({
297 "type": "object",
298 "description": instructions,
299 "required": ["type", "choice"],
300 "properties": {
301 "type": {"const": "choice"},
302 "choice": {"enum": criteria.keys().collect::<Vec<_>>()},
303 "confidence": confidence,
304 "probabilities": probabilities(criteria.keys().cloned().collect()),
305 },
306 }),
307 Question::Score {
308 instructions,
309 criteria,
310 } => json!({
311 "type": "object",
312 "description": format!(
313 "{instructions}. Levels, lowest first: {}",
314 criteria.join("; ")
315 ),
316 "required": ["type", "score"],
317 "properties": {
318 "type": {"const": "score"},
319 "score": {
320 "type": "number",
321 "minimum": 0,
322 "maximum": criteria.len().saturating_sub(1),
323 },
324 "confidence": confidence,
325 "probabilities": probabilities(
326 (0..criteria.len()).map(|i| i.to_string()).collect()
327 ),
328 },
329 }),
330 };
331 (name.clone(), schema)
332 })
333 .collect();
334 json!({
335 "type": "object",
336 "required": ["model", "answers"],
337 "properties": {
338 "model": {"type": "string"},
339 "answers": {
340 "type": "object",
341 "required": questions.keys().collect::<Vec<_>>(),
342 "properties": answers,
343 },
344 "usage": {
345 "type": "object",
346 "properties": {
347 "input_tokens": {"type": "integer"},
348 "output_tokens": {"type": "integer"},
349 "cost": {"type": "number"},
350 },
351 },
352 },
353 })
354}
355
356#[derive(Debug, thiserror::Error)]
358pub enum DecisionError {
359 #[error("could not reach the decision model: {0}")]
361 Transport(String),
362 #[error("the decision model refused ({status}): {message}")]
364 Refused {
365 status: u16,
367 message: String,
369 },
370 #[error("the decision model's reply did not parse: {0}")]
372 Malformed(String),
373 #[error("the decision model did not answer what was asked: {0}")]
375 Mismatch(String),
376}
377
378#[async_trait]
380pub trait DecisionModel: Send + Sync {
381 async fn decide(&self, state: &Value, questions: &Questions)
384 -> Result<Decision, DecisionError>;
385}
386
387#[derive(Debug, Clone)]
393pub struct FixedDecision(pub Decision);
394
395#[async_trait]
396impl DecisionModel for FixedDecision {
397 async fn decide(
398 &self,
399 _state: &Value,
400 _questions: &Questions,
401 ) -> Result<Decision, DecisionError> {
402 Ok(self.0.clone())
403 }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum DecisionProvider {
409 OpenRouter,
411 Perplexity,
413 SystemOne,
416}
417
418pub const DECISION_PROVIDERS: [&str; 3] = ["openrouter", "perplexity", "systemone"];
421
422impl DecisionProvider {
423 pub fn from_name(name: &str) -> Option<Self> {
426 match name {
427 "openrouter" => Some(Self::OpenRouter),
428 "perplexity" => Some(Self::Perplexity),
429 "systemone" => Some(Self::SystemOne),
430 _ => None,
431 }
432 }
433
434 pub fn name(self) -> &'static str {
436 match self {
437 Self::OpenRouter => "openrouter",
438 Self::Perplexity => "perplexity",
439 Self::SystemOne => "systemone",
440 }
441 }
442
443 pub fn default_url(self) -> Option<&'static str> {
445 match self {
446 Self::OpenRouter => Some("https://openrouter.ai/api/alpha/decisions"),
447 Self::Perplexity => Some("https://api.perplexity.ai/v1/decisions"),
448 Self::SystemOne => None,
449 }
450 }
451
452 pub fn key_var(self) -> Option<&'static str> {
457 match self {
458 Self::OpenRouter => Some("OPENROUTER_API_KEY"),
459 Self::Perplexity => Some("PERPLEXITY_API_KEY"),
460 Self::SystemOne => None,
461 }
462 }
463}
464
465#[derive(Clone, Default, PartialEq, Eq)]
467pub struct DecisionSettings {
468 pub provider: String,
470 pub model: String,
473 pub api_key: Option<String>,
475 pub url: Option<String>,
478}
479
480impl std::fmt::Debug for DecisionSettings {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484 f.debug_struct("DecisionSettings")
485 .field("provider", &self.provider)
486 .field("model", &self.model)
487 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
488 .field("url", &self.url)
489 .finish()
490 }
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
496pub enum DecisionConfigError {
497 #[error("the {provider} decision provider is not usable: {detail}")]
499 Unusable {
500 provider: &'static str,
502 detail: String,
504 },
505 #[error(
507 "unsupported decision provider {name:?}; expected one of: {}",
508 DECISION_PROVIDERS.join(", ")
509 )]
510 Unsupported {
511 name: String,
513 },
514}
515
516#[derive(Clone, PartialEq, Eq)]
518pub struct DecisionEndpoint {
519 pub provider: DecisionProvider,
521 pub model: String,
523 pub url: String,
525 pub api_key: Option<String>,
527 pub key_from: Option<&'static str>,
530}
531
532impl std::fmt::Debug for DecisionEndpoint {
533 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534 f.debug_struct("DecisionEndpoint")
535 .field("provider", &self.provider)
536 .field("model", &self.model)
537 .field("url", &self.url)
538 .field("key_from", &self.key_from)
539 .finish_non_exhaustive()
540 }
541}
542
543#[derive(Clone)]
545pub struct SelectedDecision {
546 pub model: Arc<dyn DecisionModel>,
548 pub endpoint: DecisionEndpoint,
550}
551
552pub fn decision_from_settings(
557 settings: &DecisionSettings,
558) -> Result<SelectedDecision, DecisionConfigError> {
559 let endpoint = resolve_endpoint(settings, Env::os())?;
560 let model =
561 HttpDecisionModel::new(endpoint.clone()).map_err(|e| DecisionConfigError::Unusable {
562 provider: endpoint.provider.name(),
563 detail: e.to_string(),
564 })?;
565 Ok(SelectedDecision {
566 model: Arc::new(model),
567 endpoint,
568 })
569}
570
571fn resolve_endpoint(
572 settings: &DecisionSettings,
573 env: Env<'_>,
574) -> Result<DecisionEndpoint, DecisionConfigError> {
575 let provider = DecisionProvider::from_name(&settings.provider).ok_or_else(|| {
576 DecisionConfigError::Unsupported {
577 name: settings.provider.clone(),
578 }
579 })?;
580 let unusable = |detail: String| DecisionConfigError::Unusable {
581 provider: provider.name(),
582 detail,
583 };
584 if settings.model.trim().is_empty() {
585 return Err(unusable("no model is named".to_string()));
586 }
587 let url = match (settings.url.as_deref(), provider.default_url()) {
588 (Some(url), _) | (None, Some(url)) => url.to_string(),
589 (None, None) => {
590 return Err(unusable(
591 "it needs the full endpoint URL, such as http://127.0.0.1:8000/v1/systemone"
592 .to_string(),
593 ));
594 }
595 };
596 if !(url.starts_with("http://") || url.starts_with("https://")) {
597 return Err(unusable(format!("{url:?} is not an http(s) address")));
598 }
599 let configured = settings.api_key.as_deref().filter(|k| !k.trim().is_empty());
600 let (api_key, key_from) = match (configured, provider.key_var()) {
601 (Some(key), _) => (Some(key.to_string()), Some("config")),
602 (None, Some(var)) => match env.get(var) {
603 Some(key) => (Some(key), Some(var)),
604 None => return Err(unusable(format!("no key is configured and {var} is unset"))),
605 },
606 (None, None) => (None, None),
607 };
608 Ok(DecisionEndpoint {
609 provider,
610 model: settings.model.clone(),
611 url,
612 api_key,
613 key_from,
614 })
615}
616
617pub struct HttpDecisionModel {
620 client: reqwest::Client,
621 endpoint: DecisionEndpoint,
622}
623
624impl HttpDecisionModel {
625 pub fn new(endpoint: DecisionEndpoint) -> Result<Self, DecisionError> {
628 let client = reqwest::Client::builder()
629 .timeout(DECISION_DEADLINE)
630 .build()
631 .map_err(|e| {
632 DecisionError::Transport(format!(
633 "building the HTTP client: {}",
634 describe_transport_error(&e)
635 ))
636 })?;
637 Ok(Self { client, endpoint })
638 }
639
640 pub fn endpoint(&self) -> &DecisionEndpoint {
642 &self.endpoint
643 }
644}
645
646#[derive(Deserialize)]
648struct ErrorBody {
649 error: ErrorDetail,
650}
651
652#[derive(Deserialize)]
653struct ErrorDetail {
654 message: String,
655}
656
657#[async_trait]
658impl DecisionModel for HttpDecisionModel {
659 async fn decide(
660 &self,
661 state: &Value,
662 questions: &Questions,
663 ) -> Result<Decision, DecisionError> {
664 let body = serde_json::json!({
665 "model": self.endpoint.model,
666 "state": state,
667 "questions": questions,
668 });
669 let mut request = self.client.post(&self.endpoint.url).json(&body);
670 if let Some(key) = &self.endpoint.api_key {
671 request = request.bearer_auth(key);
672 }
673 let response = request
674 .send()
675 .await
676 .map_err(|e| DecisionError::Transport(describe_transport_error(&e)))?;
677 let status = response.status();
678 let text = response
679 .text()
680 .await
681 .map_err(|e| DecisionError::Transport(describe_transport_error(&e)))?;
682 if !status.is_success() {
683 let message = serde_json::from_str::<ErrorBody>(&text)
684 .map(|b| b.error.message)
685 .unwrap_or(text);
686 return Err(DecisionError::Refused {
687 status: status.as_u16(),
688 message,
689 });
690 }
691 let decision: Decision =
692 serde_json::from_str(&text).map_err(|e| DecisionError::Malformed(e.to_string()))?;
693 decision.check(questions)?;
694 Ok(decision)
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 fn questions(value: Value) -> Questions {
703 serde_json::from_value(value).expect("questions parse")
704 }
705
706 fn triage() -> Questions {
707 questions(json!({
708 "refund": {"type": "noul", "instructions": "a refund is requested"},
709 "team": {"type": "choice", "instructions": "who",
710 "criteria": {"billing": "money", "technical": "bugs"}},
711 }))
712 }
713
714 #[test]
715 fn a_noul_criteria_is_sent_under_true_and_false() {
716 let q = questions(json!({
717 "refund": {"type": "noul", "instructions": "a refund is requested",
718 "criteria": {"true": "asks for money back", "false": "anything else"}},
719 }));
720 assert_eq!(
721 serde_json::to_value(&q["refund"]).unwrap()["criteria"],
722 json!({"true": "asks for money back", "false": "anything else"})
723 );
724 }
725
726 #[test]
727 fn a_misspelled_field_is_refused() {
728 let err = serde_json::from_value::<Questions>(json!({
729 "team": {"type": "choice", "instructions": "who", "options": {"a": "x", "b": "y"}},
730 }))
731 .unwrap_err();
732 assert!(err.to_string().contains("options"), "{err}");
733 }
734
735 #[test]
736 fn a_choice_of_one_and_a_scale_of_eleven_are_refused() {
737 let one = questions(json!({
738 "team": {"type": "choice", "instructions": "who", "criteria": {"billing": "money"}},
739 }));
740 assert!(check_questions(&one).unwrap_err().contains("1 options"));
741
742 let levels: Vec<String> = (0..11).map(|i| format!("level {i}")).collect();
743 let eleven = questions(json!({
744 "urgency": {"type": "score", "instructions": "how soon", "criteria": levels},
745 }));
746 assert!(check_questions(&eleven).unwrap_err().contains("11 levels"));
747 assert!(check_questions(&Questions::new()).is_err());
748 check_questions(&triage()).unwrap();
749 }
750
751 fn decision(answers: Value) -> Decision {
752 serde_json::from_value(json!({"model": "m", "answers": answers})).unwrap()
753 }
754
755 #[test]
756 fn a_decision_that_answers_what_was_asked_passes_and_drops_extra_fields() {
757 let d = decision(json!({
758 "refund": {"type": "noul", "noul": 0.97},
759 "team": {"type": "choice", "choice": "billing", "confidence": 0.9,
760 "probabilities": {"billing": 0.95, "technical": 0.05},
761 "x_p_max": 0.95},
762 }));
763 d.check(&triage()).unwrap();
764 assert!(serde_json::to_string(&d).unwrap().find("x_p_max").is_none());
765 }
766
767 #[test]
768 fn a_decision_that_drops_mistypes_or_invents_is_refused() {
769 let dropped = decision(json!({"refund": {"type": "noul", "noul": 0.9}}));
770 assert!(
771 dropped
772 .check(&triage())
773 .unwrap_err()
774 .to_string()
775 .contains("no answer to `team`")
776 );
777
778 let mistyped = decision(json!({
779 "refund": {"type": "score", "score": 1.0},
780 "team": {"type": "choice", "choice": "billing"},
781 }));
782 assert!(
783 mistyped
784 .check(&triage())
785 .unwrap_err()
786 .to_string()
787 .contains("noul question")
788 );
789
790 let invented = decision(json!({
791 "refund": {"type": "noul", "noul": 0.9},
792 "team": {"type": "choice", "choice": "sales"},
793 }));
794 assert!(
795 invented
796 .check(&triage())
797 .unwrap_err()
798 .to_string()
799 .contains("\"sales\"")
800 );
801 }
802
803 #[test]
806 fn the_answer_schema_describes_the_decision_that_answers() {
807 let mut asked = triage();
808 asked.insert(
809 "urgency".into(),
810 Question::Score {
811 instructions: "how soon".into(),
812 criteria: vec!["week".into(), "today".into(), "now".into()],
813 },
814 );
815 let schema = answer_schema(&asked);
816 let answers = &schema["properties"]["answers"];
817 assert_eq!(answers["required"], json!(["refund", "team", "urgency"]));
818 assert_eq!(
819 answers["properties"]["team"]["properties"]["choice"]["enum"],
820 json!(["billing", "technical"])
821 );
822 assert_eq!(
823 answers["properties"]["urgency"]["properties"]["score"]["maximum"],
824 json!(2)
825 );
826
827 let decision = decision(json!({
828 "refund": {"type": "noul", "noul": 0.9},
829 "team": {"type": "choice", "choice": "billing", "confidence": 0.8,
830 "probabilities": {"billing": 0.9, "technical": 0.1}},
831 "urgency": {"type": "score", "score": 1.2, "probabilities": {"1": 0.8, "2": 0.2}},
832 }));
833 let value = serde_json::to_value(&decision).unwrap();
834 for (name, answer) in value["answers"].as_object().unwrap() {
835 let declared = &answers["properties"][name]["properties"];
836 for key in answer.as_object().unwrap().keys() {
837 assert!(
838 declared.get(key).is_some(),
839 "`{name}.{key}` is not in the schema"
840 );
841 }
842 }
843 }
844
845 fn settings(provider: &str, url: Option<&str>) -> DecisionSettings {
846 DecisionSettings {
847 provider: provider.to_string(),
848 model: "typesafe/jev-1.13".to_string(),
849 api_key: None,
850 url: url.map(str::to_string),
851 }
852 }
853
854 #[test]
855 fn openrouter_reads_its_key_from_the_environment_and_names_where_it_came_from() {
856 let lookup = |var: &str| (var == "OPENROUTER_API_KEY").then(|| "sk-or".to_string());
857 let resolved = resolve_endpoint(&settings("openrouter", None), Env::new(&lookup)).unwrap();
858 assert_eq!(resolved.url, "https://openrouter.ai/api/alpha/decisions");
859 assert_eq!(resolved.api_key.as_deref(), Some("sk-or"));
860 assert_eq!(resolved.key_from, Some("OPENROUTER_API_KEY"));
861 assert!(!format!("{resolved:?}").contains("sk-or"));
862 }
863
864 #[test]
865 fn a_hosted_provider_without_a_key_is_refused() {
866 let none = |_: &str| None;
867 let err = resolve_endpoint(&settings("perplexity", None), Env::new(&none)).unwrap_err();
868 assert!(err.to_string().contains("PERPLEXITY_API_KEY"), "{err}");
869 }
870
871 #[test]
872 fn systemone_needs_a_url_and_reads_no_key_ambiently() {
873 let any = |_: &str| Some("a key for some other host".to_string());
874 assert!(resolve_endpoint(&settings("systemone", None), Env::new(&any)).is_err());
875 let local = resolve_endpoint(
876 &settings("systemone", Some("http://127.0.0.1:8000/v1/systemone")),
877 Env::new(&any),
878 )
879 .unwrap();
880 assert_eq!(local.api_key, None);
881 }
882
883 #[test]
884 fn an_unknown_provider_lists_the_known_ones() {
885 let none = |_: &str| None;
886 let err = resolve_endpoint(&settings("typesafe", None), Env::new(&none)).unwrap_err();
887 assert!(
888 err.to_string()
889 .contains("openrouter, perplexity, systemone"),
890 "{err}"
891 );
892 }
893}