systemprompt-ai 0.62.0

Provider-agnostic LLM integration for systemprompt.io AI governance — Anthropic, OpenAI, Gemini, and local models unified behind one governed pipeline with cost tracking and audit.
Documentation
//! Typed error hierarchy for the [`systemprompt-ai`](crate) crate.
//!
//! Two error families live here:
//!
//! - [`AiError`] — the top-level public error returned by [`crate::services`].
//!   It composes repository-level failures ([`RepositoryError`]) via `#[from]`,
//!   plus common transport / parsing errors ([`reqwest::Error`],
//!   [`serde_json::Error`], [`sqlx::Error`]).
//! - [`RepositoryError`] — the persistence-layer error returned by every
//!   `*Repository` type in [`crate::repository`].
//!
//! All public service signatures use [`Result<T>`] (i.e. `Result<T, AiError>`).
//! The dyn `AiProvider` seam returns
//! [`AiInferenceError`](systemprompt_models::errors::AiInferenceError); the
//! `From<AiError>` impl below is the single mapping onto it.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use std::time::Duration;

use thiserror::Error;
use uuid::Uuid;

use systemprompt_database::resilience::Outcome;
use systemprompt_identifiers::{AiRequestId, McpServerId};

#[derive(Debug, Error)]
pub enum AiError {
    #[error("Model not specified and no default available for provider {provider}")]
    ModelNotSpecified { provider: String },

    #[error("Request metadata missing required field: {field}")]
    MissingMetadata { field: String },

    #[error("User context required for billing and audit trails")]
    MissingUserContext,

    #[error("Provider {provider} returned empty response")]
    EmptyProviderResponse { provider: String },

    #[error("Tool call schema validation failed: {reason}")]
    InvalidToolSchema { reason: String },

    #[error("Authentication required for service {service_id}")]
    AuthenticationRequired { service_id: McpServerId },

    #[error("Structured output validation failed after {retries} attempts: {details}")]
    StructuredOutputFailed { retries: usize, details: String },

    #[error("Provider {provider} error: {message}")]
    ProviderError { provider: String, message: String },

    #[error("No configured provider supports model {model}")]
    NoProviderForModel { model: String },

