1use 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
20pub const BATCH_ID_MAX_LEN: usize = 128;
22
23pub const CLASSIFIER_KIND: &str = "classifier";
25
26#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
31#[non_exhaustive]
32pub struct ClassifyRequest {
33 pub batch_id: String,
36 pub model: String,
38 pub questions: IndexMap<String, Question>,
41 pub items: Vec<Item>,
43 #[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#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
74#[non_exhaustive]
75pub struct Item {
76 pub state: Value,
79 #[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
98pub 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
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 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
229pub 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
241pub 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
315pub 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 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 assert_eq!(parse_request("{}").unwrap_err().field(), Some("batch_id"));
509 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}