Skip to main content

allama/decision/
request.rs

1use std::collections::HashSet;
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5
6use super::named;
7use super::{DecisionInput, Question};
8use crate::{Error, Image, ProviderOptions, RequestOptions, Result};
9
10/// Named questions about one input, answered by a decision model.
11///
12/// Every field is semantic: a provider either sends it as given or fails.
13/// There are no hints to degrade, so unlike chat and embedding requests a
14/// decision request carries no [`UnsupportedPolicy`](crate::UnsupportedPolicy).
15///
16/// ```
17/// use allama::DecisionRequest;
18/// use allama::decision::Question;
19///
20/// let request = DecisionRequest::new("nimble", "I was charged twice. Please refund me.")
21///     .question(
22///         "label",
23///         Question::choice("Which label fits this ticket?")
24///             .option("billing", "Payments and refunds")
25///             .option("bug", "Software errors"),
26///     )
27///     .question("refund", Question::yes_no("Is the customer requesting a refund?"));
28/// assert_eq!(request.questions.len(), 2);
29/// ```
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[non_exhaustive]
32pub struct DecisionRequest {
33    /// Model identifier.
34    pub model: String,
35    /// What the questions are about.
36    pub input: DecisionInput,
37    /// Images the model reads together with the input, in order, for
38    /// models that report
39    /// [`DecisionCapability::ImageInput`](crate::DecisionCapability::ImageInput).
40    /// Every question is about the input and the images alike.
41    ///
42    /// [`Image::detail`] is a hint no decision API honors: adapters warn
43    /// about it.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub images: Vec<Image>,
46    /// Questions by unique name, in order.
47    #[serde(with = "named")]
48    pub questions: Vec<(String, Question)>,
49    /// Provider-specific options.
50    #[serde(default, skip_serializing_if = "RequestOptions::is_empty")]
51    pub options: RequestOptions,
52}
53
54impl DecisionRequest {
55    /// A request for `model` about `input`, with no questions yet.
56    #[must_use]
57    pub fn new(model: impl Into<String>, input: impl Into<DecisionInput>) -> Self {
58        Self {
59            model: model.into(),
60            input: input.into(),
61            images: Vec::new(),
62            questions: Vec::new(),
63            options: RequestOptions::new(),
64        }
65    }
66
67    /// Append an image the model reads together with the input.
68    ///
69    /// ```
70    /// use allama::{DecisionRequest, Image};
71    /// use allama::decision::Question;
72    ///
73    /// # let png_bytes: &[u8] = b"\x89PNG";
74    /// let request = DecisionRequest::new("@cf/cloudflare/clef", "A product photo from a listing.")
75    ///     .image(Image::bytes("image/png", png_bytes))
76    ///     .question("damaged", Question::yes_no("Does the product look damaged?"));
77    /// assert_eq!(request.images.len(), 1);
78    /// ```
79    #[must_use]
80    pub fn image(mut self, image: impl Into<Image>) -> Self {
81        self.images.push(image.into());
82        self
83    }
84
85    /// Append a question named `name`. Names must be unique.
86    #[must_use]
87    pub fn question(mut self, name: impl Into<String>, question: impl Into<Question>) -> Self {
88        self.questions.push((name.into(), question.into()));
89        self
90    }
91
92    /// Append several named questions.
93    #[must_use]
94    pub fn questions<I, N, Q>(mut self, questions: I) -> Self
95    where
96        I: IntoIterator<Item = (N, Q)>,
97        N: Into<String>,
98        Q: Into<Question>,
99    {
100        self.questions.extend(
101            questions
102                .into_iter()
103                .map(|(name, question)| (name.into(), question.into())),
104        );
105        self
106    }
107
108    /// The question named `name`, if any.
109    #[must_use]
110    pub fn find_question(&self, name: &str) -> Option<&Question> {
111        named::get(&self.questions, name)
112    }
113
114    /// Check the request's structure and `limits` before sending it.
115    ///
116    /// Providers call this through
117    /// [`validate_decision_request`](crate::DecisionProvider::validate_decision_request)
118    /// before any I/O.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`Error::InvalidRequest`] when:
123    ///
124    /// - the model, input, a question name, instructions, an option key, a
125    ///   level label, or a description is blank;
126    /// - question names, option keys, or level labels repeat within their
127    ///   scope;
128    /// - a choice has fewer than two options or a score fewer than two
129    ///   levels;
130    /// - the request exceeds `limits`, its images included.
131    pub fn validate(&self, limits: &DecisionLimits) -> Result<()> {
132        if self.model.trim().is_empty() {
133            return Err(invalid("model must not be blank"));
134        }
135        if self.input.is_blank() {
136            return Err(invalid("input must not be blank"));
137        }
138        if self.questions.is_empty() {
139            return Err(invalid("at least one question is required"));
140        }
141        check_max(self.questions.len(), limits.max_questions, || {
142            "questions".into()
143        })?;
144        check_max(self.images.len(), limits.max_images, || "images".into())?;
145
146        let mut names = HashSet::new();
147        for (name, question) in &self.questions {
148            if is_blank(name) {
149                return Err(invalid("question names must not be blank"));
150            }
151            if !names.insert(name.as_str()) {
152                return Err(invalid(format!("duplicate question name {name:?}")));
153            }
154            let fail = |problem: String| invalid(format!("question {name:?}: {problem}"));
155            if is_blank(question.instructions()) {
156                return Err(fail("instructions must not be blank".into()));
157            }
158            match question {
159                Question::YesNo(question) => {
160                    if [&question.yes, &question.no]
161                        .into_iter()
162                        .flatten()
163                        .any(|description| is_blank(description))
164                    {
165                        return Err(fail("outcome descriptions must not be blank".into()));
166                    }
167                }
168                Question::Choice(choice) => {
169                    check_entries(
170                        choice
171                            .options
172                            .iter()
173                            .map(|option| (option.key.as_str(), option.description.as_deref())),
174                        "option",
175                        "key",
176                        "options",
177                    )
178                    .map_err(fail)?;
179                    check_max(choice.options.len(), limits.max_choice_options, || {
180                        format!("options in question {name:?}")
181                    })?;
182                }
183                Question::Score(score) => {
184                    check_entries(
185                        score
186                            .levels
187                            .iter()
188                            .map(|level| (level.label.as_str(), level.description.as_deref())),
189                        "level",
190                        "label",
191                        "levels",
192                    )
193                    .map_err(fail)?;
194                    check_max(score.levels.len(), limits.max_score_levels, || {
195                        format!("levels in question {name:?}")
196                    })?;
197                }
198            }
199        }
200        Ok(())
201    }
202
203    /// Attach provider options of the exact type.
204    #[must_use]
205    pub fn with_option<T: ProviderOptions + Serialize>(mut self, value: T) -> Self {
206        self.options.insert(value);
207        self
208    }
209
210    /// Attach an application value that is not a provider option: data a
211    /// wrapper of yours reads back with [`option`](Self::option), such as a
212    /// tenant id or a conversation id. Replaces any earlier value of that
213    /// type.
214    ///
215    /// The value needs no namespace and no serde: adapters never send it,
216    /// and it is left out of the request's serialized form and its
217    /// equality (an *opaque* option, [`RequestOptions::insert_opaque`](crate::RequestOptions::insert_opaque)).
218    #[must_use]
219    pub fn with_opaque_option<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
220        self.options.insert_opaque(value);
221        self
222    }
223
224    /// Attach provider options in their namespaced JSON form.
225    #[must_use]
226    pub fn with_options_json(
227        mut self,
228        namespace: impl Into<String>,
229        value: serde_json::Value,
230    ) -> Self {
231        self.options.insert_json(namespace, value);
232        self
233    }
234
235    /// Borrow provider options of the exact type.
236    #[must_use]
237    pub fn option<T: 'static>(&self) -> Option<&T> {
238        self.options.get::<T>()
239    }
240
241    /// Resolve provider options of type `T`, falling back to the default.
242    ///
243    /// # Errors
244    ///
245    /// See [`RequestOptions::resolve`].
246    pub fn resolve_option<T>(&self) -> Result<T>
247    where
248        T: ProviderOptions + DeserializeOwned + Default,
249    {
250        Ok(self.options.resolve_or_default::<T>()?.into_owned())
251    }
252
253    /// Mutably borrow provider options of the exact type, when attached.
254    ///
255    /// Like [`option`](Self::option), the JSON form is not consulted; to
256    /// change the options whichever form they were given in, use
257    /// [`resolve_option_mut`](Self::resolve_option_mut).
258    pub fn option_mut<T: Clone + Send + Sync + 'static>(&mut self) -> Option<&mut T> {
259        self.options.get_mut::<T>()
260    }
261
262    /// Resolve provider options of type `T` for changing in place: the
263    /// typed value, else the JSON form, else `T::default()`, stored as the
264    /// typed value. Use it to add to the caller's options without
265    /// replacing them, as [`with_option`](Self::with_option) would.
266    ///
267    /// # Errors
268    ///
269    /// See [`RequestOptions::resolve_mut`].
270    pub fn resolve_option_mut<T>(&mut self) -> Result<&mut T>
271    where
272        T: ProviderOptions + Serialize + DeserializeOwned + Default,
273    {
274        self.options.resolve_mut::<T>()
275    }
276}
277
278/// Request limits of a decision model, as reported by
279/// [`DecisionProvider::decision_limits`](crate::DecisionProvider::decision_limits).
280///
281/// `None` means the provider reports no limit. Limits differ widely between
282/// providers (for example 26 versus 255 choice options), which is why they
283/// are queried rather than fixed.
284#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
285#[non_exhaustive]
286pub struct DecisionLimits {
287    /// Maximum questions per request.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub max_questions: Option<usize>,
290    /// Maximum options per choice question.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub max_choice_options: Option<usize>,
293    /// Maximum levels per score question.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub max_score_levels: Option<usize>,
296    /// Maximum images per request.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub max_images: Option<usize>,
299}
300
301impl DecisionLimits {
302    /// No known limits.
303    #[must_use]
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    /// Set the maximum questions per request.
309    #[must_use]
310    pub fn max_questions(mut self, max: usize) -> Self {
311        self.max_questions = Some(max);
312        self
313    }
314
315    /// Set the maximum options per choice question.
316    #[must_use]
317    pub fn max_choice_options(mut self, max: usize) -> Self {
318        self.max_choice_options = Some(max);
319        self
320    }
321
322    /// Set the maximum levels per score question.
323    #[must_use]
324    pub fn max_score_levels(mut self, max: usize) -> Self {
325        self.max_score_levels = Some(max);
326        self
327    }
328
329    /// Set the maximum images per request.
330    #[must_use]
331    pub fn max_images(mut self, max: usize) -> Self {
332        self.max_images = Some(max);
333        self
334    }
335}
336
337fn invalid(message: impl Into<String>) -> Error {
338    Error::invalid_request(message).authored()
339}
340
341fn is_blank(text: &str) -> bool {
342    text.trim().is_empty()
343}
344
345/// Check the `(name, description)` entries of a choice (keys) or score
346/// (labels): at least two, names non-blank and unique, descriptions non-blank
347/// when present.
348fn check_entries<'a>(
349    entries: impl ExactSizeIterator<Item = (&'a str, Option<&'a str>)>,
350    entry: &str,
351    name: &str,
352    plural: &str,
353) -> std::result::Result<(), String> {
354    let count = entries.len();
355    if count < 2 {
356        return Err(format!("at least two {plural} are required, found {count}"));
357    }
358    let mut keys = HashSet::new();
359    for (key, description) in entries {
360        if is_blank(key) {
361            return Err(format!("{entry} {name}s must not be blank"));
362        }
363        if !keys.insert(key) {
364            return Err(format!("duplicate {entry} {key:?}"));
365        }
366        if description.is_some_and(is_blank) {
367            return Err(format!(
368                "the description of {entry} {key:?} must not be blank"
369            ));
370        }
371    }
372    Ok(())
373}
374
375fn check_max(count: usize, max: Option<usize>, what: impl FnOnce() -> String) -> Result<()> {
376    match max {
377        Some(max) if count > max => Err(invalid(format!(
378            "too many {}: {count}, the model accepts at most {max}",
379            what()
380        ))),
381        _ => Ok(()),
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    #[test]
391    fn options_change_in_place() {
392        #[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
393        struct Tags(Vec<String>);
394
395        impl ProviderOptions for Tags {
396            const NAMESPACE: &'static str = "tags";
397        }
398
399        let mut request = request().with_options_json("tags", json!(["caller"]));
400        assert!(request.option_mut::<Tags>().is_none());
401        request
402            .resolve_option_mut::<Tags>()
403            .unwrap()
404            .0
405            .push("wrapper".into());
406        request.option_mut::<Tags>().unwrap().0.push("more".into());
407        assert_eq!(
408            request.option::<Tags>().unwrap().0,
409            ["caller", "wrapper", "more"]
410        );
411    }
412
413    fn request() -> DecisionRequest {
414        DecisionRequest::new("m", "ticket")
415            .question(
416                "label",
417                Question::choice("Label?").bare_option("a").bare_option("b"),
418            )
419            .question("spam", Question::yes_no("Spam?"))
420            .question(
421                "urgency",
422                Question::score("Urgency?").levels(["low", "high"]),
423            )
424    }
425
426    fn message(request: DecisionRequest) -> String {
427        match request.validate(&DecisionLimits::new()).unwrap_err() {
428            Error::InvalidRequest { message, .. } => message,
429            other => panic!("expected InvalidRequest, got {other:?}"),
430        }
431    }
432
433    fn one(question: impl Into<Question>) -> DecisionRequest {
434        DecisionRequest::new("m", "x").question("q", question)
435    }
436
437    #[test]
438    fn valid_request_passes() {
439        request().validate(&DecisionLimits::new()).unwrap();
440    }
441
442    #[test]
443    fn structure_is_checked() {
444        assert!(message(DecisionRequest::new("m", "x")).contains("at least one question"));
445        assert!(
446            message(DecisionRequest::new("m", " ").question("q", Question::yes_no("?")))
447                .contains("input")
448        );
449        // A `null` value is no input, not the text `null`.
450        assert_eq!(
451            message(DecisionRequest::new("m", json!(null)).question("q", Question::yes_no("?"))),
452            "input must not be blank"
453        );
454        assert!(
455            message(request().question("spam", Question::yes_no("Again?")))
456                .contains("duplicate question")
457        );
458        assert!(
459            message(one(Question::choice("?").bare_option("only")))
460                .contains("at least two options")
461        );
462        assert!(
463            message(one(Question::choice("?").bare_option("a").bare_option("a")))
464                .contains("duplicate option \"a\"")
465        );
466        assert!(message(one(Question::yes_no(""))).contains("instructions"));
467        assert!(
468            message(one(Question::score("?")))
469                .contains("at least two levels are required, found 0")
470        );
471        assert!(message(one(Question::score("?").levels(["x", "x"]))).contains("duplicate level"));
472    }
473
474    #[test]
475    fn blank_descriptions_are_rejected() {
476        assert!(
477            message(one(Question::choice("?").option("a", " ").bare_option("b")))
478                .contains("description of option \"a\"")
479        );
480        assert!(
481            message(one(Question::score("?")
482                .level("low", "")
483                .bare_level("high")))
484            .contains("description of level \"low\"")
485        );
486        assert!(message(one(Question::yes_no("?").yes("  "))).contains("outcome descriptions"));
487    }
488
489    #[test]
490    fn limits_are_enforced() {
491        let limits = DecisionLimits::new().max_questions(2);
492        assert!(request().validate(&limits).is_err());
493
494        let limits = DecisionLimits::new().max_choice_options(1);
495        let err = request().validate(&limits).unwrap_err();
496        assert!(
497            err.message().contains("options in question \"label\""),
498            "{err}"
499        );
500
501        let limits = DecisionLimits::new().max_score_levels(1);
502        let err = request().validate(&limits).unwrap_err();
503        assert!(err.message().contains("levels"), "{err}");
504
505        let limits = DecisionLimits::new().max_images(1);
506        let two = request()
507            .image(Image::base64("image/png", "aGk="))
508            .image(Image::base64("image/png", "aGk="));
509        let err = two.validate(&limits).unwrap_err();
510        assert!(err.message().contains("too many images: 2"), "{err}");
511        two.validate(&DecisionLimits::new().max_images(2)).unwrap();
512    }
513
514    #[test]
515    fn images_serialize_only_when_present() {
516        assert!(
517            serde_json::to_value(request())
518                .unwrap()
519                .get("images")
520                .is_none()
521        );
522        let request = request().image(Image::base64("image/png", "aGk="));
523        let value = serde_json::to_value(&request).unwrap();
524        assert_eq!(
525            value["images"],
526            json!([{"source": {"type": "base64", "media_type": "image/png", "data": "aGk="}}])
527        );
528        let back: DecisionRequest = serde_json::from_value(value).unwrap();
529        assert_eq!(back, request);
530    }
531
532    #[test]
533    fn questions_serialize_in_order() {
534        let text = serde_json::to_string(&request()).unwrap();
535        let (label, spam, urgency) = (
536            text.find("\"label\"").unwrap(),
537            text.find("\"spam\"").unwrap(),
538            text.find("\"urgency\"").unwrap(),
539        );
540        assert!(label < spam && spam < urgency);
541        let back: DecisionRequest = serde_json::from_str(&text).unwrap();
542        assert_eq!(back, request());
543        assert_eq!(
544            serde_json::to_value(request()).unwrap()["input"],
545            json!("ticket")
546        );
547    }
548}