1use thiserror::Error as ThisError;
37
38use crate::archive::MAGIC;
39use crate::model::Model;
40
41const ENVELOPE_EXCERPT: usize = 200;
46
47fn unsupported(tag: u32) -> String {
49 if tag == MAGIC {
50 format!(
51 "magic tag {tag:#010X} is right, but the tensor directory after it is truncated or \
52 malformed"
53 )
54 } else {
55 format!("magic tag {tag:#010X} is not a Needle 3 or Whistle archive")
56 }
57}
58
59const fn weights_type(model: Model) -> &'static str {
61 match model {
62 Model::Needle => "needle::Weights",
63 Model::Whistle => "whistle::Weights",
64 }
65}
66
67#[derive(Debug, ThisError)]
82#[non_exhaustive]
83pub enum Error {
84 #[error(
87 "the engine slot for this model is already in use by another instance in this process\n\
88 The engine is one process-global runtime with no handles that holds one model per \
89 kind, so one Needle and one Whistle can exist at a time, not two of either.\n\
90 Help: drop the existing instance before building another of the same model, or share \
91 it behind a mutex."
92 )]
93 EngineBusy,
94
95 #[error(
97 "different weights of the same kind are already loaded in this process\n\
98 The engine copies the archive on the first load of each kind and offers no way to \
99 unload it.\n\
100 Help: build every instance of a model from the same weights, or run the second archive \
101 in its own process."
102 )]
103 WeightsAlreadyLoaded,
104
105 #[error(
111 "the weights are not a {expected} archive\n\
112 Needle 3 and Whistle archives share one magic tag, and this one holds the other model; \
113 it was not loaded.\n\
114 Help: build a {expected} from {} through {}, and hand this archive to the other \
115 model's builder.",
116 .expected.archive(),
117 weights_type(*.expected)
118 )]
119 WrongModel {
120 expected: Model,
122 },
123
124 #[error(
127 "unsupported weights: {}\n\
128 Needle 3 and Whistle archives start with the little-endian tag 0x05E12A84; 0x05E12A83 \
129 is a Needle 2 archive, which the linked engine cannot read.\n\
130 Help: download needle3.cact from Cactus-Compute/needle3 or whistle.cact from \
131 Cactus-Compute/whistle, or call Weights::fetch().",
132 unsupported(*.tag)
133 )]
134 UnsupportedWeights {
135 tag: u32,
138 },
139
140 #[error(
142 "the engine rejected the weight archive\n\
143 The magic tag was right but the engine could not load the bytes as a Needle 3 or \
144 Whistle model, which usually means a truncated or corrupted download.\n\
145 Help: delete the cached archive and fetch it again, or verify its SHA-256."
146 )]
147 Load,
148
149 #[error(
151 "the engine rejected the {model} archive: {detail}\n\
152 The magic tag was right but the engine could not load the bytes, which usually means a \
153 truncated or corrupted download.\n\
154 Help: delete the cached archive and fetch it again, or verify its SHA-256."
155 )]
156 LoadFailed {
157 model: Model,
159 detail: String,
161 },
162
163 #[error(
165 "the engine could not initialise the conversation prefix\n\
166 needle_init returned a non-positive token count, which happens when the weights are not \
167 loaded or the tool index path does not exist.\n\
168 Help: check that the tool index path exists and that the weights loaded successfully."
169 )]
170 Init,
171
172 #[error(
175 "the engine could not initialise the conversation prefix: {detail}\n\
176 The system prompt and the tool declarations must fit the context window together, and a \
177 tool index path must exist.\n\
178 Help: shorten the system prompt or declare fewer tools, and check that the tool index \
179 path exists."
180 )]
181 InitFailed {
182 detail: String,
185 },
186
187 #[error(
189 "the engine failed to complete the request: {detail}\n\
190 Help: reset the conversation, shorten the input, or raise max_new_tokens."
191 )]
192 Complete {
193 detail: String,
195 },
196
197 #[error(
199 "the engine truncated its response to fill the {capacity} byte output buffer\n\
200 The engine truncates without reporting it, so the JSON envelope is incomplete.\n\
201 Help: lower max_new_tokens, ask for fewer tool calls in one turn, or turn word \
202 timestamps off."
203 )]
204 Truncated {
205 capacity: usize,
207 },
208
209 #[error(
211 "the engine failed to embed the input\n\
212 needle_embed returned a negative value, which happens when no weights are loaded.\n\
213 Help: build the Needle from valid weights before embedding."
214 )]
215 Embed,
216
217 #[error(
220 "the engine failed to embed the input: {detail}\n\
221 Help: build the Needle from valid weights before embedding, and report the reason above \
222 if they are."
223 )]
224 EmbedFailed {
225 detail: String,
227 },
228
229 #[error(
231 "the {field} contains an interior NUL byte\n\
232 The engine takes NUL-terminated C strings, so a NUL inside the text would silently cut \
233 it short.\n\
234 Help: strip '\\0' from the {field} before passing it in."
235 )]
236 InteriorNul {
237 field: &'static str,
240 },
241
242 #[error(
244 "no speech model is loaded in this process\n\
245 An audio completion transcribes the clip with Whistle before Needle answers it, and \
246 the engine holds no Whistle weights yet.\n\
247 Help: build a Whistle once in this process before completing audio; its weights stay \
248 loaded after it is dropped."
249 )]
250 SpeechModelNotLoaded,
251
252 #[error(
254 "the clip is {samples} samples long, more than the 480000 the engine accepts\n\
255 The engine takes at most 30 seconds of 16 kHz mono audio in one call.\n\
256 Help: split the clip into chunks of at most 30 seconds and send them one at a time."
257 )]
258 AudioTooLong {
259 samples: usize,
261 },
262
263 #[error(
265 "sample {index} of the clip is not a finite number\n\
266 The engine expects 16 kHz mono PCM in [-1, 1], and a NaN or an infinity would poison \
267 every frame it touches.\n\
268 Help: check the decoder or resampler that produced the clip, or replace non-finite \
269 samples with 0.0."
270 )]
271 NonFiniteSample {
272 index: usize,
274 },
275
276 #[error(
278 "the engine failed to transcribe the clip: {detail}\n\
279 Help: check that the clip is 16 kHz mono PCM in [-1, 1], and that the Whistle weights \
280 loaded successfully."
281 )]
282 Transcribe {
283 detail: String,
285 },
286
287 #[error(
289 "the engine failed to embed the clip: {detail}\n\
290 Help: check that the clip is 16 kHz mono PCM in [-1, 1], and that the Whistle weights \
291 loaded successfully."
292 )]
293 EmbedAudio {
294 detail: String,
296 },
297
298 #[error(
300 "unsupported language {code:?}\n\
301 Whistle transcribes English, German, French, Spanish, Italian, Dutch and Polish.\n\
302 Help: use one of en, de, fr, es, it, nl or pl, or leave the language unset to detect it."
303 )]
304 UnsupportedLanguage {
305 code: String,
307 },
308
309 #[error(
311 "the keyword {keyword:?} contains a line break\n\
312 Keywords reach the engine as one newline-separated list, so a line break inside one \
313 would silently split it in two.\n\
314 Help: pass each word or phrase as its own keyword."
315 )]
316 InvalidKeyword {
317 keyword: String,
319 },
320
321 #[error(
323 "the engine returned an envelope this crate could not parse: {source}\n\
324 Response: {text}\n\
325 Help: this is a bug or an engine newer than the pinned one; please report the response \
326 above."
327 )]
328 Envelope {
329 text: String,
331 #[source]
333 source: serde_json::Error,
334 },
335
336 #[error(
338 "the tool call arguments do not fit the requested type: {source}\n\
339 Help: the model fills arguments from the JSON schema you declared; relax the type, or \
340 tighten the schema so the engine cannot produce this shape."
341 )]
342 Arguments {
343 #[source]
345 source: serde_json::Error,
346 },
347
348 #[error(
350 "the tool declarations could not be serialised: {source}\n\
351 Help: check the `parameters` value of each Tool; it must be plain JSON."
352 )]
353 Tools {
354 #[source]
356 source: serde_json::Error,
357 },
358
359 #[error(
361 "filesystem error: {0}\n\
362 Help: check that the path exists and that this process may read and write it."
363 )]
364 Io(#[from] std::io::Error),
365
366 #[error(
368 "could not download weights from {url}: {detail}\n\
369 Help: retry, or download the archive by hand and point CACTUS_NEEDLE_WEIGHTS (Needle) \
370 or CACTUS_WHISTLE_WEIGHTS (Whistle) at it."
371 )]
372 Download {
373 url: String,
375 detail: String,
377 },
378
379 #[error(
381 "the downloaded weights do not match their pinned checksum\n\
382 Expected sha256 {expected}, got {actual}.\n\
383 Help: the download was corrupted or the remote file changed; retry, and report the \
384 mismatch if it persists."
385 )]
386 ChecksumMismatch {
387 expected: String,
389 actual: String,
391 },
392}
393
394impl Error {
395 #[must_use]
414 pub fn detail(&self) -> Option<&str> {
415 match self {
416 Error::Complete { detail }
417 | Error::Transcribe { detail }
418 | Error::EmbedAudio { detail }
419 | Error::LoadFailed { detail, .. }
420 | Error::InitFailed { detail }
421 | Error::EmbedFailed { detail }
422 | Error::Download { detail, .. } => Some(detail),
423 _ => None,
424 }
425 }
426
427 #[must_use]
439 pub fn envelope(text: &str, source: serde_json::Error) -> Self {
440 let excerpt = match text.char_indices().nth(ENVELOPE_EXCERPT) {
441 Some((cut, _)) => format!("{}... (truncated)", &text[..cut]),
442 None => text.to_owned(),
443 };
444 Error::Envelope {
445 text: excerpt,
446 source,
447 }
448 }
449}
450
451pub type Result<T> = std::result::Result<T, Error>;
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
472 fn test_error_is_send_sync_static() {
473 fn assert_bounds<T: Send + Sync + 'static>() {}
474 assert_bounds::<Error>();
475 }
476
477 const VARIANTS: usize = 26;
479
480 fn variant_index(error: &Error) -> usize {
485 match error {
486 Error::EngineBusy => 0,
487 Error::WeightsAlreadyLoaded => 1,
488 Error::WrongModel { .. } => 2,
489 Error::UnsupportedWeights { .. } => 3,
490 Error::Load => 4,
491 Error::Init => 5,
492 Error::Complete { .. } => 6,
493 Error::Truncated { .. } => 7,
494 Error::Embed => 8,
495 Error::InteriorNul { .. } => 9,
496 Error::SpeechModelNotLoaded => 10,
497 Error::AudioTooLong { .. } => 11,
498 Error::NonFiniteSample { .. } => 12,
499 Error::Transcribe { .. } => 13,
500 Error::EmbedAudio { .. } => 14,
501 Error::UnsupportedLanguage { .. } => 15,
502 Error::InvalidKeyword { .. } => 16,
503 Error::Envelope { .. } => 17,
504 Error::Arguments { .. } => 18,
505 Error::Tools { .. } => 19,
506 Error::Io(_) => 20,
507 Error::Download { .. } => 21,
508 Error::ChecksumMismatch { .. } => 22,
509 Error::LoadFailed { .. } => 23,
510 Error::InitFailed { .. } => 24,
511 Error::EmbedFailed { .. } => 25,
512 }
513 }
514
515 fn samples() -> Vec<Error> {
517 let json = || serde_json::from_str::<serde_json::Value>("{").unwrap_err();
518 vec![
519 Error::EngineBusy,
520 Error::WeightsAlreadyLoaded,
521 Error::WrongModel {
522 expected: Model::Needle,
523 },
524 Error::WrongModel {
525 expected: Model::Whistle,
526 },
527 Error::UnsupportedWeights { tag: 0 },
528 Error::UnsupportedWeights { tag: MAGIC },
529 Error::Load,
530 Error::LoadFailed {
531 model: Model::Whistle,
532 detail: "x".to_owned(),
533 },
534 Error::Init,
535 Error::InitFailed {
536 detail: "x".to_owned(),
537 },
538 Error::Complete {
539 detail: "x".to_owned(),
540 },
541 Error::Truncated { capacity: 1 },
542 Error::Embed,
543 Error::EmbedFailed {
544 detail: "x".to_owned(),
545 },
546 Error::InteriorNul { field: "input" },
547 Error::InteriorNul { field: "keywords" },
548 Error::SpeechModelNotLoaded,
549 Error::AudioTooLong { samples: 480_001 },
550 Error::NonFiniteSample { index: 7 },
551 Error::Transcribe {
552 detail: "x".to_owned(),
553 },
554 Error::EmbedAudio {
555 detail: "x".to_owned(),
556 },
557 Error::UnsupportedLanguage {
558 code: "xx".to_owned(),
559 },
560 Error::InvalidKeyword {
561 keyword: "a\nb".to_owned(),
562 },
563 Error::envelope("{", json()),
564 Error::Arguments { source: json() },
565 Error::Tools { source: json() },
566 Error::Io(std::io::Error::other("x")),
567 Error::Download {
568 url: "u".to_owned(),
569 detail: "d".to_owned(),
570 },
571 Error::ChecksumMismatch {
572 expected: "a".to_owned(),
573 actual: "b".to_owned(),
574 },
575 ]
576 }
577
578 #[test]
579 fn every_message_ends_with_a_help_line() {
580 let samples = samples();
581
582 let mut covered = [false; VARIANTS];
583 for error in &samples {
584 covered[variant_index(error)] = true;
585 }
586 assert!(
587 covered.iter().all(|seen| *seen),
588 "a variant has no sample: {covered:?}"
589 );
590
591 for error in samples {
592 let message = error.to_string();
593 let last = message.lines().last().unwrap_or_default();
594 assert!(last.starts_with("Help: "), "no help line in: {message}");
595 assert!(message.lines().count() >= 2, "one line only: {message}");
597 }
598 }
599
600 #[test]
601 fn detail_is_the_carried_reason_and_nothing_else() {
602 for error in samples() {
603 let carries = matches!(
604 error,
605 Error::Complete { .. }
606 | Error::Transcribe { .. }
607 | Error::EmbedAudio { .. }
608 | Error::LoadFailed { .. }
609 | Error::InitFailed { .. }
610 | Error::EmbedFailed { .. }
611 | Error::Download { .. }
612 );
613 match error.detail() {
614 Some(detail) => {
615 assert!(carries, "unexpected detail on {error:?}");
616 assert!(error.to_string().contains(detail), "{error:?}");
617 }
618 None => assert!(!carries, "no detail on {error:?}"),
619 }
620 }
621 }
622
623 #[test]
624 fn the_failed_variants_carry_the_engines_words() {
625 let load = Error::LoadFailed {
626 model: Model::Needle,
627 detail: "bad tensor table".to_owned(),
628 };
629 assert_eq!(load.detail(), Some("bad tensor table"));
630 assert!(load.to_string().starts_with("the engine rejected the "));
631 assert!(load.to_string().contains("bad tensor table"));
632
633 let init = Error::InitFailed {
634 detail: "needle_init returned -1: 40000 tokens".to_owned(),
635 };
636 assert_eq!(init.detail(), Some("needle_init returned -1: 40000 tokens"));
637
638 let embed = Error::EmbedFailed {
639 detail: "engine wrote 12 floats, expected 3072".to_owned(),
640 };
641 assert_eq!(
642 embed.detail(),
643 Some("engine wrote 12 floats, expected 3072")
644 );
645 for error in [Error::Load, Error::Init, Error::Embed] {
646 assert_eq!(error.detail(), None);
647 }
648 }
649
650 #[test]
651 fn wrong_model_names_both_the_model_and_its_archive() {
652 let message = Error::WrongModel {
653 expected: Model::Whistle,
654 }
655 .to_string();
656 assert!(message.starts_with("the weights are not a Whistle archive"));
657 assert!(message.contains("whistle.cact"));
658 assert!(message.contains("whistle::Weights"));
659 assert!(message.contains("it was not loaded"));
660 assert!(!message.contains("stays loaded"));
661 }
662
663 #[test]
664 fn unsupported_weights_tells_a_bad_tag_from_a_bad_directory() {
665 let tag = Error::UnsupportedWeights { tag: 0x05E1_2A83 }.to_string();
666 assert!(tag.contains("0x05E12A83 is not a Needle 3 or Whistle archive"));
667
668 let directory = Error::UnsupportedWeights { tag: MAGIC }.to_string();
669 assert!(directory.contains("tensor directory"));
670 assert!(!directory.contains("is not a Needle 3"));
671 }
672
673 #[test]
674 fn download_help_names_both_variables() {
675 let message = Error::Download {
676 url: "u".to_owned(),
677 detail: "d".to_owned(),
678 }
679 .to_string();
680 assert!(message.contains("CACTUS_NEEDLE_WEIGHTS"));
681 assert!(message.contains("CACTUS_WHISTLE_WEIGHTS"));
682 }
683
684 #[test]
685 fn envelope_excerpt_is_bounded() {
686 let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
687 let err = Error::envelope(&"a".repeat(4096), source);
688 let Error::Envelope { text, .. } = &err else {
689 panic!("expected an envelope error");
690 };
691 assert!(text.len() < 4096);
692 }
693
694 #[test]
695 fn short_envelopes_are_kept_whole() {
696 let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
697 let err = Error::envelope("{\"type\":\"nope\"", source);
698 let Error::Envelope { text, .. } = &err else {
699 panic!("expected an envelope error");
700 };
701 assert_eq!(text, "{\"type\":\"nope\"");
702 }
703}