spectracost 0.1.0

AI cost observability SDK - see the full spectrum of your AI spend
Documentation
//! # spectracost
//!
//! AI cost observability SDK for Rust. See the crate-level README for usage.
//!
//! The SDK owns minimal HTTP clients for OpenAI and Anthropic under
//! [`openai`] and [`anthropic`] so callers get a one-line setup without
//! wrestling with a third-party crate's builder types. Every call is
//! instrumented: we capture model, token counts, latency, and attribution
//! tags, then forward a [`UsageEvent`] to the Spectracost ingestion endpoint
//! in a background task.

use std::sync::Arc;
use std::time::Duration;

use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use thiserror::Error;

pub mod anthropic;
pub mod openai;
mod transport;

use transport::Transport;

/// Default Spectracost ingestion endpoint.
pub const DEFAULT_ENDPOINT: &str = "https://spectracost.com/ingest";

/// Options for constructing a [`Spectracost`] client.
#[derive(Debug, Clone, Default)]
pub struct Options {
    /// Spectracost API key (starts with `sprc_`).
    pub api_key: String,
    /// Override the ingestion endpoint. Defaults to `DEFAULT_ENDPOINT`.
    pub endpoint: Option<String>,

    /// OpenAI API key used by [`Spectracost::openai_chat`].
    pub openai_api_key: Option<String>,
    /// Override the OpenAI base URL (e.g. for Groq, DeepSeek, etc.).
    /// When set, the provider name is auto-detected from the host.
    pub openai_base_url: Option<String>,

    /// Anthropic API key used by [`Spectracost::anthropic_messages`].
    pub anthropic_api_key: Option<String>,
    /// Override the Anthropic base URL.
    pub anthropic_base_url: Option<String>,

    /// Default attribution tags applied to every event.
    pub team: Option<String>,
    pub service: Option<String>,
    pub feature: Option<String>,
    pub environment: Option<String>,
    pub customer_id: Option<String>,

    /// Extra custom tags on every event.
    pub tags: Option<std::collections::HashMap<String, String>>,
}

/// Per-call attribution overrides.
#[derive(Debug, Clone, Default)]
pub struct Attribution {
    pub team: Option<String>,
    pub service: Option<String>,
    pub feature: Option<String>,
    pub environment: Option<String>,
    pub customer_id: Option<String>,
}

/// Errors returned by the SDK.
#[derive(Debug, Error)]
pub enum Error {
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("provider api returned status {status}: {body}")]
    Provider { status: u16, body: String },
    #[error("missing credential: {0}")]
    MissingCredential(&'static str),
    #[error("serialization error: {0}")]
    Serde(#[from] serde_json::Error),
}

/// Wire format for a telemetry event. Matches `sdk/SPEC.md` exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageEvent {
    pub id: String,
    pub timestamp: String,
    pub provider: String,
    pub model: String,
    pub endpoint: String,
    pub input_tokens: u32,
    pub output_tokens: u32,
    pub total_tokens: u32,
    #[serde(skip_serializing_if = "is_zero")]
    pub cached_tokens: u32,
    pub latency_ms: u32,
    pub status: String,
    #[serde(skip_serializing_if = "String::is_empty", default)]
    pub error_code: String,
    #[serde(skip_serializing_if = "String::is_empty", default)]
    pub team: String,
    #[serde(skip_serializing_if = "String::is_empty", default)]
    pub service: String,
    #[serde(skip_serializing_if = "String::is_empty", default)]
    pub feature: String,
    #[serde(skip_serializing_if = "String::is_empty", default)]
    pub environment: String,
    #[serde(skip_serializing_if = "String::is_empty", default)]
    pub customer_id: String,
    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty", default)]
    pub tags: std::collections::HashMap<String, String>,
}

fn is_zero(v: &u32) -> bool {
    *v == 0
}

/// The main client. Cheap to clone (inner state is `Arc`'d).
#[derive(Debug, Clone)]
pub struct Spectracost {
    inner: Arc<Inner>,
}

#[derive(Debug)]
struct Inner {
    options: Options,
    http: HttpClient,
    transport: Transport,
}

impl Spectracost {
    /// Construct a new client. Panics if `options.api_key` is empty.
    pub fn new(options: Options) -> Self {
        assert!(!options.api_key.is_empty(), "spectracost: Options.api_key must be set");
        let http = HttpClient::builder()
            .timeout(Duration::from_secs(120))
            .build()
            .expect("reqwest client");
        let endpoint = options.endpoint.clone().unwrap_or_else(|| DEFAULT_ENDPOINT.to_string());
        let transport = Transport::new(http.clone(), endpoint, options.api_key.clone());
        Self {
            inner: Arc::new(Inner { options, http, transport }),
        }
    }

