polyc-llm 0.1.3

Provider-agnostic LLM trait + wire types for polychrome.
Documentation
//! `LlmError` marker trait and reference [`DummyError`] implementation.
//!
//! Every `LlmProvider::Error` associated type must satisfy the [`LlmError`]
//! bound, which is equivalent to
//! `std::error::Error + Send + Sync + 'static` but named so it is
//! grep-able and can grow cross-provider extension methods without
//! breaking changes.

/// Marker trait that every `LlmProvider::Error` must satisfy.
///
/// Equivalent to `std::error::Error + Send + Sync + 'static`, written as
/// its own trait so it is:
///   1. searchable in the codebase (grep for `LlmError`),
///   2. one place to add cross-provider extension methods later, and
///   3. a sticky name in error messages (clippy / rustdoc).
pub trait LlmError: std::error::Error + Send + Sync + 'static {
    /// Classify this error so a transport (e.g. the harness's Connect surface)
    /// can map it onto an accurate status code — telling retryable (rate-limit /
    /// timeout / unavailable) apart from terminal (auth / bad-request) failures
    /// instead of collapsing everything to a catch-all.
    ///
    /// Defaults to [`LlmErrorKind::Other`]; provider error types override it,
    /// and [`crate::BoxError`] carries the kind through type erasure.
    fn kind(&self) -> LlmErrorKind {
        LlmErrorKind::Other
    }
}

/// A coarse, provider-agnostic classification of an [`LlmError`].
///
/// Deliberately small and stable: it names only the distinctions a caller acts
/// on (retry vs. fail, and which status to surface), not a full provider
/// taxonomy. [`kind_from_http_status`] maps an HTTP status onto these.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LlmErrorKind {
    /// Rate-limited / quota exhausted (HTTP 429). Retryable after backoff.
    RateLimit,
    /// Upstream timed out (HTTP 408/504, or a client read/connect timeout).
    /// Retryable.
    Timeout,
    /// Transient upstream unavailability (HTTP 5xx, connection refused/reset,
    /// DNS, stream break). Retryable.
    Unavailable,
    /// Authentication / authorization failure (HTTP 401/403). Terminal —
    /// retrying with the same credentials won't help.
    Auth,
    /// The request itself was rejected (HTTP 400/404/422, unknown model).
    /// Terminal.
    BadRequest,
    /// Anything else — an unclassified or internal failure.
    #[default]
    Other,
}

/// Maps an HTTP status code onto an [`LlmErrorKind`]. Shared by every provider
/// so the classification of `Provider { status, .. }` errors stays consistent.
#[must_use]
pub const fn kind_from_http_status(status: u16) -> LlmErrorKind {
    match status {
        429 => LlmErrorKind::RateLimit,
        408 | 504 => LlmErrorKind::Timeout,
        401 | 403 => LlmErrorKind::Auth,
        400 | 404 | 422 => LlmErrorKind::BadRequest,
        500..=599 => LlmErrorKind::Unavailable,
        _ => LlmErrorKind::Other,
    }
}

/// Reference implementation: the shape of error a real provider would
/// ship. Concrete provider crates will define their own.
#[derive(Debug, thiserror::Error)]
pub enum DummyError {
    /// Network or transport-layer failure.
    #[error("transport: {0}")]
    Transport(String),

    /// Provider returned a non-2xx status with a body.
    #[error("provider returned status {status}: {body}")]
    Provider {
        /// HTTP status code returned by the provider.
        status: u16,
        /// Response body, typically a JSON error payload.
        body: String,
    },

    /// Streamed response broke mid-flight.
    #[error("stream interrupted: {0}")]
    StreamInterrupted(String),

    /// Anything else, escape hatch.
    #[error("other: {0}")]
    Other(String),
}

impl LlmError for DummyError {
    fn kind(&self) -> LlmErrorKind {
        match self {
            Self::Transport(_) | Self::StreamInterrupted(_) => LlmErrorKind::Unavailable,
            Self::Provider { status, .. } => kind_from_http_status(*status),
            Self::Other(_) => LlmErrorKind::Other,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{DummyError, LlmError};

    /// Compile-time assertion: `E` implements [`LlmError`].
    fn require_llm_error<E: LlmError>() {}

    /// Compile-time assertion: `T` is `Send + Sync + 'static`.
    fn assert_send_sync<T: Send + Sync + 'static>() {}

    // --- Display -----------------------------------------------------------

    #[test]
    fn display_transport() {
        let e = DummyError::Transport("DNS failure".to_owned());
        assert_eq!(format!("{e}"), "transport: DNS failure");
    }

    #[test]
    fn display_provider() {
        let e = DummyError::Provider {
            status: 404,
            body: "not found".to_owned(),
        };
        assert_eq!(format!("{e}"), "provider returned status 404: not found");
    }

    #[test]
    fn display_stream_interrupted() {
        let e = DummyError::StreamInterrupted("EOF".to_owned());
        assert_eq!(format!("{e}"), "stream interrupted: EOF");
    }

    #[test]
    fn display_other() {
        let e = DummyError::Other("unexpected".to_owned());
        assert_eq!(format!("{e}"), "other: unexpected");
    }

    // --- Debug -------------------------------------------------------------

    #[test]
    fn debug_is_derived() {
        let e = DummyError::Transport("t".to_owned());
        assert!(format!("{e:?}").contains("Transport"));
    }

    // --- Trait-bound proofs (compile-time) ---------------------------------

    #[test]
    fn dummy_error_satisfies_llm_error() {
        // DummyError: std::error::Error + Send + Sync + 'static
        // → blanket impl grants DummyError: LlmError.
        require_llm_error::<DummyError>();
    }

    #[test]
    fn dummy_error_is_send_sync_static() {
        assert_send_sync::<DummyError>();
    }

    #[test]
    fn dummy_error_boxes_as_std_error() {
        // Coercing to the trait object verifies std::error::Error + Send + Sync + 'static.
        let _: Box<dyn std::error::Error + Send + Sync + 'static> =
            Box::new(DummyError::Other("boxed".to_owned()));
    }

    #[test]
    fn http_status_maps_to_kind() {
        use super::{LlmErrorKind, kind_from_http_status};
        assert_eq!(kind_from_http_status(429), LlmErrorKind::RateLimit);
        assert_eq!(kind_from_http_status(504), LlmErrorKind::Timeout);
        assert_eq!(kind_from_http_status(408), LlmErrorKind::Timeout);
        assert_eq!(kind_from_http_status(401), LlmErrorKind::Auth);
        assert_eq!(kind_from_http_status(403), LlmErrorKind::Auth);
        assert_eq!(kind_from_http_status(400), LlmErrorKind::BadRequest);
        assert_eq!(kind_from_http_status(404), LlmErrorKind::BadRequest);
        assert_eq!(kind_from_http_status(503), LlmErrorKind::Unavailable);
        assert_eq!(kind_from_http_status(200), LlmErrorKind::Other);
    }

    #[test]
    fn dummy_error_classifies() {
        use super::{LlmError, LlmErrorKind};
        assert_eq!(
            DummyError::Provider {
                status: 429,
                body: String::new()
            }
            .kind(),
            LlmErrorKind::RateLimit
        );
        assert_eq!(
            DummyError::Transport("reset".to_owned()).kind(),
            LlmErrorKind::Unavailable
        );
        assert_eq!(
            DummyError::Other("x".to_owned()).kind(),
            LlmErrorKind::Other
        );
    }
}