use thiserror::Error as ThisError;
use crate::model::Model;
const ENVELOPE_EXCERPT: usize = 200;
#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum Error {
#[error(
"the engine slot for this model is already in use by another instance in this process\n\
The engine is one process-global runtime with no handles that holds one model per \
kind, so one Needle and one Whistle can exist at a time, not two of either.\n\
Help: drop the existing instance before building another of the same model, or share \
it behind a mutex."
)]
EngineBusy,
#[error(
"different weights of the same kind are already loaded in this process\n\
The engine copies the archive on the first load of each kind and offers no way to \
unload it.\n\
Help: build every instance of a model from the same weights, or run the second archive \
in its own process."
)]
WeightsAlreadyLoaded,
#[error(
"the weights are not a {expected} archive\n\
Needle 3 and Whistle archives share one magic tag, and the engine read this one as the \
other model; it stays loaded as that model for the life of the process.\n\
Help: build a {expected} from {}, and hand this archive to the other model's builder.",
.expected.archive()
)]
WrongModel {
expected: Model,
},
#[error(
"unsupported weights: magic tag {tag:#010X} is not a Needle 3 or Whistle archive\n\
Needle 3 and Whistle archives start with the little-endian tag 0x05E12A84; 0x05E12A83 \
is a Needle 2 archive, which the linked engine cannot read.\n\
Help: download needle3.cact from Cactus-Compute/needle3 or whistle.cact from \
Cactus-Compute/whistle, or call Weights::fetch()."
)]
UnsupportedWeights {
tag: u32,
},
#[error(
"the engine rejected the weight archive\n\
The magic tag was right but the engine could not load the bytes as a Needle 3 or \
Whistle model, which usually means a truncated or corrupted download.\n\
Help: delete the cached archive and fetch it again, or verify its SHA-256."
)]
Load,
#[error(
"the engine could not initialise the conversation prefix\n\
needle_init returned a non-positive token count, which happens when the weights are not \
loaded or the tool index path does not exist.\n\
Help: check that the tool index path exists and that the weights loaded successfully."
)]
Init,
#[error(
"the engine failed to complete the request: {detail}\n\
Help: reset the conversation, shorten the input, or raise max_new_tokens."
)]
Complete {
detail: String,
},
#[error(
"the engine truncated its response to fill the {capacity} byte output buffer\n\
The engine truncates without reporting it, so the JSON envelope is incomplete.\n\
Help: lower max_new_tokens, ask for fewer tool calls in one turn, or turn word \
timestamps off."
)]
Truncated {
capacity: usize,
},
#[error(
"the engine failed to embed the input\n\
needle_embed returned a negative value, which happens when no weights are loaded.\n\
Help: build the Needle from valid weights before embedding."
)]
Embed,
#[error(
"the {field} contains an interior NUL byte\n\
The engine takes NUL-terminated C strings, so a NUL inside the text would silently cut \
it short.\n\
Help: strip '\\0' from the {field} before passing it in."
)]
InteriorNul {
field: &'static str,
},
#[error(
"no speech model is loaded in this process\n\
An audio completion transcribes the clip with Whistle before Needle answers it, and \
the engine holds no Whistle weights yet.\n\
Help: build a Whistle once in this process before completing audio; its weights stay \
loaded after it is dropped."
)]
SpeechModelNotLoaded,
#[error(
"the clip is {samples} samples long, more than the 480000 the engine accepts\n\
The engine takes at most 30 seconds of 16 kHz mono audio in one call.\n\
Help: split the clip into chunks of at most 30 seconds and send them one at a time."
)]
AudioTooLong {
samples: usize,
},
#[error(
"sample {index} of the clip is not a finite number\n\
The engine expects 16 kHz mono PCM in [-1, 1], and a NaN or an infinity would poison \
every frame it touches.\n\
Help: check the decoder or resampler that produced the clip, or replace non-finite \
samples with 0.0."
)]
NonFiniteSample {
index: usize,
},
#[error(
"the engine failed to transcribe the clip: {detail}\n\
Help: check that the clip is 16 kHz mono PCM in [-1, 1], and that the Whistle weights \
loaded successfully."
)]
Transcribe {
detail: String,
},
#[error(
"the engine failed to embed the clip: {detail}\n\
Help: check that the clip is 16 kHz mono PCM in [-1, 1], and that the Whistle weights \
loaded successfully."
)]
EmbedAudio {
detail: String,
},
#[error(
"unsupported language {code:?}\n\
Whistle transcribes English, German, French, Spanish, Italian, Dutch and Polish.\n\
Help: use one of en, de, fr, es, it, nl or pl, or leave the language unset to detect it."
)]
UnsupportedLanguage {
code: String,
},
#[error(
"the keyword {keyword:?} contains a line break\n\
Keywords reach the engine as one newline-separated list, so a line break inside one \
would silently split it in two.\n\
Help: pass each word or phrase as its own keyword."
)]
InvalidKeyword {
keyword: String,
},
#[error(
"the engine returned an envelope this crate could not parse: {source}\n\
Response: {text}\n\
Help: this is a bug or an engine newer than the pinned one; please report the response \
above."
)]
Envelope {
text: String,
#[source]
source: serde_json::Error,
},
#[error(
"the tool call arguments do not fit the requested type: {source}\n\
Help: the model fills arguments from the JSON schema you declared; relax the type, or \
tighten the schema so the engine cannot produce this shape."
)]
Arguments {
#[source]
source: serde_json::Error,
},
#[error(
"the tool declarations could not be serialised: {source}\n\
Help: check the `parameters` value of each Tool; it must be plain JSON."
)]
Tools {
#[source]
source: serde_json::Error,
},
#[error(
"filesystem error: {0}\n\
Help: check that the path exists and that this process may read and write it."
)]
Io(#[from] std::io::Error),
#[error(
"could not download weights from {url}: {detail}\n\
Help: retry, or download the archive by hand and point CACTUS_NEEDLE_WEIGHTS (Needle) \
or CACTUS_WHISTLE_WEIGHTS (Whistle) at it."
)]
Download {
url: String,
detail: String,
},
#[error(
"the downloaded weights do not match their pinned checksum\n\
Expected sha256 {expected}, got {actual}.\n\
Help: the download was corrupted or the remote file changed; retry, and report the \
mismatch if it persists."
)]
ChecksumMismatch {
expected: String,
actual: String,
},
}
impl Error {
#[must_use]
pub fn envelope(text: &str, source: serde_json::Error) -> Self {
let excerpt = match text.char_indices().nth(ENVELOPE_EXCERPT) {
Some((cut, _)) => format!("{}... (truncated)", &text[..cut]),
None => text.to_owned(),
};
Error::Envelope {
text: excerpt,
source,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_is_send_sync_static() {
fn assert_bounds<T: Send + Sync + 'static>() {}
assert_bounds::<Error>();
}
const VARIANTS: usize = 23;
fn variant_index(error: &Error) -> usize {
match error {
Error::EngineBusy => 0,
Error::WeightsAlreadyLoaded => 1,
Error::WrongModel { .. } => 2,
Error::UnsupportedWeights { .. } => 3,
Error::Load => 4,
Error::Init => 5,
Error::Complete { .. } => 6,
Error::Truncated { .. } => 7,
Error::Embed => 8,
Error::InteriorNul { .. } => 9,
Error::SpeechModelNotLoaded => 10,
Error::AudioTooLong { .. } => 11,
Error::NonFiniteSample { .. } => 12,
Error::Transcribe { .. } => 13,
Error::EmbedAudio { .. } => 14,
Error::UnsupportedLanguage { .. } => 15,
Error::InvalidKeyword { .. } => 16,
Error::Envelope { .. } => 17,
Error::Arguments { .. } => 18,
Error::Tools { .. } => 19,
Error::Io(_) => 20,
Error::Download { .. } => 21,
Error::ChecksumMismatch { .. } => 22,
}
}
fn samples() -> Vec<Error> {
let json = || serde_json::from_str::<serde_json::Value>("{").unwrap_err();
vec![
Error::EngineBusy,
Error::WeightsAlreadyLoaded,
Error::WrongModel {
expected: Model::Needle,
},
Error::WrongModel {
expected: Model::Whistle,
},
Error::UnsupportedWeights { tag: 0 },
Error::Load,
Error::Init,
Error::Complete {
detail: "x".to_owned(),
},
Error::Truncated { capacity: 1 },
Error::Embed,
Error::InteriorNul { field: "input" },
Error::InteriorNul { field: "keywords" },
Error::SpeechModelNotLoaded,
Error::AudioTooLong { samples: 480_001 },
Error::NonFiniteSample { index: 7 },
Error::Transcribe {
detail: "x".to_owned(),
},
Error::EmbedAudio {
detail: "x".to_owned(),
},
Error::UnsupportedLanguage {
code: "xx".to_owned(),
},
Error::InvalidKeyword {
keyword: "a\nb".to_owned(),
},
Error::envelope("{", json()),
Error::Arguments { source: json() },
Error::Tools { source: json() },
Error::Io(std::io::Error::other("x")),
Error::Download {
url: "u".to_owned(),
detail: "d".to_owned(),
},
Error::ChecksumMismatch {
expected: "a".to_owned(),
actual: "b".to_owned(),
},
]
}
#[test]
fn every_message_ends_with_a_help_line() {
let samples = samples();
let mut covered = [false; VARIANTS];
for error in &samples {
covered[variant_index(error)] = true;
}
assert!(
covered.iter().all(|seen| *seen),
"a variant has no sample: {covered:?}"
);
for error in samples {
let message = error.to_string();
let last = message.lines().last().unwrap_or_default();
assert!(last.starts_with("Help: "), "no help line in: {message}");
assert!(message.lines().count() >= 2, "one line only: {message}");
}
}
#[test]
fn wrong_model_names_both_the_model_and_its_archive() {
let message = Error::WrongModel {
expected: Model::Whistle,
}
.to_string();
assert!(message.starts_with("the weights are not a Whistle archive"));
assert!(message.contains("whistle.cact"));
}
#[test]
fn download_help_names_both_variables() {
let message = Error::Download {
url: "u".to_owned(),
detail: "d".to_owned(),
}
.to_string();
assert!(message.contains("CACTUS_NEEDLE_WEIGHTS"));
assert!(message.contains("CACTUS_WHISTLE_WEIGHTS"));
}
#[test]
fn envelope_excerpt_is_bounded() {
let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let err = Error::envelope(&"a".repeat(4096), source);
let Error::Envelope { text, .. } = &err else {
panic!("expected an envelope error");
};
assert!(text.len() < 4096);
}
#[test]
fn short_envelopes_are_kept_whole() {
let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let err = Error::envelope("{\"type\":\"nope\"", source);
let Error::Envelope { text, .. } = &err else {
panic!("expected an envelope error");
};
assert_eq!(text, "{\"type\":\"nope\"");
}
}