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 },
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
31pub const CLASSIFIER_KIND: &str = "classifier";
33
34#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
39#[non_exhaustive]
40pub struct ClassifyRequest {
41 pub batch_id: String,
44 pub model: String,
46 pub questions: IndexMap<String, Question>,
49 pub items: Vec<Item>,
51 #[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#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
82#[non_exhaustive]
83pub struct Item {
84 pub state: Value,
87 #[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
106pub fn is_state(state: &Value) -> bool {
109 matches!(state, Value::String(_) | Value::Object(_) | Value::Array(_))
110}
111
112pub 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 serde_json::from_str(text).map_err(|e| Refusal::invalid_params("params", e.to_string()))
209}
210
211pub fn check_request_bytes(len: usize, limits: &Limits) -> Result<(), Refusal> {
215 check_request_size(len, limits.max_request_bytes)
216}
217
218pub 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
233pub 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 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 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 assert_eq!(parse_request("{}").unwrap_err().field(), Some("batch_id"));
470 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}