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