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 cortexkit_model_batch::{
15    admission::{
16        check_batch_id, check_catalog_row as check_row, check_max_cost_usd, check_request_size,
17    },
18    image::{check_images, ImageLimits},
19};
20
21pub use cortexkit_model_batch::admission::{
22    effective_ceiling, is_batch_id, CatalogRow, BATCH_ID_MAX_LEN,
23};
24
25use crate::{
26    describe::{Limits, ModelEntry},
27    errors::Refusal,
28    question::{bounded, check_question, Image, Question},
29};
30
31/// The catalog `kind` of a classifier model.
32pub const CLASSIFIER_KIND: &str = "classifier";
33
34/// The `classify.run` request.
35///
36/// Non-exhaustive so later optional members are additive: use
37/// [`ClassifyRequest::new`] and the `with_*` setters, or decode one.
38#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
39#[non_exhaustive]
40pub struct ClassifyRequest {
41    /// The idempotency key: 1 to [`BATCH_ID_MAX_LEN`] printable ASCII
42    /// characters.
43    pub batch_id: String,
44    /// The catalog id, `<provider>/<model>`. Never substituted.
45    pub model: String,
46    /// The questions, by caller-chosen id, in the order the caller wrote
47    /// them.
48    pub questions: IndexMap<String, Question>,
49    /// The items, answered in this order.
50    pub items: Vec<Item>,
51    /// The batch's spend ceiling. Not part of the body identity; a lower
52    /// value on a re-send becomes the batch's recorded ceiling (see
53    /// [`effective_ceiling`]).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub max_cost_usd: Option<Number>,
56}
57
58impl ClassifyRequest {
59    pub fn new(
60        batch_id: impl Into<String>,
61        model: impl Into<String>,
62        questions: IndexMap<String, Question>,
63        items: Vec<Item>,
64    ) -> Self {
65        Self {
66            batch_id: batch_id.into(),
67            model: model.into(),
68            questions,
69            items,
70            max_cost_usd: None,
71        }
72    }
73
74    pub fn with_max_cost_usd(mut self, max_cost_usd: Number) -> Self {
75        self.max_cost_usd = Some(max_cost_usd);
76        self
77    }
78}
79
80/// One item: `{state, images?}`.
81#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
82#[non_exhaustive]
83pub struct Item {
84    /// A string, an object or an array, forwarded to the provider verbatim.
85    /// Null, numbers and booleans are refused.
86    pub state: Value,
87    /// Only where the model's catalog row allows images.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub images: Option<Vec<Image>>,
90}
91
92impl Item {
93    pub fn new(state: impl Into<Value>) -> Self {
94        Self {
95            state: state.into(),
96            images: None,
97        }
98    }
99
100    pub fn with_images(mut self, images: Vec<Image>) -> Self {
101        self.images = Some(images);
102        self
103    }
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    check_request_size(len, limits.max_request_bytes)
216}
217
218/// Check the model's catalog row: [`CatalogRow::Absent`] is `model_unknown`;
219/// a row with another kind or no kind is `model_not_classifier`. A kindless
220/// refusal names the model without inventing a kind.
221pub fn check_catalog_row(model: &str, row: CatalogRow<'_>) -> Result<(), Refusal> {
222    check_row(model, row, CLASSIFIER_KIND, |model, kind| match kind {
223        Some(kind) => Refusal::model_not_classifier(model, kind),
224        None => {
225            let mut refusal = Refusal::model_not_classifier(model, "");
226            refusal.message = format!("{model} is not a classifier");
227            refusal.detail.kind = None;
228            refusal
229        }
230    })
231}
232
233/// Check every limit of `request` against the model's `role.describe` entry:
234/// the batch id, question count and each question, item count, each item's
235/// state and images, and `max_cost_usd`. Returns the first refusal.
236pub fn check_request(request: &ClassifyRequest, model: &ModelEntry) -> Result<(), Refusal> {
237    check_batch_id(&request.batch_id)?;
238    bounded(
239        request.questions.len(),
240        1,
241        model.max_questions as usize,
242        "questions",
243        "questions",
244    )?;
245    for (id, question) in &request.questions {
246        check_question(id, question)?;
247    }
248    bounded(
249        request.items.len(),
250        1,
251        model.max_items as usize,
252        "items",
253        "items",
254    )?;
255    for (i, item) in request.items.iter().enumerate() {
256        if !is_state(&item.state) {
257            return Err(Refusal::invalid_params(
258                format!("items[{i}].state"),
259                "state is a string, an object or an array",
260            ));
261        }
262        if let Some(images) = &item.images {
263            let limits = model
264                .images
265                .map(|support| ImageLimits::workers_ai(support.max));
266            check_images(
267                &model.model,
268                &format!("items[{i}].images"),
269                images,
270                limits.as_ref(),
271            )?;
272        }
273    }
274    check_max_cost_usd(request.max_cost_usd.as_ref())?;
275    Ok(())
276}
277
278#[cfg(test)]
279mod tests {
280    use serde_json::json;
281
282    use super::*;
283    use crate::{
284        describe::ImageSupport,
285        errors::{INVALID_PARAMS, MODEL_NOT_CLASSIFIER, MODEL_UNKNOWN},
286        question::InlineImage,
287    };
288
289    fn model() -> ModelEntry {
290        ModelEntry::new(
291            "cloudflare-workers-ai/@cf/cloudflare/clef",
292            2,
293            3,
294            8192,
295            "api.cloudflare.com",
296        )
297        .with_images(ImageSupport::new(4))
298    }
299
300    fn request(items: Vec<Item>) -> ClassifyRequest {
301        ClassifyRequest::new(
302            "b-1",
303            "cloudflare-workers-ai/@cf/cloudflare/clef",
304            [("urgent".to_owned(), Question::noul("Is it urgent?"))].into(),
305            items,
306        )
307    }
308
309    fn field(result: Result<(), Refusal>) -> String {
310        let refusal = result.unwrap_err();
311        assert_eq!(refusal.code, INVALID_PARAMS);
312        refusal.detail.field.unwrap()
313    }
314
315    #[test]
316    fn a_state_of_null_number_or_boolean_is_refused_naming_its_index() {
317        for bad in [json!(null), json!(3), json!(true)] {
318            let items = vec![Item::new("ok"), Item::new(json!({"a": 1})), Item::new(bad)];
319            assert_eq!(
320                field(check_request(&request(items), &model())),
321                "items[2].state"
322            );
323        }
324        let items = vec![
325            Item::new("s"),
326            Item::new(json!({"a": 1})),
327            Item::new(json!([1])),
328        ];
329        assert!(check_request(&request(items), &model()).is_ok());
330        let refusal = parse_request(
331            r#"{"batch_id":"b","model":"m","questions":{},"items":[{"state":"s"},{"state":null}]}"#,
332        )
333        .unwrap_err();
334        assert_eq!(refusal.field(), Some("items[1].state"));
335    }
336
337    #[test]
338    fn counts_are_refused_with_their_limit() {
339        let items = (0..4).map(|i| Item::new(format!("s{i}"))).collect();
340        let refusal = check_request(&request(items), &model()).unwrap_err();
341        assert_eq!(refusal.field(), Some("items"));
342        assert_eq!(refusal.detail.limit, Some(3));
343        let mut three = request(vec![Item::new("s")]);
344        for id in ["a", "b"] {
345            three.questions.insert(id.into(), Question::noul("i"));
346        }
347        let refusal = check_request(&three, &model()).unwrap_err();
348        assert_eq!(refusal.field(), Some("questions"));
349        assert_eq!(refusal.detail.limit, Some(2));
350        assert_eq!(field(check_request(&request(vec![]), &model())), "items");
351    }
352
353    #[test]
354    fn images_are_refused_where_the_model_takes_none_or_too_many() {
355        let image = Image::Inline(InlineImage::new("image/png", "AAAA"));
356        let item = Item::new("s").with_images(vec![image.clone(); 5]);
357        let refusal = check_request(&request(vec![item]), &model()).unwrap_err();
358        assert_eq!(refusal.field(), Some("items[0].images"));
359        assert_eq!(refusal.detail.limit, Some(4));
360        let text_only = ModelEntry::new("typesafe/jev-1.13.0", 2, 3, 8192, "api.typesafe.ai");
361        let item = Item::new("s").with_images(vec![image]);
362        let refusal = check_request(&request(vec![item]), &text_only).unwrap_err();
363        assert_eq!(refusal.field(), Some("items[0].images"));
364        assert_eq!(refusal.detail.limit, Some(0));
365        let item = Item::new("s").with_images(vec![Image::DataUrl("https://x/y.png".into())]);
366        assert_eq!(
367            field(check_request(&request(vec![item]), &model())),
368            "items[0].images[0]"
369        );
370        // Malformed base64 is refused before any provider call, naming the
371        // one image.
372        let item = Item::new("s").with_images(vec![Image::Inline(InlineImage::new(
373            "image/png",
374            "not base64!",
375        ))]);
376        assert_eq!(
377            field(check_request(&request(vec![item]), &model())),
378            "items[0].images[0]"
379        );
380    }
381
382    #[test]
383    fn batch_id_and_max_cost_are_checked() {
384        let mut bad = request(vec![Item::new("s")]);
385        bad.batch_id = "tab\there".into();
386        assert_eq!(field(check_request(&bad, &model())), "batch_id");
387        bad.batch_id = "x".repeat(129);
388        assert_eq!(field(check_request(&bad, &model())), "batch_id");
389        let negative =
390            request(vec![Item::new("s")]).with_max_cost_usd(Number::from_f64(-1.0).unwrap());
391        assert_eq!(field(check_request(&negative, &model())), "max_cost_usd");
392    }
393
394    #[test]
395    fn parse_names_the_malformed_field_and_keeps_question_order() {
396        let refusal = parse_request(r#"{"model":"m","questions":{},"items":[]}"#).unwrap_err();
397        assert_eq!(refusal.field(), Some("batch_id"));
398        let refusal = parse_request(
399            r#"{"batch_id":"b","model":"m","questions":{"q":{"instructions":"i"}},"items":[]}"#,
400        )
401        .unwrap_err();
402        assert_eq!(refusal.field(), Some("questions.q.type"));
403        let parsed = parse_request(
404            r#"{"batch_id":"b","model":"m","questions":{"z":{"type":"noul","instructions":"i"},"a":{"type":"noul","instructions":"i"}},"items":[{"state":"s"}]}"#,
405        )
406        .unwrap();
407        assert_eq!(parsed.questions.keys().collect::<Vec<_>>(), ["z", "a"]);
408    }
409
410    #[test]
411    fn catalog_rows_are_pinned() {
412        assert!(check_catalog_row("m", CatalogRow::Kind("classifier")).is_ok());
413        assert_eq!(
414            check_catalog_row("m", CatalogRow::Absent).unwrap_err().code,
415            MODEL_UNKNOWN
416        );
417        let kindless = check_catalog_row("m", CatalogRow::Kindless).unwrap_err();
418        assert_eq!(kindless.code, MODEL_NOT_CLASSIFIER);
419        assert_eq!(kindless.detail.model.as_deref(), Some("m"));
420        assert_eq!(kindless.detail.kind, None);
421        let other_kind = check_catalog_row("m", CatalogRow::Kind("chat")).unwrap_err();
422        assert_eq!(other_kind.code, MODEL_NOT_CLASSIFIER);
423        assert_eq!(other_kind.detail.model.as_deref(), Some("m"));
424        assert_eq!(other_kind.detail.kind.as_deref(), Some("chat"));
425    }
426
427    #[test]
428    fn request_bytes_are_bounded() {
429        let limits = Limits::new(10, 100);
430        assert!(check_request_bytes(100, &limits).is_ok());
431        let refusal = check_request_bytes(101, &limits).unwrap_err();
432        assert_eq!(refusal.field(), Some("params"));
433        assert_eq!(refusal.detail.limit, Some(100));
434    }
435
436    #[test]
437    fn the_ceiling_tightens_and_never_loosens() {
438        assert_eq!(effective_ceiling(Some(1.0), Some(5.0)), Some(1.0));
439        assert_eq!(effective_ceiling(Some(1.0), Some(0.5)), Some(0.5));
440        assert_eq!(effective_ceiling(None, Some(0.5)), Some(0.5));
441        assert_eq!(effective_ceiling(Some(1.0), None), Some(1.0));
442        assert_eq!(effective_ceiling(None, None), None);
443    }
444
445    #[test]
446    fn a_tightened_ceiling_is_recorded_across_resends() {
447        // High, then lower, then the original high again: each call records
448        // its effective ceiling, so the third works under the lower value.
449        let mut recorded = None;
450        let mut worked_under = Vec::new();
451        for sent in [5.0, 1.0, 5.0] {
452            recorded = effective_ceiling(recorded, Some(sent));
453            worked_under.push(recorded);
454        }
455        assert_eq!(worked_under, [Some(5.0), Some(1.0), Some(1.0)]);
456    }
457
458    #[test]
459    fn params_is_named_only_when_no_narrower_path_applies() {
460        for whole in ["", "not json", "[]", "null", "7"] {
461            assert_eq!(
462                parse_request(whole).unwrap_err().field(),
463                Some("params"),
464                "{whole:?}"
465            );
466        }
467        // An empty object misses its first required member, which is a
468        // narrower path than the request as a whole.
469        assert_eq!(parse_request("{}").unwrap_err().field(), Some("batch_id"));
470        // A malformed image deep in the request keeps the whole text from
471        // decoding, and the refusal still names the one image.
472        let refusal = parse_request(
473            r#"{"batch_id":"b","model":"m","questions":{"q":{"type":"noul","instructions":"i"}},"items":[{"state":"s"},{"state":"t","images":["data:image/png;base64,AAAA",5]}]}"#,
474        )
475        .unwrap_err();
476        assert_eq!(refusal.field(), Some("items[1].images[1]"));
477        let refusal = parse_request(
478            r#"{"batch_id":"b","model":"m","questions":{"q":{"type":"noul","instructions":"i","criteria":"yes"}},"items":[{"state":"s"}]}"#,
479        )
480        .unwrap_err();
481        assert_eq!(refusal.field(), Some("questions.q.criteria"));
482    }
483
484    #[test]
485    fn an_unknown_question_type_in_a_request_is_refused_naming_its_key() {
486        let mut request = request(vec![Item::new("s")]);
487        request.questions.insert(
488            "priority".into(),
489            Question::new(crate::question::QuestionType::parse("rank"), "How urgent?"),
490        );
491        assert_eq!(
492            field(check_request(&request, &model())),
493            "questions.priority.type"
494        );
495    }
496}