1use std::time::Duration;
21
22use thiserror::Error;
23use uuid::Uuid;
24
25use systemprompt_database::resilience::Outcome;
26use systemprompt_identifiers::{AiRequestId, McpServerId};
27
28#[derive(Debug, Error)]
29pub enum AiError {
30 #[error("Model not specified and no default available for provider {provider}")]
31 ModelNotSpecified { provider: String },
32
33 #[error("Request metadata missing required field: {field}")]
34 MissingMetadata { field: String },
35
36 #[error("User context required for billing and audit trails")]
37 MissingUserContext,
38
39 #[error("Provider {provider} returned empty response")]
40 EmptyProviderResponse { provider: String },
41
42 #[error("Tool call schema validation failed: {reason}")]
43 InvalidToolSchema { reason: String },
44
45 #[error("Authentication required for service {service_id}")]
46 AuthenticationRequired { service_id: McpServerId },
47
48 #[error("Structured output validation failed after {retries} attempts: {details}")]
49 StructuredOutputFailed { retries: usize, details: String },
50
51 #[error("Provider {provider} error: {message}")]
52 ProviderError { provider: String, message: String },
53
54 #[error("No configured provider supports model {model}")]
55 NoProviderForModel { model: String },
56
57 #[error("Upstream provider could not be resolved: {0}")]
58 Upstream(#[from] crate::services::upstream::UpstreamTargetError),
59
60 #[error("Serialization failed: {0}")]
61 SerializationError(#[from] serde_json::Error),
62
63 #[error("HTTP request failed: {0}")]
64 Http(#[from] reqwest::Error),
65
66 #[error("I/O error: {0}")]
67 Io(#[from] std::io::Error),
68
69 #[error("Message history cannot be serialized to JSON")]
70 MessageSerializationFailed,
71
72 #[error("Tool {tool_name} missing required field: {field}")]
73 MissingToolField { tool_name: String, field: String },
74
75 #[error("Tool description cannot be empty for tool: {tool_name}")]
76 EmptyToolDescription { tool_name: String },
77
78 #[error("No tool calls found in provider response")]
79 NoToolCalls,
80
81 #[error("Rate limit exceeded for provider {provider}: {details}")]
82 RateLimit { provider: String, details: String },
83
84 #[error("Provider {provider} returned HTTP {status}: {body}")]
85 HttpStatus {
86 provider: String,
87 status: u16,
88 retry_after: Option<Duration>,
89 body: String,
90 },
91
92 #[error("Provider {provider} request timed out after {after_ms}ms")]
93 Timeout { provider: String, after_ms: u64 },
94
95 #[error("Circuit breaker open for provider {provider}; failing fast")]
96 CircuitOpen { provider: String },
97
98 #[error("Provider {provider} unavailable: concurrency limit reached")]
99 DependencyUnavailable { provider: String },
100
101 #[error("Invalid API credentials for provider {provider}")]
102 AuthenticationFailed { provider: String },
103
104 #[error("Configuration error: {message}")]
105 ConfigurationError { message: String },
106
107 #[error("Database operation failed: {message}")]
108 DatabaseError { message: String },
109
110 #[error("MCP service {service_id} not found or not configured")]
111 McpServiceNotFound { service_id: McpServerId },
112
113 #[error("MCP service {service_id} requires OAuth authentication but no token available")]
114 McpAuthenticationMissing { service_id: McpServerId },
115
116 #[error("Failed to determine service authentication requirements: {details}")]
117 ServiceAuthCheckFailed { details: String },
118
119 #[error("Storage operation failed: {message}")]
120 StorageError { message: String },
121
122 #[error("Invalid input: {0}")]
123 InvalidInput(String),
124
125 #[error("Regex error: {0}")]
126 Regex(#[from] regex::Error),
127
128 #[error(transparent)]
129 ToolProvider(#[from] systemprompt_traits::ToolProviderError),
130
131 #[error("tool discovery incomplete: {0}")]
132 ToolDiscovery(String),
133
134 #[error("provider {provider} has no pricing for model {model}; refusing to bill it")]
135 UnknownModel { provider: String, model: String },
136
137 #[error(transparent)]
138 Secrets(#[from] systemprompt_config::SecretsBootstrapError),
139
140 #[error(transparent)]
141 WireParse(#[from] systemprompt_models::wire::error::WireParseError),
142
143 #[error("internal: {0}")]
144 Internal(String),
145}
146
147impl From<AiError> for systemprompt_models::errors::AiInferenceError {
148 fn from(err: AiError) -> Self {
149 match err {
150 AiError::ModelNotSpecified { ref provider }
151 | AiError::EmptyProviderResponse { ref provider } => Self::Provider {
152 provider: provider.clone(),
153 message: err.to_string(),
154 },
155 AiError::ProviderError { provider, message } => Self::Provider { provider, message },
156 AiError::NoProviderForModel { model } => Self::NoProviderForModel { model },
157 AiError::RateLimit { provider, details } => Self::RateLimited { provider, details },
158 AiError::AuthenticationFailed { provider } => Self::AuthenticationFailed { provider },
159 AiError::HttpStatus { ref provider, .. }
160 | AiError::Timeout { ref provider, .. }
161 | AiError::CircuitOpen { ref provider }
162 | AiError::DependencyUnavailable { ref provider } => Self::Unavailable {
163 provider: provider.clone(),
164 message: err.to_string(),
165 },
166 AiError::MissingMetadata { .. }
167 | AiError::MissingUserContext
168 | AiError::InvalidToolSchema { .. }
169 | AiError::StructuredOutputFailed { .. }
170 | AiError::MessageSerializationFailed
171 | AiError::MissingToolField { .. }
172 | AiError::EmptyToolDescription { .. }
173 | AiError::InvalidInput(_)
174 | AiError::WireParse(_) => Self::InvalidRequest(err.to_string()),
175 AiError::NoToolCalls
176 | AiError::McpServiceNotFound { .. }
177 | AiError::McpAuthenticationMissing { .. }
178 | AiError::ServiceAuthCheckFailed { .. }
179 | AiError::ToolDiscovery(_)
180 | AiError::ToolProvider(_) => Self::Tool(err.to_string()),
181 AiError::UnknownModel { .. }
182 | AiError::AuthenticationRequired { .. }
183 | AiError::ConfigurationError { .. }
184 | AiError::Secrets(_)
185 | AiError::Upstream(_) => Self::Configuration(err.to_string()),
186 AiError::DatabaseError { .. } | AiError::StorageError { .. } => {
187 Self::Storage(err.to_string())
188 },
189 AiError::SerializationError(_)
190 | AiError::Http(_)
191 | AiError::Io(_)
192 | AiError::Regex(_)
193 | AiError::Internal(_) => Self::Internal(err.to_string()),
194 }
195 }
196}
197
198#[derive(Debug, Error)]
199pub enum RepositoryError {
200 #[error("AI request not found: {0}")]
201 NotFound(Uuid),
202
203 #[error("Database error: {0}")]
204 Database(#[from] sqlx::Error),
205
206 #[error("Invalid data: {field} - {reason}")]
207 InvalidData { field: String, reason: String },
208
209 #[error("Database pool initialization failed: {0}")]
210 PoolInitialization(String),
211
212 #[error("Settlement of AI request {request_id} rejected: {reason}")]
213 SettlementConflict {
214 request_id: AiRequestId,
215 reason: String,
216 },
217
218 #[error("AI request {0} already exists")]
219 AlreadyExists(AiRequestId),
220}
221
222impl AiError {
223 pub async fn from_error_response(provider: &str, response: reqwest::Response) -> Self {
224 let status = response.status().as_u16();
225 let retry_after = parse_retry_after(response.headers());
226 let body = response
227 .text()
228 .await
229 .unwrap_or_else(|e| format!("<unreadable body: {e}>"));
230 Self::HttpStatus {
231 provider: provider.to_owned(),
232 status,
233 retry_after,
234 body,
235 }
236 }
237
238 #[must_use]
239 pub fn classify(&self) -> Outcome {
240 match self {
241 Self::HttpStatus {
242 status,
243 retry_after,
244 ..
245 } => {
246 if matches!(*status, 408 | 425 | 429 | 500 | 502 | 503 | 504) {
247 Outcome::Transient {
248 retry_after: *retry_after,
249 }
250 } else {
251 Outcome::Permanent
252 }
253 },
254 Self::RateLimit { .. } | Self::Timeout { .. } => {
255 Outcome::Transient { retry_after: None }
256 },
257 Self::Http(err) if err.is_timeout() || err.is_connect() => {
258 Outcome::Transient { retry_after: None }
259 },
260 _ => Outcome::Permanent,
261 }
262 }
263}
264
265fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
266 headers
267 .get(reqwest::header::RETRY_AFTER)?
268 .to_str()
269 .ok()?
270 .trim()
271 .parse::<u64>()
272 .ok()
273 .map(Duration::from_secs)
274}
275
276pub type Result<T> = std::result::Result<T, AiError>;
277
278impl From<RepositoryError> for AiError {
279 fn from(error: RepositoryError) -> Self {
280 Self::DatabaseError {
281 message: error.to_string(),
282 }
283 }
284}