Skip to main content

cortexkit_role_classifier/
request.rs

1//! `classify.run` requests and their validators.
2//!
3//! A runner validates a request completely before it records anything or
4//! calls a provider: [`parse_request`] decodes the request text naming the
5//! first malformed field, [`check_request_bytes`] checks its size,
6//! [`check_catalog_row`] the model's catalog row, and [`check_request`]
7//! every limit the model's `role.describe` entry states. Each refusal
8//! carries its code and `detail.field`.
9
10use indexmap::IndexMap;
11use serde::{Deserialize, Serialize};
12use serde_json::{Number, Value};
13
14use crate::{
15    describe::{Limits, ModelEntry},
16    errors::Refusal,
17    question::{bounded, check_image, check_question, Image, Question, IMAGES_MAX_TOTAL_BYTES},
18};
19
20/// Longest `batch_id`, in bytes.
21pub const BATCH_ID_MAX_LEN: usize = 128;
22
23/// The catalog `kind` of a classifier model.
24pub const CLASSIFIER_KIND: &str = "classifier";
25
26/// The `classify.run` request.
27///
28/// Non-exhaustive so later optional members are additive: use
29/// [`ClassifyRequest::new`] and the `with_*` setters, or decode one.
30#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
31#[non_exhaustive]
32pub struct ClassifyRequest {
33    /// The idempotency key: 1 to [`BATCH_ID_MAX_LEN`] printable ASCII
34    /// characters.
35    pub batch_id: String,
36    /// The catalog id, `<provider>/<model>`. Never substituted.
37    pub model: String,
38    /// The questions, by caller-chosen id, in the order the caller wrote
39    /// them.
40    pub questions: IndexMap<String, Question>,
41    /// The items, answered in this order.
42    pub items: Vec<Item>,
43    /// The batch's spend ceiling. Not part of the body identity; a lower
44    /// value on a re-send becomes the batch's recorded ceiling (see
45    /// [`effective_ceiling`]).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub max_cost_usd: Option<Number>,
48}
49
50impl ClassifyRequest {
51    pub fn new(
52        batch_id: impl Into<String>,
53        model: impl Into<String>,
54        questions: IndexMap<String, Question>,
55        items: Vec<Item>,
56    ) -> Self {
57        Self {
58            batch_id: batch_id.into(),
59            model: model.into(),
60            questions,
61            items,
62            max_cost_usd: None,
63        }
64    }
65
66    pub fn with_max_cost_usd(mut self, max_cost_usd: Number) -> Self {
67        self.max_cost_usd = Some(max_cost_usd);
68        self
69    }
70}
71
72/// One item: `{state, images?}`.
73#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
74#[non_exhaustive]
75pub struct Item {
76    /// A string, an object or an array, forwarded to the provider verbatim.
77    /// Null, numbers and booleans are refused.
78    pub state: Value,
79    /// Only where the model's catalog row allows images.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub images: Option<Vec<Image>>,
82}
83
84impl Item {
85    pub fn new(state: impl Into<Value>) -> Self {
86        Self {
87            state: state.into(),
88            images: None,
89        }
90    }
91
92    pub fn with_images(mut self, images: Vec<Image>) -> Self {
93        self.images = Some(images);
94        self
95    }
96}
97
98/// Whether `batch_id` is 1 to [`BATCH_ID_MAX_LEN`] printable ASCII
99/// characters (space through tilde).
100pub fn is_batch_id(batch_id: &str) -> bool {
101    !batch_id.is_empty()
102        && batch_id.len() <= BATCH_ID_MAX_LEN
103        && batch_id.bytes().all(|b| (0x20..=0x7e).contains(&b))
104}
105
106/// Whether `state` is a kind the role forwards: a string, an object or an
107/// array.
108pub fn is_state(state: &Value) -> bool {
109    matches!(state, Value::String(_) | Value::Object(_) | Value::Array(_))
110}
111
112/// Decode a `classify.run` request from its JSON text, refusing a malformed
113/// one `invalid_params` naming the first malformed field. Decoding from the
114/// text keeps the order of the questions and of each choice's options.
115///
116/// The refusal names `params` only when no narrower path applies: the text
117/// is not JSON, or not an object. A problem deep inside an otherwise
118/// well-formed request names its own path (`items[2].images[0]`, say),
119/// never `params`.
120pub fn parse_request(text: &str) -> Result<ClassifyRequest, Refusal> {
121    let value: Value = serde_json::from_str(text)
122        .map_err(|e| Refusal::invalid_params("params", format!("not JSON: {e}")))?;
123    let Value::Object(params) = &value else {
124        return Err(Refusal::invalid_params("params", "params is an object"));
125    };
126    let malformed = |field: String, what: &str| Refusal::invalid_params(field, what.to_owned());
127    for (field, what) in [
128        ("batch_id", "a string"),
129        ("model", "a string"),
130        ("questions", "an object"),
131        ("items", "an array"),
132    ] {
133        let ok = matches!(
134            (params.get(field), what),
135            (Some(Value::String(_)), "a string")
136                | (Some(Value::Object(_)), "an object")
137                | (Some(Value::Array(_)), "an array")
138        );
139        if !ok {
140            return Err(malformed(field.to_owned(), &format!("{field} is {what}")));
141        }
142    }
143    match params.get("max_cost_usd") {
144        None | Some(Value::Null) | Some(Value::Number(_)) => {}
145        Some(_) => return Err(malformed("max_cost_usd".into(), "max_cost_usd is a number")),
146    }
147    for (id, question) in params["questions"].as_object().into_iter().flatten() {
148        let Value::Object(question) = question else {
149            return Err(malformed(
150                format!("questions.{id}"),
151                "a question is an object",
152            ));
153        };
154        if !question.get("type").is_some_and(Value::is_string) {
155            return Err(malformed(
156                format!("questions.{id}.type"),
157                "type is a string",
158            ));
159        }
160        if !question.contains_key("instructions") {
161            return Err(malformed(
162                format!("questions.{id}.instructions"),
163                "instructions is required",
164            ));
165        }
166        if !matches!(
167            question.get("criteria"),
168            None | Some(Value::Null) | Some(Value::Array(_)) | Some(Value::Object(_))
169        ) {
170            return Err(malformed(
171                format!("questions.{id}.criteria"),
172                "criteria is an array or an object",
173            ));
174        }
175    }
176    for (i, item) in params["items"].as_array().into_iter().flatten().enumerate() {
177        let Value::Object(item) = item else {
178            return Err(malformed(format!("items[{i}]"), "an item is an object"));
179        };
180        if !item.get("state").is_some_and(is_state) {
181            return Err(malformed(
182                format!("items[{i}].state"),
183                "state is a string, an object or an array",
184            ));
185        }
186        match item.get("images") {
187            None | Some(Value::Null) => {}
188            Some(Value::Array(images)) => {
189                for (j, image) in images.iter().enumerate() {
190                    if serde_json::from_value::<Image>(image.clone()).is_err() {
191                        return Err(malformed(
192                            format!("items[{i}].images[{j}]"),
193                            "an image is a data: URL or {content_type, base64}",
194                        ));
195                    }
196                }
197            }
198            Some(_) => {
199                return Err(malformed(
200                    format!("items[{i}].images"),
201                    "images is an array",
202                ))
203            }
204        }
205    }
206    // Every member was checked above, so this decode does not fail for any
207    // reason a narrower path could name; `params` covers what is left.
208    serde_json::from_str(text).map_err(|e| Refusal::invalid_params("params", e.to_string()))
209}
210
211/// Check the request body's size against the module's limit. The refusal
212/// names `params` and the limit: the size belongs to the request as a
213/// whole, so no narrower path applies.
214pub fn check_request_bytes(len: usize, limits: &Limits) -> Result<(), Refusal> {
215    if len as u64 > limits.max_request_bytes {
216        Err(Refusal::over_limit(
217            "params",
218            limits.max_request_bytes,
219            format!(
220                "the request is {len} bytes; at most {}",
221                limits.max_request_bytes
222            ),
223        ))
224    } else {
225        Ok(())
226    }
227}
228
229/// Check the model's catalog row: `None` when the model is not in the
230/// catalog (`model_unknown`), else its `kind`, which must be
231/// [`CLASSIFIER_KIND`] (`model_not_classifier`). The model is never
232/// substituted.
233pub fn check_catalog_row(model: &str, kind: Option<&str>) -> Result<(), Refusal> {
234    match kind {
235        None => Err(Refusal::model_unknown(model)),
236        Some(CLASSIFIER_KIND) => Ok(()),
237        Some(kind) => Err(Refusal::model_not_classifier(model, kind)),
238    }
239}
240
241/// Check every limit of `request` against the model's `role.describe` entry:
242/// the batch id, question count and each question, item count, each item's
243/// state and images, and `max_cost_usd`. Returns the first refusal.
244pub fn check_request(request: &ClassifyRequest, model: &ModelEntry) -> Result<(), Refusal> {
245    if !is_batch_id(&request.batch_id) {
246        return Err(Refusal::invalid_params(
247            "batch_id",
248            "batch_id is 1-128 printable ASCII characters",
249        ));
250    }
251    bounded(
252        request.questions.len(),
253        1,
254        model.max_questions as usize,
255        "questions",
256        "questions",
257    )?;
258    for (id, question) in &request.questions {
259        check_question(id, question)?;
260    }
261    bounded(
262        request.items.len(),
263        1,
264        model.max_items as usize,
265        "items",
266        "items",
267    )?;
268    for (i, item) in request.items.iter().enumerate() {
269        if !is_state(&item.state) {
270            return Err(Refusal::invalid_params(
271                format!("items[{i}].state"),
272                "state is a string, an object or an array",
273            ));
274        }
275        let Some(images) = &item.images else {
276            continue;
277        };
278        let field = format!("items[{i}].images");
279        let max = model.images.map_or(0, |support| support.max);
280        if images.len() as u64 > max {
281            return Err(Refusal::over_limit(
282                field,
283                max,
284                if model.images.is_none() {
285                    format!("{} takes no images", model.model)
286                } else {
287                    format!("{} images; at most {max}", images.len())
288                },
289            ));
290        }
291        for (j, image) in images.iter().enumerate() {
292            check_image(image)
293                .map_err(|why| Refusal::invalid_params(format!("{field}[{j}]"), why))?;
294        }
295        let total: usize = images.iter().map(Image::approx_bytes).sum();
296        if total > IMAGES_MAX_TOTAL_BYTES {
297            return Err(Refusal::over_limit(
298                field,
299                IMAGES_MAX_TOTAL_BYTES as u64,
300                format!("{total} image bytes; at most {IMAGES_MAX_TOTAL_BYTES}"),
301            ));
302        }
303    }
304    if let Some(max_cost_usd) = &request.max_cost_usd {
305        if max_cost_usd.as_f64().is_none_or(|usd| usd < 0.0) {
306            return Err(Refusal::invalid_params(
307                "max_cost_usd",
308                "max_cost_usd is a number of USD, zero or more",
309            ));
310        }
311    }
312    Ok(())
313}
314
315/// The ceiling a call works under, and the batch's recorded ceiling from
316/// then on: the lower of the ceiling recorded so far and the one this call
317/// carries. `None` when neither names one.
318///
319/// The recorded ceiling is not fixed at first admission. A runner records
320/// this value durably before the call sends anything, so after any call the
321/// recorded ceiling is `effective_ceiling(recorded, sent)`. That is what
322/// makes a tightening stick: a later re-send carrying the original, higher
323/// value works under the lower one, and a caller can tighten a batch's
324/// ceiling but never loosen it.
325pub fn effective_ceiling(recorded: Option<f64>, sent: Option<f64>) -> Option<f64> {
326    match (recorded, sent) {
327        (Some(recorded), Some(sent)) => Some(recorded.min(sent)),
328        (one, None) | (None, one) => one,
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use serde_json::json;
335
336    use super::*;
337    use crate::{
338        describe::ImageSupport,
339        errors::{INVALID_PARAMS, MODEL_NOT_CLASSIFIER, MODEL_UNKNOWN},
340        question::InlineImage,
341    };
342
343    fn model() -> ModelEntry {
344        ModelEntry::new(
345            "cloudflare-workers-ai/@cf/cloudflare/clef",
346            2,
347            3,
348            8192,
349            "api.cloudflare.com",
350        )
351        .with_images(ImageSupport::new(4))
352    }
353
354    fn request(items: Vec<Item>) -> ClassifyRequest {
355        ClassifyRequest::new(
356            "b-1",
357            "cloudflare-workers-ai/@cf/cloudflare/clef",
358            [("urgent".to_owned(), Question::noul("Is it urgent?"))].into(),
359            items,
360        )
361    }
362
363    fn field(result: Result<(), Refusal>) -> String {
364        let refusal = result.unwrap_err();
365        assert_eq!(refusal.code, INVALID_PARAMS);
366        refusal.detail.field.unwrap()
367    }
368
369    #[test]
370    fn a_state_of_null_number_or_boolean_is_refused_naming_its_index() {
371        for bad in [json!(null), json!(3), json!(true)] {
372            let items = vec![Item::new("ok"), Item::new(json!({"a": 1})), Item::new(bad)];
373            assert_eq!(
374                field(check_request(&request(items), &model())),
375                "items[2].state"
376            );
377        }
378        let items = vec![
379            Item::new("s"),
380            Item::new(json!({"a": 1})),
381            Item::new(json!([1])),
382        ];
383        assert!(check_request(&request(items), &model()).is_ok());
384        let refusal = parse_request(
385            r#"{"batch_id":"b","model":"m","questions":{},"items":[{"state":"s"},{"state":null}]}"#,
386        )
387        .unwrap_err();
388        assert_eq!(refusal.field(), Some("items[1].state"));
389    }
390
391    #[test]
392    fn counts_are_refused_with_their_limit() {
393        let items = (0..4).map(|i| Item::new(format!("s{i}"))).collect();
394        let refusal = check_request(&request(items), &model()).unwrap_err();
395        assert_eq!(refusal.field(), Some("items"));
396        assert_eq!(refusal.detail.limit, Some(3));
397        let mut three = request(vec![Item::new("s")]);
398        for id in ["a", "b"] {
399            three.questions.insert(id.into(), Question::noul("i"));
400        }
401        let refusal = check_request(&three, &model()).unwrap_err();
402        assert_eq!(refusal.field(), Some("questions"));
403        assert_eq!(refusal.detail.limit, Some(2));
404        assert_eq!(field(check_request(&request(vec![]), &model())), "items");
405    }
406
407    #[test]
408    fn images_are_refused_where_the_model_takes_none_or_too_many() {
409        let image = Image::Inline(InlineImage::new("image/png", "AAAA"));
410        let item = Item::new("s").with_images(vec![image.clone(); 5]);
411        let refusal = check_request(&request(vec![item]), &model()).unwrap_err();
412        assert_eq!(refusal.field(), Some("items[0].images"));
413        assert_eq!(refusal.detail.limit, Some(4));
414        let text_only = ModelEntry::new("typesafe/jev-1.13.0", 2, 3, 8192, "api.typesafe.ai");
415        let item = Item::new("s").with_images(vec![image]);
416        let refusal = check_request(&request(vec![item]), &text_only).unwrap_err();
417        assert_eq!(refusal.field(), Some("items[0].images"));
418        assert_eq!(refusal.detail.limit, Some(0));
419        let item = Item::new("s").with_images(vec![Image::DataUrl("https://x/y.png".into())]);
420        assert_eq!(
421            field(check_request(&request(vec![item]), &model())),
422            "items[0].images[0]"
423        );
424    }
425
426    #[test]
427    fn batch_id_and_max_cost_are_checked() {
428        let mut bad = request(vec![Item::new("s")]);
429        bad.batch_id = "tab\there".into();
430        assert_eq!(field(check_request(&bad, &model())), "batch_id");
431        bad.batch_id = "x".repeat(129);
432        assert_eq!(field(check_request(&bad, &model())), "batch_id");
433        let negative =
434            request(vec![Item::new("s")]).with_max_cost_usd(Number::from_f64(-1.0).unwrap());
435        assert_eq!(field(check_request(&negative, &model())), "max_cost_usd");
436    }
437
438    #[test]
439    fn parse_names_the_malformed_field_and_keeps_question_order() {
440        let refusal = parse_request(r#"{"model":"m","questions":{},"items":[]}"#).unwrap_err();
441        assert_eq!(refusal.field(), Some("batch_id"));
442        let refusal = parse_request(
443            r#"{"batch_id":"b","model":"m","questions":{"q":{"instructions":"i"}},"items":[]}"#,
444        )
445        .unwrap_err();
446        assert_eq!(refusal.field(), Some("questions.q.type"));
447        let parsed = parse_request(
448            r#"{"batch_id":"b","model":"m","questions":{"z":{"type":"noul","instructions":"i"},"a":{"type":"noul","instructions":"i"}},"items":[{"state":"s"}]}"#,
449        )
450        .unwrap();
451        assert_eq!(parsed.questions.keys().collect::<Vec<_>>(), ["z", "a"]);
452    }
453
454    #[test]
455    fn catalog_rows_are_pinned() {
456        assert!(check_catalog_row("m", Some("classifier")).is_ok());
457        assert_eq!(
458            check_catalog_row("m", None).unwrap_err().code,
459            MODEL_UNKNOWN
460        );
461        let refusal = check_catalog_row("m", Some("chat")).unwrap_err();
462        assert_eq!(refusal.code, MODEL_NOT_CLASSIFIER);
463        assert_eq!(refusal.detail.kind.as_deref(), Some("chat"));
464    }
465
466    #[test]
467    fn request_bytes_are_bounded() {
468        let limits = Limits::new(10, 100);
469        assert!(check_request_bytes(100, &limits).is_ok());
470        let refusal = check_request_bytes(101, &limits).unwrap_err();
471        assert_eq!(refusal.field(), Some("params"));
472        assert_eq!(refusal.detail.limit, Some(100));
473    }
474
475    #[test]
476    fn the_ceiling_tightens_and_never_loosens() {
477        assert_eq!(effective_ceiling(Some(1.0), Some(5.0)), Some(1.0));
478        assert_eq!(effective_ceiling(Some(1.0), Some(0.5)), Some(0.5));
479        assert_eq!(effective_ceiling(None, Some(0.5)), Some(0.5));
480        assert_eq!(effective_ceiling(Some(1.0), None), Some(1.0));
481        assert_eq!(effective_ceiling(None, None), None);
482    }
483
484    #[test]
485    fn a_tightened_ceiling_is_recorded_across_resends() {
486        // High, then lower, then the original high again: each call records
487        // its effective ceiling, so the third works under the lower value.
488        let mut recorded = None;
489        let mut worked_under = Vec::new();
490        for sent in [5.0, 1.0, 5.0] {
491            recorded = effective_ceiling(recorded, Some(sent));
492            worked_under.push(recorded);
493        }
494        assert_eq!(worked_under, [Some(5.0), Some(1.0), Some(1.0)]);
495    }
496
497    #[test]
498    fn params_is_named_only_when_no_narrower_path_applies() {
499        for whole in ["", "not json", "[]", "null", "7"] {
500            assert_eq!(
501                parse_request(whole).unwrap_err().field(),
502                Some("params"),
503                "{whole:?}"
504            );
505        }
506        // An empty object misses its first required member, which is a
507        // narrower path than the request as a whole.
508        assert_eq!(parse_request("{}").unwrap_err().field(), Some("batch_id"));
509        // A malformed image deep in the request keeps the whole text from
510        // decoding, and the refusal still names the one image.
511        let refusal = parse_request(
512            r#"{"batch_id":"b","model":"m","questions":{"q":{"type":"noul","instructions":"i"}},"items":[{"state":"s"},{"state":"t","images":["data:image/png;base64,AAAA",5]}]}"#,
513        )
514        .unwrap_err();
515        assert_eq!(refusal.field(), Some("items[1].images[1]"));
516        let refusal = parse_request(
517            r#"{"batch_id":"b","model":"m","questions":{"q":{"type":"noul","instructions":"i","criteria":"yes"}},"items":[{"state":"s"}]}"#,
518        )
519        .unwrap_err();
520        assert_eq!(refusal.field(), Some("questions.q.criteria"));
521    }
522
523    #[test]
524    fn an_unknown_question_type_in_a_request_is_refused_naming_its_key() {
525        let mut request = request(vec![Item::new("s")]);
526        request.questions.insert(
527            "priority".into(),
528            Question::new(crate::question::QuestionType::parse("rank"), "How urgent?"),
529        );
530        assert_eq!(
531            field(check_request(&request, &model())),
532            "questions.priority.type"
533        );
534    }
535}