use crate::commands::usage::{fetch_pricing, parse_time_range};
use crate::error::{Error, Result};
use clap::Args;
use comfy_table::{presets::UTF8_FULL, ContentArrangement, Table};
use otelite_core::api::ProviderMixResponse;
use otelite_core::pricing::TokenUsage;
use otelite_storage::StorageBackend;
use std::sync::Arc;
#[derive(Debug, Args)]
pub struct ProvidersCommand {
#[arg(long, default_value = "24h", value_parser = crate::commands::usage::validate_since)]
pub since: String,
}
impl ProvidersCommand {
pub async fn execute(
&self,
storage: Arc<dyn StorageBackend>,
format: crate::config::OutputFormat,
) -> Result<()> {
let (start_time, end_time) = parse_time_range(&self.since)?;
let mut response: ProviderMixResponse = storage
.query_provider_mix(Some(start_time), Some(end_time))
.await
.map_err(|e| Error::ApiError(format!("Failed to query provider mix: {}", e)))?;
let pricing_db = fetch_pricing().await;
let pricing_source = if pricing_db.is_litellm() {
"LiteLLM".to_string()
} else {
"fallback (hardcoded Claude rates)".to_string()
};
for provider in &mut response.providers {
let mut total: f64 = 0.0;
let mut any_priced = false;
for m in &mut provider.models {
let usage = TokenUsage {
input: m.tokens.input,
output: m.tokens.output,
cache_creation: m.tokens.cache_write,
cache_read: m.tokens.cache_read,
};
let cr = pricing_db.compute_cost(Some(&m.model), usage, None);
m.cost_usd = cr.cost;
m.cost_source = Some(cr.source.as_str().to_string());
if cr.cost.is_some() {
total += cr.cost.unwrap_or(0.0);
any_priced = true;
}
}
provider.cost_usd = if any_priced { Some(total) } else { None };
}
use crate::config::OutputFormat;
match format {
OutputFormat::Json | OutputFormat::JsonCompact => {
let json = if matches!(format, OutputFormat::JsonCompact) {
serde_json::to_string(&response)
} else {
serde_json::to_string_pretty(&response)
};
println!(
"{}",
json.map_err(|e| Error::ApiError(format!("JSON serialization failed: {}", e)))?
);
},
OutputFormat::Pretty => {
display_provider_mix(&response, &pricing_source);
},
}
Ok(())
}
}
fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.2}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn fmt_cost(c: Option<f64>) -> String {
match c {
Some(v) if v > 0.0 => format!("${:.2}", v),
Some(_) => "$0.00".to_string(),
None => "—".to_string(),
}
}
fn display_provider_mix(response: &ProviderMixResponse, pricing_source: &str) {
println!(
"\nProvider × model mix ({} tokens, method: {})",
fmt_tokens(response.total_tokens),
response.method
);
println!("cost source: {}\n", pricing_source);
if response.providers.is_empty() {
println!(" No provider × model usage in the window.\n");
return;
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"provider", "model", "tokens", "in", "out", "cache rd", "cache wr", "reason",
"sessions", "cost", "share",
]);
for provider in &response.providers {
for (i, m) in provider.models.iter().enumerate() {
let share = if response.total_tokens > 0 {
m.tokens.total() as f64 / response.total_tokens as f64 * 100.0
} else {
0.0
};
table.add_row(vec![
if i == 0 {
provider.provider.clone()
} else {
String::new()
},
m.model.clone(),
fmt_tokens(m.tokens.total()),
fmt_tokens(m.tokens.input),
fmt_tokens(m.tokens.output),
fmt_tokens(m.tokens.cache_read),
fmt_tokens(m.tokens.cache_write),
fmt_tokens(m.tokens.reasoning),
m.sessions.to_string(),
fmt_cost(m.cost_usd),
format!("{:.1}%", share),
]);
}
let p_tokens: u64 = provider.models.iter().map(|m| m.tokens.total()).sum();
table.add_row(vec![
format!(" {} total", provider.provider),
String::new(),
fmt_tokens(p_tokens),
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
fmt_cost(provider.cost_usd),
provider
.share_pct
.map(|s| format!("{:.1}%", s))
.unwrap_or_else(|| "—".to_string()),
]);
}
println!("{}", table);
println!();
}