Skip to main content

a2a_llm/
decision.rs

1//! Decision models: a typed answer and its probability, instead of text.
2//!
3//! A decision model is asked named questions about a `state` and returns one
4//! answer per question. A [`Question::Noul`] is answered with the probability
5//! of yes, a [`Question::Choice`] with one of the options it offers, and a
6//! [`Question::Score`] with a position on an ordered scale. The model
7//! generates no text and calls no tools.
8//!
9//! TypeSafe's Jev, Perplexity's `pplx-decider` and Mapika's open `decider`
10//! models all speak one wire format, which TypeSafe calls System One. Each
11//! host serves it at a different path. [`DecisionProvider`] is that set of
12//! hosts, and [`HttpDecisionModel`] is the one adapter for all of them.
13//!
14//! [`DecisionModel`] is the port, separate from [`LlmProvider`](crate::LlmProvider)
15//! because nothing about a chat request or a chat response carries over.
16//! [`decision_from_settings`] builds one from a host's configuration with no
17//! I/O, so a host can check a config before it runs.
18
19use 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
29/// The most options a [`Question::Choice`] may offer. Perplexity's documented
30/// limit; the open `decider` models share it.
31pub const MAX_CHOICE_OPTIONS: usize = 255;
32
33/// The most levels a [`Question::Score`] may have. TypeSafe's and Perplexity's
34/// documented limit.
35pub const MAX_SCORE_LEVELS: usize = 10;
36
37/// How long one decision may take. Decision models answer in well under a
38/// second; a minute covers a host in its long-context mode.
39const DECISION_DEADLINE: Duration = Duration::from_secs(60);
40
41/// One question, in the wire's spelling.
42#[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    /// A yes-or-no proposition. The answer is the probability that it holds.
47    Noul {
48        /// The proposition, phrased so that yes is a true statement about the
49        /// state.
50        instructions: String,
51        /// What counts as yes and what counts as no, when the instructions
52        /// alone leave it open.
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        criteria: Option<NoulCriteria>,
55    },
56    /// One option out of a set. The answer names the option and gives a
57    /// probability for each.
58    Choice {
59        /// What is being chosen.
60        instructions: String,
61        /// Each option's name and what it covers.
62        criteria: BTreeMap<String, String>,
63    },
64    /// A position on an ordered scale. The answer is the probability-weighted
65    /// index of the levels, `0.0` for the first.
66    Score {
67        /// What is being scored.
68        instructions: String,
69        /// The levels, lowest first. A level's index is its score.
70        criteria: Vec<String>,
71    },
72}
73
74/// What a [`Question::Noul`] counts as yes and as no.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
77#[serde(deny_unknown_fields)]
78pub struct NoulCriteria {
79    /// What a yes looks like.
80    #[serde(rename = "true")]
81    pub yes: String,
82    /// What a no looks like.
83    #[serde(rename = "false")]
84    pub no: String,
85}
86
87/// The questions one decision asks, by name. The name is the key its answer
88/// comes back under.
89pub type Questions = BTreeMap<String, Question>;
90
91impl Question {
92    /// The wire name of this question's type, which its answer carries too.
93    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    /// What the question asks.
102    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
111/// Why a set of questions cannot be asked, decided without asking.
112///
113/// Every documented host refuses these, so a host application can check
114/// them when its configuration loads rather than on the first request.
115pub 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            // One option is not a choice: its probability is 1 whatever the
129            // state says.
130            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/// One answer, as the model returned it.
152///
153/// Fields a host adds beyond these are dropped. Mapika's server adds
154/// `certainty` and `level_fit`, and no other host has them.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156#[serde(tag = "type", rename_all = "lowercase")]
157pub enum Answer {
158    /// The probability that the proposition holds.
159    Noul {
160        /// Between 0 and 1. Near 0.5 is the model saying it cannot tell.
161        noul: f64,
162    },
163    /// The option the model picked.
164    Choice {
165        /// The most probable option.
166        choice: String,
167        /// How concentrated the distribution is, 0 to 1. Not the chance the
168        /// choice is right.
169        #[serde(default, skip_serializing_if = "Option::is_none")]
170        confidence: Option<f64>,
171        /// Each option's probability.
172        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
173        probabilities: BTreeMap<String, f64>,
174    },
175    /// Where the state sits on the scale.
176    Score {
177        /// The probability-weighted level index.
178        score: f64,
179        /// How concentrated the distribution is, 0 to 1.
180        #[serde(default, skip_serializing_if = "Option::is_none")]
181        confidence: Option<f64>,
182        /// Each level's probability, keyed by its index.
183        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
184        probabilities: BTreeMap<String, f64>,
185    },
186}
187
188impl Answer {
189    /// The wire name of this answer's type.
190    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/// What one decision cost, as the host reported it.
200///
201/// Integers rather than the `Option`s of [`TokenUsage`](crate::TokenUsage):
202/// every documented host requires both counts in its response schema.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct DecisionUsage {
205    /// Tokens of state and questions read.
206    pub input_tokens: u64,
207    /// Tokens written. Decision hosts bill none.
208    pub output_tokens: u64,
209    /// The charge in the host's currency, when it says.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub cost: Option<f64>,
212}
213
214/// The answers to one set of questions.
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct Decision {
217    /// The model that answered, as the host names it. A host serving an alias
218    /// reports the dated snapshot here.
219    pub model: String,
220    /// One answer per question, under the question's name.
221    pub answers: BTreeMap<String, Answer>,
222    /// What it cost, when the host said.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub usage: Option<DecisionUsage>,
225}
226
227impl Decision {
228    /// Whether this answers what was asked: every question once, with an
229    /// answer of its type, and a choice that is one of the options.
230    ///
231    /// Checked rather than trusted to the host: a reply that drops a question
232    /// still parses, and a caller reading the answer would find the key
233    /// missing with nothing saying why.
234    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
265/// The JSON Schema of the [`Decision`] that answers `questions`.
266///
267/// Derived from the questions rather than written beside them, so the
268/// options a `choice` may return and the range a `score` spans are the ones
269/// the question offers. A host that serves decisions as typed tools, such as
270/// an MCP server, declares this as the tool's output schema.
271pub 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/// Why a decision could not be had.
357#[derive(Debug, thiserror::Error)]
358pub enum DecisionError {
359    /// The host could not be reached, or did not answer in time.
360    #[error("could not reach the decision model: {0}")]
361    Transport(String),
362    /// The host answered with an error.
363    #[error("the decision model refused ({status}): {message}")]
364    Refused {
365        /// The HTTP status.
366        status: u16,
367        /// The host's own words, or the body when it sent none.
368        message: String,
369    },
370    /// The host answered with something that is not a decision.
371    #[error("the decision model's reply did not parse: {0}")]
372    Malformed(String),
373    /// The host answered, but not the questions that were asked.
374    #[error("the decision model did not answer what was asked: {0}")]
375    Mismatch(String),
376}
377
378/// Something that answers typed questions about a state.
379#[async_trait]
380pub trait DecisionModel: Send + Sync {
381    /// Answer `questions` about `state`. A JSON string is text; an object or
382    /// an array is passed as the structure it is.
383    async fn decide(&self, state: &Value, questions: &Questions)
384    -> Result<Decision, DecisionError>;
385}
386
387/// The in-memory adapter: answers every request with the decision it was
388/// built with.
389///
390/// For a host that has to run with no model behind it, and for testing what
391/// a caller does with a decision.
392#[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/// Which host a decision goes to.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum DecisionProvider {
409    /// OpenRouter's Decisions API, which serves Jev and other hosted models.
410    OpenRouter,
411    /// Perplexity's Decisions API, serving `pplx-decider`.
412    Perplexity,
413    /// Any server speaking TypeSafe's System One format at a URL the settings
414    /// give: TypeSafe itself, or Mapika's `decider` serving locally.
415    SystemOne,
416}
417
418/// Every name [`DecisionProvider::from_name`] accepts, for the error that
419/// lists them.
420pub const DECISION_PROVIDERS: [&str; 3] = ["openrouter", "perplexity", "systemone"];
421
422impl DecisionProvider {
423    /// The provider a configuration names, or `None` for a name no adapter
424    /// serves.
425    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    /// The name a configuration spells it with.
435    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    /// The endpoint when the settings name none.
444    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    /// The variable the key is read from when the settings give none.
453    ///
454    /// A System One server may need no key, so none is read ambiently for
455    /// it: a key meant for one host, sent to whatever `url` names, is a leak.
456    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/// Decision model settings from a host's configuration.
466#[derive(Clone, Default, PartialEq, Eq)]
467pub struct DecisionSettings {
468    /// One of [`DECISION_PROVIDERS`].
469    pub provider: String,
470    /// The model, as the host names it: `typesafe/jev-1.13` on OpenRouter,
471    /// `pplx-decider-v1.1-27b` on Perplexity.
472    pub model: String,
473    /// The bearer token. When `None`, [`DecisionProvider::key_var`] is read.
474    pub api_key: Option<String>,
475    /// The full endpoint. Required for `systemone`; the hosted providers
476    /// default to their own.
477    pub url: Option<String>,
478}
479
480impl std::fmt::Debug for DecisionSettings {
481    /// Hand-written to keep `api_key` out of the output, as for
482    /// [`LlmSettings`](crate::LlmSettings).
483    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/// A decision provider is named but cannot be built. Raised before any
494/// request is made.
495#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
496pub enum DecisionConfigError {
497    /// The provider is known and its settings cannot be used: no key, no URL.
498    #[error("the {provider} decision provider is not usable: {detail}")]
499    Unusable {
500        /// Which provider.
501        provider: &'static str,
502        /// What is wrong with its settings.
503        detail: String,
504    },
505    /// [`DecisionSettings::provider`] names something no adapter serves.
506    #[error(
507        "unsupported decision provider {name:?}; expected one of: {}",
508        DECISION_PROVIDERS.join(", ")
509    )]
510    Unsupported {
511        /// The provider string as configured.
512        name: String,
513    },
514}
515
516/// Where decisions are sent and how they authenticate.
517#[derive(Clone, PartialEq, Eq)]
518pub struct DecisionEndpoint {
519    /// The host.
520    pub provider: DecisionProvider,
521    /// The model asked for.
522    pub model: String,
523    /// The full URL requests are posted to.
524    pub url: String,
525    /// The bearer token, when there is one.
526    pub api_key: Option<String>,
527    /// Where the key came from: `"config"`, a variable's name, or `None` when
528    /// there is no key.
529    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/// A decision model built from [`DecisionSettings`], and where it sends.
544#[derive(Clone)]
545pub struct SelectedDecision {
546    /// The model to ask.
547    pub model: Arc<dyn DecisionModel>,
548    /// Where it was resolved to, for a host's report.
549    pub endpoint: DecisionEndpoint,
550}
551
552/// Build the decision model `settings` describe, reading the provider's key
553/// variable when the settings give no key.
554///
555/// No I/O beyond the environment, so a host can run it to check a config.
556pub 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
617/// The one adapter for every [`DecisionProvider`]: a JSON POST of
618/// `{model, state, questions}`, answered with `{model, answers, usage}`.
619pub struct HttpDecisionModel {
620    client: reqwest::Client,
621    endpoint: DecisionEndpoint,
622}
623
624impl HttpDecisionModel {
625    /// A client for `endpoint`. Fails only when the HTTP client cannot be
626    /// built, which is a missing CA bundle.
627    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    /// Where this client sends.
641    pub fn endpoint(&self) -> &DecisionEndpoint {
642        &self.endpoint
643    }
644}
645
646/// The error body every documented host sends: `{"error": {"message": …}}`.
647#[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    /// Every key a serialized decision carries is one the schema declares,
804    /// with the choice among its options and the score inside its range.
805    #[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}