af-llm 0.5.0

Unified async LLM client with timeout and circuit breaking for OpenAI-compatible endpoints.
Documentation
//! Error type for the LLM client.

use thiserror::Error;

/// Everything that can go wrong on the way to a completion.
///
/// Mirrors the failure modes of the Python `llm_completion` wrapper, but where
/// that returned `None` and swallowed the cause, we keep a typed error so the
/// caller decides how to degrade.
#[derive(Debug, Error)]
pub enum LlmError {
    /// Caller cancelled the provider attempt.
    #[error("llm request cancelled")]
    Cancelled,

    /// Caller deadline elapsed before the provider attempt settled.
    #[error("llm request deadline exceeded")]
    DeadlineExceeded,
    /// Circuit breaker is open — we fail fast without hitting the provider.
    #[error("llm circuit breaker is open; failing fast")]
    CircuitOpen,

    /// Transport-level failure (connection, TLS, timeout).
    #[error("llm transport error: {0}")]
    Transport(#[from] reqwest::Error),

    /// Provider returned a non-2xx status.
    #[error("llm api error: status {status}, body: {body}")]
    Api {
        /// HTTP status returned.
        status: u16,
        /// Response body, truncated for logs.
        body: String,
    },

    /// Response body could not be decoded into the expected shape.
    #[error("llm response decode error: {0}")]
    Decode(#[from] serde_json::Error),

    /// A streaming response violated the SSE completion contract.
    #[error("llm stream protocol error: {0}")]
    StreamProtocol(String),

    /// A streaming response contained bytes that were not valid UTF-8.
    #[error("llm stream utf-8 error: {0}")]
    InvalidUtf8(#[from] std::string::FromUtf8Error),
}

impl LlmError {
    /// Whether retrying this error class could plausibly succeed.
    ///
    /// A 4xx (except 408/429) is the caller's fault and will fail identically on
    /// retry; transport errors and 5xx/408/429 are worth another attempt.
    pub fn is_retryable(&self) -> bool {
        match self {
            LlmError::Transport(_) | LlmError::StreamProtocol(_) | LlmError::InvalidUtf8(_) => true,
            LlmError::Api { status, .. } => {
                *status == 408 || *status == 429 || (500..600).contains(status)
            }
            // Circuit-open and decode failures are not improved by retry.
            LlmError::CircuitOpen
            | LlmError::Decode(_)
            | LlmError::Cancelled
            | LlmError::DeadlineExceeded => false,
        }
    }
}

/// Result alias for this crate's error.
pub type Result<T> = std::result::Result<T, LlmError>;