    #[error("Upstream provider could not be resolved: {0}")]
    Upstream(#[from] crate::services::upstream::UpstreamTargetError),

    #[error("Serialization failed: {0}")]
    SerializationError(#[from] serde_json::Error),

    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Message history cannot be serialized to JSON")]
    MessageSerializationFailed,

    #[error("Tool {tool_name} missing required field: {field}")]
    MissingToolField { tool_name: String, field: String },

    #[error("Tool description cannot be empty for tool: {tool_name}")]
    EmptyToolDescription { tool_name: String },

    #[error("No tool calls found in provider response")]
    NoToolCalls,

    #[error("Rate limit exceeded for provider {provider}: {details}")]
    RateLimit { provider: String, details: String },

    #[error("Provider {provider} returned HTTP {status}: {body}")]
    HttpStatus {
        provider: String,
        status: u16,
        retry_after: Option<Duration>,
        body: String,
    },

    #[error("Provider {provider} request timed out after {after_ms}ms")]
    Timeout { provider: String, after_ms: u64 },

    #[error("Circuit breaker open for provider {provider}; failing fast")]
    CircuitOpen { provider: String },

    #[error("Provider {provider} unavailable: concurrency limit reached")]
    DependencyUnavailable { provider: String },

    #[error("Invalid API credentials for provider {provider}")]
    AuthenticationFailed { provider: String },

    #[error("Configuration error: {message}")]
    ConfigurationError { message: String },

    #[error("Database operation failed: {message}")]
    DatabaseError { message: String },

    #[error("MCP service {service_id} not found or not configured")]
    McpServiceNotFound { service_id: McpServerId },

    #[error("MCP service {service_id} requires OAuth authentication but no token available")]
    McpAuthenticationMissing { service_id: McpServerId },

    #[error("Failed to determine service authentication requirements: {details}")]
    ServiceAuthCheckFailed { details: String },

    #[error("Storage operation failed: {message}")]
    StorageError { message: String },

    #[error("Invalid input: {0}")]
    InvalidInput(String),

    #[error("Regex error: {0}")]
    Regex(#[from] regex::Error),

    #[error(transparent)]
    ToolProvider(#[from] systemprompt_traits::ToolProviderError),

    #[error("tool discovery incomplete: {0}")]
    ToolDiscovery(String),

    #[error("provider {provider} has no pricing for model {model}; refusing to bill it")]
    UnknownModel { provider: String, model: String },

    #[error(transparent)]
    Secrets(#[from] systemprompt_config::SecretsBootstrapError),

    #[error(transparent)]
    WireParse(#[from] systemprompt_models::wire::error::WireParseError),

    #[error("internal: {0}")]
    Internal(String),
}

impl From<AiError> for systemprompt_models::errors::AiInferenceError {
    fn from(err: AiError) -> Self {
        match err {
            AiError::ModelNotSpecified { ref provider }
            | AiError::EmptyProviderResponse { ref provider } => Self::Provider {
                provider: provider.clone(),
                message: err.to_string(),
            },
            AiError::ProviderError { provider, message } => Self::Provider { provider, message },
            AiError::NoProviderForModel { model } => Self::NoProviderForModel { model },
            AiError::RateLimit { provider, details } => Self::RateLimited { provider, details },
            AiError::AuthenticationFailed { provider } => Self::AuthenticationFailed { provider },
            AiError::HttpStatus { ref provider, .. }
            | AiError::Timeout { ref provider, .. }
            | AiError::CircuitOpen { ref provider }
            | AiError::DependencyUnavailable { ref provider } => Self::Unavailable {
                provider: provider.clone(),
                message: err.to_string(),
            },
            AiError::MissingMetadata { .. }
            | AiError::MissingUserContext
            | AiError::InvalidToolSchema { .. }
            | AiError::StructuredOutputFailed { .. }
            | AiError::MessageSerializationFailed
            | AiError::MissingToolField { .. }
            | AiError::EmptyToolDescription { .. }
            | AiError::InvalidInput(_)
            | AiError::WireParse(_) => Self::InvalidRequest(err.to_string()),
            AiError::NoToolCalls
            | AiError::McpServiceNotFound { .. }
            | AiError::McpAuthenticationMissing { .. }
            | AiError::ServiceAuthCheckFailed { .. }
            | AiError::ToolDiscovery(_)
            | AiError::ToolProvider(_) => Self::Tool(err.to_string()),
            AiError::UnknownModel { .. }
            | AiError::AuthenticationRequired { .. }
            | AiError::ConfigurationError { .. }
            | AiError::Secrets(_)
            | AiError::Upstream(_) => Self::Configuration(err.to_string()),
            AiError::DatabaseError { .. } | AiError::StorageError { .. } => {
                Self::Storage(err.to_string())
            },
            AiError::SerializationError(_)
            | AiError::Http(_)
            | AiError::Io(_)
            | AiError::Regex(_)
            | AiError::Internal(_) => Self::Internal(err.to_string()),
        }
    }
}

#[derive(Debug, Error)]
pub enum RepositoryError {
    #[error("AI request not found: {0}")]
    NotFound(Uuid),

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Invalid data: {field} - {reason}")]
    InvalidData { field: String, reason: String },

    #[error("Database pool initialization failed: {0}")]
    PoolInitialization(String),

    #[error("Settlement of AI request {request_id} rejected: {reason}")]
    SettlementConflict {
        request_id: AiRequestId,
        reason: String,
    },

    #[error("AI request {0} already exists")]
    AlreadyExists(AiRequestId),
}

impl AiError {
    pub async fn from_error_response(provider: &str, response: reqwest::Response) -> Self {
        let status = response.status().as_u16();
        let retry_after = parse_retry_after(response.headers());
        let body = response
            .text()
            .await
            .unwrap_or_else(|e| format!("<unreadable body: {e}>"));
        Self::HttpStatus {
            provider: provider.to_owned(),
            status,
            retry_after,
            body,
        }
    }

    #[must_use]
    pub fn classify(&self) -> Outcome {
        match self {
            Self::HttpStatus {
                status,
                retry_after,
                ..
            } => {
                if matches!(*status, 408 | 425 | 429 | 500 | 502 | 503 | 504) {
                    Outcome::Transient {
                        retry_after: *retry_after,
                    }
                } else {
                    Outcome::Permanent
                }
            },
            Self::RateLimit { .. } | Self::Timeout { .. } => {
                Outcome::Transient { retry_after: None }
            },
            Self::Http(err) if err.is_timeout() || err.is_connect() => {
                Outcome::Transient { retry_after: None }
            },
            _ => Outcome::Permanent,
        }
    }
}

fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
    headers
        .get(reqwest::header::RETRY_AFTER)?
        .to_str()
        .ok()?
        .trim()
        .parse::<u64>()
        .ok()
        .map(Duration::from_secs)
}

pub type Result<T> = std::result::Result<T, AiError>;

impl From<RepositoryError> for AiError {
    fn from(error: RepositoryError) -> Self {
        Self::DatabaseError {
            message: error.to_string(),
        }
    }
}