1#![doc = include_str!("../README.md")]
32#![forbid(unsafe_code)]
33
34mod speech;
35
36pub use speech::{
37 SpeechBinding, SpeechModel, SpeechRunner, TranscriptionModel, WORKERS_AI_MAX_AUDIO_BYTES,
38 WorkersAiSpeech,
39};
40
41use async_trait::async_trait;
42use cratefield_core::{
43 Answer, Calibration, Classifier, ClassifierError, ClassifierProfile, DEFAULT_MAX_STATE_CHARS,
44 Question, validate_questions,
45};
46use std::collections::BTreeMap;
47use worker::send::IntoSendFuture;
48
49pub const DEFAULT_MODEL_ID: &str = "typesafe/jev";
53
54pub const WORKERS_AI_CONTEXT_TOKENS: usize = 32_000;
59
60#[async_trait]
71pub trait AiRunner: Send + Sync {
72 async fn run(
80 &self,
81 model: &str,
82 input: serde_json::Value,
83 ) -> Result<serde_json::Value, ClassifierError>;
84}
85
86pub struct AiBinding(worker::Ai);
96
97#[async_trait]
98impl AiRunner for AiBinding {
99 async fn run(
100 &self,
101 model: &str,
102 input: serde_json::Value,
103 ) -> Result<serde_json::Value, ClassifierError> {
104 self.0
105 .run::<serde_json::Value, serde_json::Value>(model, input)
106 .into_send()
107 .await
108 .map_err(|err| map_worker_error(&err))
109 }
110}
111
112pub struct WorkersAi<R: AiRunner = AiBinding> {
118 runner: R,
119 model: String,
120 profile: ClassifierProfile,
121}
122
123impl WorkersAi<AiBinding> {
124 pub fn from_env(env: &worker::Env, binding: &str) -> Option<Self> {
131 let ai = env.ai(binding).ok()?;
132 Some(Self {
133 runner: AiBinding(ai),
134 model: DEFAULT_MODEL_ID.to_owned(),
135 profile: ClassifierProfile::new(Calibration::Classifier, DEFAULT_MAX_STATE_CHARS),
136 })
137 }
138
139 pub fn from_env_with_model(
143 env: &worker::Env,
144 binding: &str,
145 model: impl Into<String>,
146 profile: ClassifierProfile,
147 ) -> Option<Self> {
148 let ai = env.ai(binding).ok()?;
149 Some(Self {
150 runner: AiBinding(ai),
151 model: model.into(),
152 profile,
153 })
154 }
155}
156
157impl<R: AiRunner> WorkersAi<R> {
158 pub fn with_runner(runner: R, model: impl Into<String>, profile: ClassifierProfile) -> Self {
162 Self {
163 runner,
164 model: model.into(),
165 profile,
166 }
167 }
168}
169
170#[derive(serde::Serialize)]
178struct ClassifyRequest<'a> {
179 state: &'a str,
180 questions: Vec<WireQuestion<'a>>,
181}
182
183#[derive(serde::Serialize)]
184struct WireQuestion<'a> {
185 id: &'a str,
186 kind: &'a str,
188 instructions: &'a str,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 criteria: Option<BTreeMap<&'a str, &'a str>>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 levels: Option<Vec<WireLevel<'a>>>,
195}
196
197#[derive(serde::Serialize)]
198struct WireLevel<'a> {
199 name: &'a str,
200 meaning: &'a str,
201}
202
203fn wire_question<'a>(id: &'a str, question: &'a Question) -> WireQuestion<'a> {
204 match question {
205 Question::Choice {
206 instructions,
207 criteria,
208 } => WireQuestion {
209 id,
210 kind: question.kind(),
211 instructions,
212 criteria: Some(
213 criteria
214 .iter()
215 .map(|(name, meaning)| (name.as_str(), meaning.as_str()))
216 .collect(),
217 ),
218 levels: None,
219 },
220 Question::Score {
221 instructions,
222 levels,
223 } => WireQuestion {
224 id,
225 kind: question.kind(),
226 instructions,
227 criteria: None,
228 levels: Some(
229 levels
230 .iter()
231 .map(|(name, meaning)| WireLevel { name, meaning })
232 .collect(),
233 ),
234 },
235 Question::Noul { instructions } => WireQuestion {
236 id,
237 kind: question.kind(),
238 instructions,
239 criteria: None,
240 levels: None,
241 },
242 }
243}
244
245#[derive(serde::Deserialize)]
247struct RunResponse {
248 answers: Vec<WireAnswer>,
249}
250
251#[derive(serde::Deserialize)]
252struct WireAnswer {
253 id: String,
254 value: WireValue,
257 #[serde(default)]
258 probabilities: BTreeMap<String, f32>,
259}
260
261#[derive(serde::Deserialize)]
264#[serde(untagged)]
265enum WireValue {
266 Label(String),
267 Number(f32),
268}
269
270const SCORE_MATCH_TOLERANCE: f32 = 1.0e-6;
274
275const SUM_TOLERANCE: f32 = 1.0e-4;
280
281pub(crate) const BINDING_MARKERS: [&str; 5] = [
286 "binding cannot be cast",
287 "does not contain binding",
288 "is undefined",
289 "is not defined",
290 "not a function",
291];
292
293pub(crate) const TRANSIENT_MARKERS: [&str; 7] = [
298 "rate limit",
299 "rate-limit",
300 "too many requests",
301 "429",
302 "overload",
303 "throttl",
304 "temporarily unavailable",
305];
306
307const REJECTED_MARKERS: [&str; 9] = [
311 "invalid",
312 "not supported",
313 "unsupported",
314 "not found",
315 "unauthorized",
316 "forbidden",
317 "not allowed",
318 "malformed",
319 "too large",
320];
321
322fn map_worker_error(err: &worker::Error) -> ClassifierError {
333 if matches!(err, worker::Error::RateLimitExceeded(_)) {
335 return ClassifierError::Transient { retry_after: None };
336 }
337 let lowered = err.to_string().to_ascii_lowercase();
338 if BINDING_MARKERS
339 .iter()
340 .any(|marker| lowered.contains(marker))
341 {
342 return ClassifierError::NotConfigured;
343 }
344 if TRANSIENT_MARKERS
345 .iter()
346 .any(|marker| lowered.contains(marker))
347 {
348 return ClassifierError::Transient { retry_after: None };
349 }
350 if REJECTED_MARKERS
351 .iter()
352 .any(|marker| lowered.contains(marker))
353 {
354 return ClassifierError::Rejected(err.to_string());
355 }
356 ClassifierError::Transport(err.to_string())
357}
358
359fn reject(message: String) -> ClassifierError {
364 tracing::warn!(
365 provider = "workers-ai",
366 "a workers-ai answer does not match the question it answers"
367 );
368 ClassifierError::Rejected(message)
369}
370
371fn checked_probabilities(
376 id: &str,
377 question: &Question,
378 probabilities: BTreeMap<String, f32>,
379) -> Result<BTreeMap<String, f32>, ClassifierError> {
380 let labels = question.labels();
383 let mut checked = BTreeMap::new();
384 for (label, probability) in probabilities {
385 if !labels.contains(&label.as_str()) {
386 return Err(reject(format!(
387 "workers-ai answered question {id:?} with label {label:?}, which it does not offer"
388 )));
389 }
390 if !(0.0..=1.0).contains(&probability) {
391 return Err(reject(format!(
392 "workers-ai answered question {id:?} with probability {probability} for {label:?}, outside [0, 1]"
393 )));
394 }
395 checked.insert(label, probability);
396 }
397 let total: f32 = checked.values().sum();
398 if !total.is_finite() || total <= 0.0 {
399 return Err(reject(format!(
400 "workers-ai answered question {id:?} with a distribution that does not sum to a positive total"
401 )));
402 }
403 if (total - 1.0).abs() > SUM_TOLERANCE {
404 for probability in checked.values_mut() {
405 *probability /= total;
406 }
407 }
408 Ok(checked)
409}
410
411fn map_answers(
424 answers: Vec<WireAnswer>,
425 questions: &BTreeMap<String, Question>,
426) -> Result<BTreeMap<String, Answer>, ClassifierError> {
427 let mut mapped = BTreeMap::new();
428 for WireAnswer {
429 id,
430 value,
431 probabilities,
432 } in answers
433 {
434 let Some(question) = questions.get(&id) else {
435 return Err(reject(format!(
436 "workers-ai answered question id {id:?}, which was not asked"
437 )));
438 };
439 if mapped.contains_key(&id) {
440 return Err(reject(format!("workers-ai answered question {id:?} twice")));
441 }
442 let checked = checked_probabilities(&id, question, probabilities)?;
443
444 let built = match question {
445 Question::Choice { criteria, .. } => {
446 let WireValue::Label(label) = value else {
447 return Err(reject(format!(
448 "workers-ai answered choice question {id:?} with a number, not a criterion"
449 )));
450 };
451 if !criteria.contains_key(&label) {
452 return Err(reject(format!(
453 "workers-ai answered choice question {id:?} with {label:?}, which is not one of its criteria"
454 )));
455 }
456 Answer::choice(label, checked)
457 }
458 Question::Score { levels, .. } => {
459 let WireValue::Number(score) = value else {
460 return Err(reject(format!(
461 "workers-ai answered score question {id:?} with a label, not a number"
462 )));
463 };
464 if !score.is_finite() {
465 return Err(reject(format!(
466 "workers-ai scored question {id:?} at a value that is not a finite number"
467 )));
468 }
469 let numeric_levels: Vec<_> = levels
473 .iter()
474 .map(|(name, _)| name.parse::<f32>().ok())
475 .collect();
476 let all_numeric = numeric_levels.iter().all(Option::is_some);
477 if all_numeric
478 && !numeric_levels.iter().any(|parsed| {
479 parsed.is_some_and(|level| (level - score).abs() < SCORE_MATCH_TOLERANCE)
480 })
481 {
482 return Err(reject(format!(
483 "workers-ai scored question {id:?} at {score}, off its scale"
484 )));
485 }
486 Answer::score(score, checked)
487 }
488 Question::Noul { .. } => {
489 let WireValue::Label(label) = value else {
490 return Err(reject(format!(
491 "workers-ai answered noul question {id:?} with a number, not true or false"
492 )));
493 };
494 let verdict = match label.as_str() {
495 "true" => true,
496 "false" => false,
497 other => {
498 return Err(reject(format!(
499 "workers-ai answered noul question {id:?} with {other:?}, which is neither true nor false"
500 )));
501 }
502 };
503 Answer::noul(verdict, checked)
504 }
505 };
506 mapped.insert(id, built);
507 }
508 if mapped.len() != questions.len() {
509 let missing: Vec<_> = questions
510 .keys()
511 .filter(|id| !mapped.contains_key(*id))
512 .cloned()
513 .collect();
514 return Err(reject(format!(
515 "workers-ai did not answer the question(s) {missing:?}"
516 )));
517 }
518 Ok(mapped)
519}
520
521#[async_trait]
522impl<R: AiRunner> Classifier for WorkersAi<R> {
523 fn profile(&self) -> ClassifierProfile {
524 self.profile
525 }
526
527 async fn ask(
528 &self,
529 state: &str,
530 questions: &BTreeMap<String, Question>,
531 ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
532 validate_questions(questions)?;
535
536 let profile = self.profile();
537 let (state, dropped) = profile.truncate(state);
538 if dropped {
539 tracing::warn!(
543 provider = "workers-ai",
544 limit = profile.max_state_chars,
545 "workers-ai truncated the state; the answers see only the first `limit` chars"
546 );
547 }
548
549 let request = ClassifyRequest {
550 state,
551 questions: questions
552 .iter()
553 .map(|(id, question)| wire_question(id, question))
554 .collect(),
555 };
556 let input = serde_json::to_value(&request)
557 .map_err(|err| ClassifierError::Transport(err.to_string()))?;
558
559 let output = self.runner.run(&self.model, input).await?;
560
561 let parsed: RunResponse = match serde_json::from_value(output) {
562 Ok(parsed) => parsed,
563 Err(err) => {
564 tracing::warn!(
568 provider = "workers-ai",
569 outcome = "transport",
570 "a workers-ai success body did not survive the binding as an answer set"
571 );
572 return Err(ClassifierError::Transport(err.to_string()));
573 }
574 };
575 let answers = map_answers(parsed.answers, questions)?;
576 tracing::debug!(
577 provider = "workers-ai",
578 outcome = "answered",
579 questions = answers.len(),
580 "classifier outcome"
581 );
582 Ok(answers)
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use cratefield_core::AnswerValue;
590
591 fn good_set() -> BTreeMap<String, Question> {
592 BTreeMap::from([
593 (
594 "topic".to_owned(),
595 Question::Choice {
596 instructions: "Which topic?".to_owned(),
597 criteria: BTreeMap::from([
598 ("billing".to_owned(), "money, invoices".to_owned()),
599 ("bugs".to_owned(), "something is broken".to_owned()),
600 ]),
601 },
602 ),
603 (
604 "severity".to_owned(),
605 Question::Score {
606 instructions: "How severe?".to_owned(),
607 levels: vec![
608 ("1".to_owned(), "a typo".to_owned()),
609 ("5".to_owned(), "data loss".to_owned()),
610 ],
611 },
612 ),
613 (
614 "angry".to_owned(),
615 Question::Noul {
616 instructions: "Is the writer angry?".to_owned(),
617 },
618 ),
619 ])
620 }
621
622 fn request_value(state: &str, questions: &BTreeMap<String, Question>) -> serde_json::Value {
623 let payload = ClassifyRequest {
624 state,
625 questions: questions
626 .iter()
627 .map(|(id, question)| wire_question(id, question))
628 .collect(),
629 };
630 serde_json::to_value(&payload).expect("serialises")
631 }
632
633 #[test]
634 fn the_request_carries_each_kind_in_its_own_shape() {
635 let sent = request_value("the state", &good_set());
636 assert_eq!(sent["state"], "the state");
637 let questions = sent["questions"].as_array().expect("array");
638 assert_eq!(questions.len(), 3);
639
640 let choice = questions
641 .iter()
642 .find(|q| q["id"] == "topic")
643 .expect("choice question");
644 assert_eq!(choice["kind"], "choice");
645 assert_eq!(choice["criteria"]["bugs"], "something is broken");
646 assert!(choice.get("levels").is_none());
647
648 let score = questions
649 .iter()
650 .find(|q| q["id"] == "severity")
651 .expect("score question");
652 assert_eq!(score["kind"], "score");
653 assert!(score.get("criteria").is_none());
654 assert_eq!(
655 score["levels"],
656 serde_json::json!([
657 {"name": "1", "meaning": "a typo"},
658 {"name": "5", "meaning": "data loss"},
659 ])
660 );
661
662 let noul = questions
663 .iter()
664 .find(|q| q["id"] == "angry")
665 .expect("noul question");
666 assert_eq!(noul["kind"], "noul");
667 assert!(noul.get("criteria").is_none());
668 assert!(noul.get("levels").is_none());
669 }
670
671 #[test]
672 fn the_default_model_id_is_the_shared_classifier() {
673 assert_eq!(DEFAULT_MODEL_ID, "typesafe/jev");
674 }
675
676 #[test]
677 fn the_documented_window_fits_inside_the_default_ceiling() {
678 assert_eq!(WORKERS_AI_CONTEXT_TOKENS, 32_000);
680 const {
682 assert!(DEFAULT_MAX_STATE_CHARS < WORKERS_AI_CONTEXT_TOKENS * 4);
683 }
684 }
685
686 #[test]
687 fn a_well_formed_set_maps_through_the_core_constructors() {
688 let answers = vec![
689 WireAnswer {
690 id: "topic".to_owned(),
691 value: WireValue::Label("bugs".to_owned()),
692 probabilities: BTreeMap::from([
693 ("billing".to_owned(), 0.1_f32),
694 ("bugs".to_owned(), 0.9_f32),
695 ]),
696 },
697 WireAnswer {
698 id: "severity".to_owned(),
699 value: WireValue::Number(5.0),
700 probabilities: BTreeMap::from([("5".to_owned(), 1.0_f32)]),
701 },
702 WireAnswer {
703 id: "angry".to_owned(),
704 value: WireValue::Label("false".to_owned()),
705 probabilities: BTreeMap::from([
706 ("true".to_owned(), 0.2_f32),
707 ("false".to_owned(), 0.8_f32),
708 ]),
709 },
710 ];
711 let mapped = map_answers(answers, &good_set()).expect("maps");
712 assert_eq!(mapped.len(), 3);
713 let topic = &mapped["topic"];
714 assert_eq!(topic.value, AnswerValue::Choice("bugs".to_owned()));
715 assert!((topic.confidence - 0.9).abs() < f32::EPSILON, "{topic:?}");
716 let severity = &mapped["severity"];
717 assert_eq!(severity.value, AnswerValue::Score(5.0));
718 let angry = &mapped["angry"];
719 assert_eq!(angry.value, AnswerValue::Noul(false));
720 for (id, answer) in &mapped {
722 let mass: f32 = answer.probabilities.values().sum();
723 assert!((mass - 1.0).abs() < SUM_TOLERANCE, "{id}: {answer:?}");
724 }
725 }
726
727 #[test]
728 fn a_distribution_that_does_not_sum_to_one_is_normalised() {
729 let answers = vec![WireAnswer {
730 id: "topic".to_owned(),
731 value: WireValue::Label("billing".to_owned()),
732 probabilities: BTreeMap::from([
733 ("billing".to_owned(), 0.3_f32),
734 ("bugs".to_owned(), 0.3_f32),
735 ]),
736 }];
737 let questions = BTreeMap::from([("topic".to_owned(), good_set()["topic"].clone())]);
738 let mapped = map_answers(answers, &questions).expect("maps");
739 let topic = &mapped["topic"];
740 assert!(
741 (topic.probabilities["billing"] - 0.5).abs() < 1.0e-6,
742 "{topic:?}"
743 );
744 assert!(
745 (topic.probabilities["bugs"] - 0.5).abs() < 1.0e-6,
746 "{topic:?}"
747 );
748 assert!((topic.confidence - 0.5).abs() < 1.0e-6, "{topic:?}");
749 }
750
751 #[test]
752 fn every_semantic_refusal_is_rejected() {
753 let cases: Vec<(String, Vec<WireAnswer>)> = vec![
754 (
755 "missing id".to_owned(),
756 vec![WireAnswer {
757 id: "offTopic".to_owned(),
758 value: WireValue::Label("bugs".to_owned()),
759 probabilities: BTreeMap::from([("bugs".to_owned(), 1.0_f32)]),
760 }],
761 ),
762 (
763 "unknown label".to_owned(),
764 vec![WireAnswer {
765 id: "topic".to_owned(),
766 value: WireValue::Label("shipping".to_owned()),
767 probabilities: BTreeMap::from([("shipping".to_owned(), 1.0_f32)]),
768 }],
769 ),
770 (
771 "empty distribution".to_owned(),
772 vec![WireAnswer {
773 id: "topic".to_owned(),
774 value: WireValue::Label("bugs".to_owned()),
775 probabilities: BTreeMap::new(),
776 }],
777 ),
778 (
779 "probability above one".to_owned(),
780 vec![WireAnswer {
781 id: "topic".to_owned(),
782 value: WireValue::Label("bugs".to_owned()),
783 probabilities: BTreeMap::from([("bugs".to_owned(), 1.5_f32)]),
784 }],
785 ),
786 (
787 "score off its scale".to_owned(),
788 vec![WireAnswer {
789 id: "severity".to_owned(),
790 value: WireValue::Number(3.5),
791 probabilities: BTreeMap::from([("5".to_owned(), 1.0_f32)]),
792 }],
793 ),
794 ];
795 for (what, answers) in cases {
796 let mapped =
797 map_answers(answers, &good_set()).expect_err(&format!("{what} must be refused"));
798 assert!(
799 matches!(mapped, ClassifierError::Rejected(_)),
800 "{what}: {mapped:?}"
801 );
802 }
803 }
804
805 #[test]
806 fn a_question_the_model_skipped_is_rejected() {
807 let answers = vec![WireAnswer {
808 id: "topic".to_owned(),
809 value: WireValue::Label("bugs".to_owned()),
810 probabilities: BTreeMap::from([("bugs".to_owned(), 1.0_f32)]),
811 }];
812 let mapped = map_answers(answers, &good_set()).expect_err("skipped set refused");
813 assert!(matches!(mapped, ClassifierError::Rejected(_)), "{mapped:?}");
814 }
815
816 #[test]
817 fn the_error_mapping_lands_on_the_right_vocabulary() {
818 for text in [
820 "Binding cannot be cast to the type Ai from Object",
821 "Env does not contain binding `AI`",
822 "Ai is not defined",
823 ] {
824 let mapped = map_worker_error(&worker::Error::JsError(text.to_owned()));
825 assert!(
826 matches!(mapped, ClassifierError::NotConfigured),
827 "{text}: {mapped:?}"
828 );
829 }
830 for text in [
832 "rate limit exceeded",
833 "Too Many Requests",
834 "model is overloaded, try again",
835 ] {
836 let mapped = map_worker_error(&worker::Error::JsError(text.to_owned()));
837 assert!(
838 matches!(mapped, ClassifierError::Transient { retry_after: None }),
839 "{text}: {mapped:?}"
840 );
841 }
842 let structured =
843 map_worker_error(&worker::Error::RateLimitExceeded("daily cap".to_owned()));
844 assert!(matches!(
845 structured,
846 ClassifierError::Transient { retry_after: None }
847 ));
848 for text in [
850 "model typesafe/jev not found",
851 "invalid input: expected an object",
852 "input too large for this model",
853 ] {
854 let mapped = map_worker_error(&worker::Error::JsError(text.to_owned()));
855 assert!(matches!(mapped, ClassifierError::Rejected(_)), "{text}");
856 }
857 for err in [
860 worker::Error::JsError("workerd hiccup".to_owned()),
861 worker::Error::BadEncoding,
862 worker::Error::RustError("connection reset".to_owned()),
863 ] {
864 let mapped = map_worker_error(&err);
865 assert!(matches!(mapped, ClassifierError::Transport(_)), "{err}");
866 }
867 }
868}