1use thiserror::Error;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum ErrorCategory {
18 InvalidInput,
19 UnsupportedCapability,
20 Cancelled,
21 DeadlineExceeded,
22 BusyOverloaded,
23 ModelUnavailable,
24 ArtifactIntegrity,
25 Network,
26 Auth,
27 RateLimit,
28 Quota,
29 Filesystem,
30 DiskFull,
31 Subprocess,
32 NativeInference,
33 Internal,
34 User,
36 Environment,
37 Provider,
38}
39
40impl ErrorCategory {
41 pub fn as_str(self) -> &'static str {
42 match self {
43 Self::InvalidInput => "invalid_input",
44 Self::UnsupportedCapability => "unsupported_capability",
45 Self::Cancelled => "cancelled",
46 Self::DeadlineExceeded => "deadline_exceeded",
47 Self::BusyOverloaded => "busy_overloaded",
48 Self::ModelUnavailable => "model_unavailable",
49 Self::ArtifactIntegrity => "artifact_integrity",
50 Self::Network => "network",
51 Self::Auth => "auth",
52 Self::RateLimit => "rate_limit",
53 Self::Quota => "quota",
54 Self::Filesystem => "filesystem",
55 Self::DiskFull => "disk_full",
56 Self::Subprocess => "subprocess",
57 Self::NativeInference => "native_inference",
58 Self::Internal => "internal",
59 Self::User => "user",
60 Self::Environment => "environment",
61 Self::Provider => "provider",
62 }
63 }
64}
65
66#[derive(Debug, Error)]
70pub enum TranscriptionError {
71 #[error("{0}")]
72 User(#[from] UserError),
73
74 #[error("{0}")]
75 Environment(#[from] EnvironmentError),
76
77 #[error("{0}")]
78 Provider(#[from] ProviderError),
79
80 #[error("internal error: {0}")]
81 Internal(String),
82}
83
84pub type AurumError = TranscriptionError;
86
87#[derive(Debug, Error)]
88pub enum UserError {
89 #[error("audio file not found: {path}\n Hint: check the path and try again.")]
90 FileNotFound { path: String },
91
92 #[error("invalid audio file: {reason}\n Hint: ensure the file is a supported audio format (mp3, m4a, wav, flac, ogg, …).")]
93 InvalidAudio { reason: String },
94
95 #[error(
96 "audio is too long ({duration_secs:.1}s); maximum accepted is {max_secs:.0}s.\n \
97 Hint: split the file, or raise the limit once longer-form support lands."
98 )]
99 AudioTooLong { duration_secs: f64, max_secs: f64 },
100
101 #[error(
102 "decoded audio is too large ({decoded_bytes} bytes); maximum is {max_bytes} bytes.\n \
103 Hint: split the file into shorter clips."
104 )]
105 AudioTooLarge {
106 decoded_bytes: usize,
107 max_bytes: usize,
108 },
109
110 #[error("unsupported output format: {format}\n Hint: use one of: txt, srt, json.")]
111 InvalidOutputFormat { format: String },
112
113 #[error("unknown provider: {provider}\n Hint: use one of: local, openrouter.")]
114 InvalidProvider { provider: String },
115
116 #[error("unknown local model: {model}\n Hint: available models: {available}")]
117 InvalidModel { model: String, available: String },
118
119 #[error(
120 "model '{model}' is not cached and downloads are disabled (local_only).\n \
121 Hint: download it once while online, or pass local_only=false."
122 )]
123 ModelNotCached { model: String },
124
125 #[error(
126 "unsupported PCM sample rate {got} Hz (need {need}).\n \
127 Hint: resample to {need} Hz mono f32 before calling from_pcm."
128 )]
129 UnsupportedSampleRate { got: u32, need: u32 },
130
131 #[error(
132 "OpenRouter API key is missing.\n \
133 Set OPENROUTER_API_KEY in your environment, or add api_key under [openrouter] in the config file.\n \
134 Get a key at https://openrouter.ai/keys"
135 )]
136 MissingApiKey,
137
138 #[error("invalid configuration: {reason}")]
139 InvalidConfig { reason: String },
140
141 #[error("unsupported capability for {provider}/{model}: {reason}\n Hint: {hint}")]
142 UnsupportedCapability {
143 provider: String,
144 model: String,
145 reason: String,
146 hint: String,
147 },
148
149 #[error("{message}")]
150 Other { message: String },
151}
152
153#[derive(Debug, Error)]
154pub enum EnvironmentError {
155 #[error(
156 "ffmpeg is required but was not found on PATH.\n \
157 Install it, then retry:\n \
158 • macOS: brew install ffmpeg\n \
159 • Ubuntu: sudo apt install ffmpeg\n \
160 • Windows: winget install ffmpeg\n \
161 • Or see: https://ffmpeg.org/download.html"
162 )]
163 FfmpegMissing,
164
165 #[error("ffmpeg failed: {reason}")]
166 FfmpegFailed { reason: String },
167
168 #[error("insufficient disk space while writing {path}: {reason}")]
169 DiskSpace { path: String, reason: String },
170
171 #[error("failed to access cache/config directory {path}: {reason}")]
172 DirectoryAccess { path: String, reason: String },
173
174 #[error("I/O error: {0}")]
175 Io(#[from] std::io::Error),
176
177 #[error("{message}")]
178 Other { message: String },
179}
180
181#[derive(Debug, Error)]
182pub enum ProviderError {
183 #[error("failed to load model '{model}': {reason}")]
184 ModelLoad { model: String, reason: String },
185
186 #[error("failed to download model '{model}': {reason}")]
187 ModelDownload { model: String, reason: String },
188
189 #[error("transcription failed: {reason}")]
190 TranscriptionFailed { reason: String },
191
192 #[error("transcription cancelled")]
193 Cancelled,
194
195 #[error("operation deadline exceeded")]
196 DeadlineExceeded,
197
198 #[error("resource overload: {reason}")]
199 Overload { reason: String },
200
201 #[error("network error talking to {provider}: {reason}")]
202 Network { provider: String, reason: String },
203
204 #[error(
205 "rate limited by {provider}.\n \
206 Wait a moment and retry. If this persists, check your plan/quota."
207 )]
208 RateLimited { provider: String },
209
210 #[error(
211 "quota or billing issue with {provider}: {reason}\n \
212 Check your account balance and plan limits."
213 )]
214 QuotaExceeded { provider: String, reason: String },
215
216 #[error("authentication failed for {provider}: {reason}")]
217 Auth { provider: String, reason: String },
218
219 #[error("{provider} returned an error: {reason}")]
220 Remote { provider: String, reason: String },
221
222 #[error("{provider} response too large: {reason}")]
223 ResponseTooLarge { provider: String, reason: String },
224
225 #[error("{provider} returned an invalid payload: {reason}")]
226 InvalidProviderPayload { provider: String, reason: String },
227
228 #[error("limit exceeded: {reason}")]
229 LimitExceeded { reason: String },
230
231 #[error("{message}")]
232 Other { message: String },
233}
234
235impl TranscriptionError {
236 pub fn category(&self) -> &'static str {
238 match self {
239 Self::User(_) => "user",
240 Self::Environment(_) => "environment",
241 Self::Provider(_) => "provider",
242 Self::Internal(_) => "internal",
243 }
244 }
245
246 pub fn error_category(&self) -> ErrorCategory {
248 match self {
249 Self::User(u) => match u {
250 UserError::UnsupportedCapability { .. } => ErrorCategory::UnsupportedCapability,
251 UserError::ModelNotCached { .. } | UserError::InvalidModel { .. } => {
252 ErrorCategory::ModelUnavailable
253 }
254 UserError::MissingApiKey => ErrorCategory::Auth,
255 UserError::InvalidConfig { .. }
256 | UserError::InvalidProvider { .. }
257 | UserError::InvalidOutputFormat { .. }
258 | UserError::FileNotFound { .. }
259 | UserError::InvalidAudio { .. }
260 | UserError::AudioTooLong { .. }
261 | UserError::AudioTooLarge { .. }
262 | UserError::UnsupportedSampleRate { .. }
263 | UserError::Other { .. } => ErrorCategory::InvalidInput,
264 },
265 Self::Environment(e) => match e {
266 EnvironmentError::DiskSpace { .. } => ErrorCategory::DiskFull,
267 EnvironmentError::FfmpegMissing | EnvironmentError::FfmpegFailed { .. } => {
268 ErrorCategory::Subprocess
269 }
270 EnvironmentError::DirectoryAccess { .. } | EnvironmentError::Io(_) => {
271 ErrorCategory::Filesystem
272 }
273 EnvironmentError::Other { .. } => ErrorCategory::Environment,
274 },
275 Self::Provider(p) => match p {
276 ProviderError::Cancelled => ErrorCategory::Cancelled,
277 ProviderError::DeadlineExceeded => ErrorCategory::DeadlineExceeded,
278 ProviderError::Overload { .. } => ErrorCategory::BusyOverloaded,
279 ProviderError::Network { .. } => ErrorCategory::Network,
280 ProviderError::Auth { .. } => ErrorCategory::Auth,
281 ProviderError::RateLimited { .. } => ErrorCategory::RateLimit,
282 ProviderError::QuotaExceeded { .. } => ErrorCategory::Quota,
283 ProviderError::ModelLoad { .. } | ProviderError::ModelDownload { .. } => {
284 ErrorCategory::ModelUnavailable
285 }
286 ProviderError::TranscriptionFailed { .. } => ErrorCategory::NativeInference,
287 ProviderError::InvalidProviderPayload { .. }
288 | ProviderError::ResponseTooLarge { .. }
289 | ProviderError::LimitExceeded { .. }
290 | ProviderError::Remote { .. }
291 | ProviderError::Other { .. } => ErrorCategory::Provider,
292 },
293 Self::Internal(_) => ErrorCategory::Internal,
294 }
295 }
296
297 pub fn retryable(&self) -> bool {
299 matches!(
300 self.error_category(),
301 ErrorCategory::Network
302 | ErrorCategory::RateLimit
303 | ErrorCategory::BusyOverloaded
304 | ErrorCategory::DeadlineExceeded
305 )
306 }
307
308 pub fn exit_code(&self) -> i32 {
313 match self.error_category() {
314 ErrorCategory::Internal => 1,
315 ErrorCategory::Cancelled => 5,
316 ErrorCategory::DeadlineExceeded => 6,
317 ErrorCategory::BusyOverloaded => 7,
318 ErrorCategory::User
319 | ErrorCategory::InvalidInput
320 | ErrorCategory::UnsupportedCapability
321 | ErrorCategory::Auth
322 | ErrorCategory::ModelUnavailable
323 | ErrorCategory::ArtifactIntegrity => 2,
324 ErrorCategory::Environment
325 | ErrorCategory::Filesystem
326 | ErrorCategory::DiskFull
327 | ErrorCategory::Subprocess => 3,
328 ErrorCategory::Provider
329 | ErrorCategory::Network
330 | ErrorCategory::RateLimit
331 | ErrorCategory::Quota
332 | ErrorCategory::NativeInference => 4,
333 }
334 }
335
336 pub fn internal(msg: impl Into<String>) -> Self {
337 Self::Internal(msg.into())
338 }
339}
340
341impl From<std::io::Error> for TranscriptionError {
342 fn from(value: std::io::Error) -> Self {
343 Self::Environment(EnvironmentError::Io(value))
344 }
345}
346
347pub type Result<T> = std::result::Result<T, TranscriptionError>;