    fn options(&self) -> &Options {
        &self.inner.options
    }

    fn http(&self) -> &HttpClient {
        &self.inner.http
    }

    /// Build an event from a partial set of fields, applying attribution
    /// defaults from `options` (and an optional per-call override).
    fn build_event(
        &self,
        provider: &str,
        model: &str,
        endpoint: &str,
        input_tokens: u32,
        output_tokens: u32,
        latency_ms: u32,
        status: &str,
        attr: Option<&Attribution>,
    ) -> UsageEvent {
        let opts = self.options();
        let get = |a: Option<&str>, b: Option<&str>| -> String {
            a.map(String::from).or_else(|| b.map(String::from)).unwrap_or_default()
        };
        let override_team = attr.and_then(|a| a.team.as_deref());
        let override_service = attr.and_then(|a| a.service.as_deref());
        let override_feature = attr.and_then(|a| a.feature.as_deref());
        let override_env = attr.and_then(|a| a.environment.as_deref());
        let override_customer = attr.and_then(|a| a.customer_id.as_deref());

        UsageEvent {
            id: uuid::Uuid::new_v4().to_string(),
            timestamp: chrono::Utc::now().to_rfc3339(),
            provider: provider.to_string(),
            model: model.to_string(),
            endpoint: endpoint.to_string(),
            input_tokens,
            output_tokens,
            total_tokens: input_tokens + output_tokens,
            cached_tokens: 0,
            latency_ms,
            status: status.to_string(),
            error_code: String::new(),
            team: get(override_team, opts.team.as_deref()),
            service: get(override_service, opts.service.as_deref()),
            feature: get(override_feature, opts.feature.as_deref()),
            environment: get(
                override_env,
                opts.environment.as_deref().or(Some("production")),
            ),
            customer_id: get(override_customer, opts.customer_id.as_deref()),
            tags: opts.tags.clone().unwrap_or_default(),
        }
    }

    fn emit(&self, event: UsageEvent) {
        self.inner.transport.enqueue(event);
    }
}

/// Resolve the canonical provider name for a given base URL. Mirrors
/// the detection logic in the Python / Node / Go SDKs.
pub(crate) fn detect_provider(base_url: Option<&str>, class_hint: &str) -> String {
    if let Some(url) = base_url {
        if let Some(p) = provider_from_proxy_path(url) {
            return p;
        }
        if let Some(p) = provider_from_host(url) {
            return p;
        }
    }
    class_hint.to_string()
}

fn provider_from_proxy_path(url: &str) -> Option<String> {
    let marker = "/proxy/v1/";
    let idx = url.find(marker)?;
    let tail = &url[idx + marker.len()..];
    for seg in tail.split('/').filter(|s| !s.is_empty()) {
        if seg.starts_with("sprc_") {
            return None;
        }
        return Some(seg.to_string());
    }
    None
}

fn provider_from_host(url: &str) -> Option<String> {
    let parsed = reqwest::Url::parse(url).ok()?;
    let host = parsed.host_str()?.to_ascii_lowercase();
    let table: &[(&str, &str)] = &[
        ("api.openai.com", "openai"),
        ("api.anthropic.com", "anthropic"),
        ("generativelanguage.googleapis.com", "google"),
        ("api.cohere.com", "cohere"),
        ("api.deepseek.com", "deepseek"),
        ("api.groq.com", "groq"),
        ("api.together.xyz", "together"),
        ("api.mistral.ai", "mistral"),
        ("api.x.ai", "xai"),
        ("openrouter.ai", "openrouter"),
        ("api.fireworks.ai", "fireworks"),
    ];
    for (known, name) in table {
        if host == *known || host.ends_with(&format!(".{}", known)) {
            return Some((*name).to_string());
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detect_by_host() {
        assert_eq!(detect_provider(Some("https://api.groq.com/openai/v1"), "openai"), "groq");
        assert_eq!(detect_provider(Some("https://api.openai.com/v1"), "openai"), "openai");
        assert_eq!(detect_provider(Some("https://api.anthropic.com"), "anthropic"), "anthropic");
    }

    #[test]
    fn detect_by_proxy_path() {
        assert_eq!(
            detect_provider(Some("https://spectracost.com/proxy/v1/deepseek/v1"), "openai"),
            "deepseek",
        );
    }

    #[test]
    fn detect_by_proxy_path_skips_sprc_segment() {
        assert_eq!(
            detect_provider(
                Some("https://spectracost.com/proxy/v1/together/sprc_abc/v1"),
                "openai",
            ),
            "together",
        );
    }

    #[test]
    fn detect_falls_back_to_class_hint() {
        assert_eq!(detect_provider(None, "anthropic"), "anthropic");
    }
}