//! Requests: what an app asks a model to do. //! //! A request names a capability and carries typed inputs (prompt, source, mask, references) //! plus free-form parameters. [`Request::validate`] checks it against a model's //! [`CapabilitySpec`] before anything is sent, so "this model takes no mask" or "at most one //! reference" is a clear [`AiError`] with kind `Unsupported`, never a mystery from the provider. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::capability::CapabilityId; use crate::error::AiError; use crate::model::{CapabilitySpec, InputRole}; use crate::payload::{Limits, Payload}; /// Largest accepted serialized size of `params`. pub const MAX_PARAMS_BYTES: usize = 64 * 1024; /// One typed input. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Input { pub role: InputRole, pub payload: Payload, } /// A unit of work for a model. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Request { pub capability: CapabilityId, #[serde(default)] pub inputs: Vec, /// Provider-neutral options such as `seed` or `size`. Capped by [`MAX_PARAMS_BYTES`]. #[serde(default)] pub params: Map, /// How many results to return (variations). #[serde(default = "one")] pub outputs: u32, /// Ask for incremental results (text deltas). #[serde(default)] pub stream: bool, } fn one() -> u32 { 1 } impl Request { pub fn new(capability: CapabilityId) -> Self { Self { capability, inputs: Vec::new(), params: Map::new(), outputs: 1, stream: false } } pub fn with_input(mut self, role: InputRole, payload: Payload) -> Self { self.inputs.push(Input { role, payload }); self } pub fn with_param(mut self, key: &str, value: Value) -> Self { self.params.insert(key.to_owned(), value); self } pub fn with_outputs(mut self, n: u32) -> Self { self.outputs = n; self } pub fn streaming(mut self) -> Self { self.stream = true; self } /// How many inputs have this role. pub fn count(&self, role: &InputRole) -> usize { self.inputs.iter().filter(|i| &i.role == role).count() } /// Checks the request against what a model can do. The first problem found is returned. pub fn validate(&self, spec: &CapabilitySpec, limits: &Limits) -> Result<(), AiError> { if self.capability != spec.capability { return Err(AiError::invalid(format!("request is for {} but the model spec is for {}", self.capability, spec.capability))); } let mut counts: BTreeMap<&InputRole, u32> = BTreeMap::new(); for input in &self.inputs { input.payload.check(limits)?; let n = counts.entry(&input.role).or_insert(0); *n = n.saturating_add(1); } for (role, n) in &counts { match spec.accepts.get(*role) { None => { return Err(AiError::unsupported(format!("this model does not accept a '{role}' input"))); } Some(max) if n > max => { return Err(AiError::unsupported(format!("this model accepts at most {max} '{role}' input(s), got {n}"))); } Some(_) => {} } } if let Some(missing) = spec.requires.iter().find(|r| !counts.contains_key(r)) { return Err(AiError::invalid(format!("a '{missing}' input is required"))); } if self.outputs == 0 || self.outputs > spec.max_outputs { return Err(AiError::unsupported(format!("this model returns at most {} output(s); {} requested", spec.max_outputs, self.outputs))); } if self.stream && !spec.features.streaming { return Err(AiError::unsupported("this model cannot stream")); } let params_len = serde_json::to_vec(&self.params).map(|v| v.len()).unwrap_or(usize::MAX); if params_len > MAX_PARAMS_BYTES { return Err(AiError::invalid(format!("params are over {MAX_PARAMS_BYTES} bytes"))); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::error::ErrorKind; use crate::model::Features; fn cap(s: &str) -> CapabilityId { CapabilityId::parse(s).unwrap() } fn png() -> Payload { Payload::inline("image/png", vec![1, 2, 3]).unwrap() } fn inpaint_spec() -> CapabilitySpec { CapabilitySpec::new(cap("image.inpaint")) .accepting(InputRole::Prompt, 1) .accepting(InputRole::Source, 1) .accepting(InputRole::Mask, 1) .accepting(InputRole::Reference, 1) .requiring(InputRole::Source) .requiring(InputRole::Mask) .max_outputs(3) } fn good() -> Request { Request::new(cap("image.inpaint")).with_input(InputRole::Source, png()).with_input(InputRole::Mask, png()) } #[test] fn a_well_formed_request_passes() { assert!(good().with_input(InputRole::Prompt, Payload::text("a cat")).validate(&inpaint_spec(), &Limits::default()).is_ok()); } #[test] fn unsupported_input_is_a_clear_error() { let spec = CapabilitySpec::new(cap("image.generate")).accepting(InputRole::Prompt, 1); let req = Request::new(cap("image.generate")).with_input(InputRole::Mask, png()); let err = req.validate(&spec, &Limits::default()).unwrap_err(); assert_eq!(err.kind, ErrorKind::Unsupported); assert!(err.message.contains("mask")); } #[test] fn too_many_references_are_counted_separately_from_masks() { let req = good().with_input(InputRole::Reference, png()).with_input(InputRole::Reference, png()); let err = req.validate(&inpaint_spec(), &Limits::default()).unwrap_err(); assert_eq!(err.kind, ErrorKind::Unsupported); assert!(err.message.contains("reference")); } #[test] fn missing_required_input_is_invalid() { let req = Request::new(cap("image.inpaint")).with_input(InputRole::Source, png()); let err = req.validate(&inpaint_spec(), &Limits::default()).unwrap_err(); assert_eq!(err.kind, ErrorKind::InvalidRequest); assert!(err.message.contains("mask")); } #[test] fn output_count_is_bounded() { assert!(good().with_outputs(3).validate(&inpaint_spec(), &Limits::default()).is_ok()); assert!(good().with_outputs(4).validate(&inpaint_spec(), &Limits::default()).is_err()); assert!(good().with_outputs(0).validate(&inpaint_spec(), &Limits::default()).is_err()); } #[test] fn streaming_needs_the_feature() { let spec = CapabilitySpec::new(cap("text.complete")).accepting(InputRole::Prompt, 1); let req = Request::new(cap("text.complete")).with_input(InputRole::Prompt, Payload::text("hi")).streaming(); assert_eq!(req.validate(&spec, &Limits::default()).unwrap_err().kind, ErrorKind::Unsupported); let spec = spec.with_features(Features { streaming: true, ..Features::default() }); assert!(req.validate(&spec, &Limits::default()).is_ok()); } #[test] fn capability_mismatch_is_invalid() { let req = Request::new(cap("image.generate")); assert_eq!(req.validate(&inpaint_spec(), &Limits::default()).unwrap_err().kind, ErrorKind::InvalidRequest); } #[test] fn oversized_inline_input_is_refused() { let limits = Limits { max_inline_bytes: 2, max_text_bytes: 10 }; assert!(good().validate(&inpaint_spec(), &limits).is_err()); } #[test] fn oversized_params_are_refused() { let big = Value::String("x".repeat(MAX_PARAMS_BYTES + 1)); assert!(good().with_param("blob", big).validate(&inpaint_spec(), &Limits::default()).is_err()); } #[test] fn request_round_trips_and_defaults_apply() { let req = good().with_param("seed", Value::from(7)).with_outputs(2); let back: Request = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); assert_eq!(req, back); let minimal: Request = serde_json::from_str(r#"{"capability":"image.generate"}"#).unwrap(); assert_eq!(minimal.outputs, 1); assert!(!minimal.stream); } }