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