polyc-llm 2026.8.0

Provider-agnostic LLM trait + wire types for polychrome.
Documentation
//! Prometheus metric for LLM provider call latency.
//!
//! Registered into the process default registry — same pattern as the edge
//! `metrics.rs` modules (`polyc-slack` is the reference): no separate scrape
//! endpoint, no separate registry plumbing.

use std::sync::OnceLock;

use prometheus::{HistogramVec, register_histogram_vec};

use crate::error::LlmErrorKind;

/// Wall-clock duration of one [`crate::LlmProvider::complete`] dial — from
/// dispatch to the outer `Result` resolving (stream opened, or a pre-stream
/// failure) — labeled `outcome`: `ok`, or the failure's [`LlmErrorKind`].
fn call_duration() -> &'static HistogramVec {
    static V: OnceLock<HistogramVec> = OnceLock::new();
    V.get_or_init(|| {
        register_histogram_vec!(
            "polychrome_llm_call_duration_seconds",
            "LLM provider call latency (dispatch to stream-open or failure), by outcome.",
            &["outcome"]
        )
        .expect("register polychrome_llm_call_duration_seconds")
    })
}

/// Record one provider call: `Ok(())` on a successfully opened stream, or the
/// classified [`LlmErrorKind`] on a pre-stream failure.
pub(crate) fn record_call(outcome: Result<(), LlmErrorKind>, elapsed: std::time::Duration) {
    let label = match outcome {
        Ok(()) => "ok",
        Err(kind) => kind_label(kind),
    };
    call_duration()
        .with_label_values(&[label])
        .observe(elapsed.as_secs_f64());
}

/// Force-register [`call_duration`] with every known `outcome` label value
/// pre-created (zero-valued), so it appears in a `/metrics` scrape before any
/// provider call completes. See [`crate::init_metrics`].
///
/// A `HistogramVec` produces NO scrape output for a label combination that
/// has never been touched — registering the vec alone is not enough.
/// `with_label_values` creates the zero-valued child without recording an
/// observation, which is what makes it appear.
pub(crate) fn force() {
    for outcome in [
        "ok",
        "rate_limit",
        "timeout",
        "unavailable",
        "auth",
        "bad_request",
        "other",
    ] {
        call_duration().with_label_values(&[outcome]);
    }
}

/// Lower-case, metric-label form of an [`LlmErrorKind`].
const fn kind_label(kind: LlmErrorKind) -> &'static str {
    match kind {
        LlmErrorKind::RateLimit => "rate_limit",
        LlmErrorKind::Timeout => "timeout",
        LlmErrorKind::Unavailable => "unavailable",
        LlmErrorKind::Auth => "auth",
        LlmErrorKind::BadRequest => "bad_request",
        LlmErrorKind::Other => "other",
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use std::time::Duration;

    use prometheus::{Encoder as _, TextEncoder};

    use super::record_call;
    use crate::error::LlmErrorKind;

    /// Both a success and an error observation land in the shared histogram,
    /// distinguished by their `outcome` label.
    #[test]
    fn record_call_is_visible_in_a_registry_scrape() {
        record_call(Ok(()), Duration::from_millis(5));
        record_call(Err(LlmErrorKind::RateLimit), Duration::from_millis(1));
        let mut buf = Vec::new();
        TextEncoder::new()
            .encode(&prometheus::default_registry().gather(), &mut buf)
            .expect("encode");
        let text = String::from_utf8(buf).expect("utf8");
        assert!(
            text.contains("polychrome_llm_call_duration_seconds_bucket"),
            "missing histogram buckets in scrape:\n{text}"
        );
        assert!(text.contains("outcome=\"ok\""));
        assert!(text.contains("outcome=\"rate_limit\""));
    }
}