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::CacheEconomicsResponse;
use otelite_storage::StorageBackend;
use std::sync::Arc;
#[derive(Debug, Args)]
pub struct CacheCommand {
#[arg(long, default_value = "24h", value_parser = crate::commands::usage::validate_since)]
pub since: String,
#[arg(long)]
pub series: bool,
#[arg(long, default_value = "3600")]
pub bucket_secs: u64,
}
impl CacheCommand {
pub async fn execute(
&self,
storage: Arc<dyn StorageBackend>,
format: crate::config::OutputFormat,
) -> Result<()> {
if self.bucket_secs == 0 {
return Err(Error::InvalidArgument(
"bucket_secs must be a positive number of seconds".to_string(),
));
}
let (start_time, end_time) = parse_time_range(&self.since)?;
let mut response: CacheEconomicsResponse = storage
.query_cache_economics(
Some(start_time),
Some(end_time),
self.bucket_secs.saturating_mul(1_000_000_000) as i64,
)
.await
.map_err(|e| Error::ApiError(format!("Failed to query cache economics: {}", e)))?;
let pricing_db = fetch_pricing().await;
for m in &mut response.models {
let r = pricing_db.compute_cache_savings(Some(&m.model), m.cache_read_tokens, None);
m.est_savings_usd = r.cost;
m.savings_known = r.cost.is_some();
}
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_cache_economics(&response, self.series, self.bucket_secs)
},
}
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 fmt_ratio(r: Option<f64>) -> String {
match r {
Some(v) if v >= 100.0 => format!("{:.0}:1", v),
Some(v) => format!("{:.1}:1", v),
None => "—".to_string(),
}
}
fn fmt_ts(ns: i64) -> String {
let secs = ns.div_euclid(1_000_000_000);
chrono::DateTime::from_timestamp(secs, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| ns.to_string())
}
fn display_cache_economics(response: &CacheEconomicsResponse, show_series: bool, bucket_secs: u64) {
let total_read: u64 = response.models.iter().map(|m| m.cache_read_tokens).sum();
let total_write: u64 = response.models.iter().map(|m| m.cache_write_tokens).sum();
let total_savings: f64 = response
.models
.iter()
.filter(|m| m.savings_known)
.map(|m| m.est_savings_usd.unwrap_or(0.0))
.sum();
let savings_complete = response.models.iter().all(|m| m.savings_known);
println!(
"\nCache economics ({}/{} tokens served from cache)",
fmt_tokens(total_read),
fmt_tokens(total_read + total_write)
);
println!(
"estimated savings: {}{}\n",
fmt_cost(Some(total_savings)),
if savings_complete {
""
} else {
" (partial — some models have no known cache-read price)"
}
);
if response.models.is_empty() {
println!(" No cache activity in the window.\n");
return;
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"model",
"cache rd",
"cache wr",
"read:write",
"hit rate",
"est. savings",
]);
for m in &response.models {
table.add_row(vec![
m.model.clone(),
fmt_tokens(m.cache_read_tokens),
fmt_tokens(m.cache_write_tokens),
fmt_ratio(m.read_write_ratio),
m.hit_rate
.map(|h| format!("{:.1}%", h * 100.0))
.unwrap_or_else(|| "—".to_string()),
fmt_cost(m.est_savings_usd),
]);
}
println!("{}", table);
println!();
if show_series && !response.series.is_empty() {
println!(
"Series ({} buckets, {}s each):\n",
response.series.len(),
bucket_secs
);
let mut st = Table::new();
st.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["bucket", "input", "cache rd", "cache wr", "hit rate"]);
for p in &response.series {
st.add_row(vec![
fmt_ts(p.timestamp),
fmt_tokens(p.input),
fmt_tokens(p.cache_read),
fmt_tokens(p.cache_write),
p.hit_rate
.map(|h| format!("{:.1}%", h * 100.0))
.unwrap_or_else(|| "—".to_string()),
]);
}
println!("{}", st);
println!();
}
}