1use 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
30pub const CLASSIFIER_KIND: &str = "classifier";
32
33#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
38#[non_exhaustive]
39pub struct ClassifyRequest {
40 pub batch_id: String,
43 pub model: String,
45 pub questions: IndexMap<String, Question>,
48 pub items: Vec<Item>,
50 #[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#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
81#[non_exhaustive]
82pub struct Item {
83 pub state: Value,
86 #[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
105pub fn is_state(state: &Value) -> bool {
108 matches!(state, Value::String(_) | Value::Object(_) | Value::Array(_))
109}
110
111pub 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 serde_json::from_str(text).map_err(|e| Refusal::invalid_params("params", e.to_string()))
208}
209
210pub fn check_request_bytes(len: usize, limits: &Limits) -> Result<(), Refusal> {
214 check_request_size(len, limits.max_request_bytes)
215}
216
217pub 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
231pub 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 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 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 assert_eq!(parse_request("{}").unwrap_err().field(), Some("batch_id"));
463 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}