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;
pub const DEFAULT_ENDPOINT: &str = "https://spectracost.com/ingest";
#[derive(Debug, Clone, Default)]
pub struct Options {
pub api_key: String,
pub endpoint: Option<String>,
pub openai_api_key: Option<String>,
pub openai_base_url: Option<String>,
pub anthropic_api_key: Option<String>,
pub anthropic_base_url: Option<String>,
pub team: Option<String>,
pub service: Option<String>,
pub feature: Option<String>,
pub environment: Option<String>,
pub customer_id: Option<String>,
pub tags: Option<std::collections::HashMap<String, String>>,
}
#[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>,
}
#[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),
}
#[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
}
#[derive(Debug, Clone)]
pub struct Spectracost {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
options: Options,
http: HttpClient,
transport: Transport,
}
impl Spectracost {
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
}
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);
}
}
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");
}
}