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