use std::path::PathBuf;
use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveDate, Utc};
use claudex::api::{Claudex, ClaudexConfig, Filter, Provider};
use claudex::index::{ModelUsageRow, TimelineRow};
use crate::models::{
LocalDailyUsage, LocalModelUsage, LocalProjectCost, LocalUsageReport, ProviderKind,
ProviderLocalUsage,
};
const TIMELINE_DAYS: usize = 14;
const TOP_LIMIT: usize = 5;
pub(crate) fn claudex_kinds(provider: ProviderKind) -> Option<Vec<Provider>> {
match provider {
ProviderKind::ClaudeCode => Some(vec![Provider::Claude]),
ProviderKind::Codex => Some(vec![Provider::Codex]),
ProviderKind::Copilot => Some(vec![Provider::Copilot, Provider::CopilotVscode]),
ProviderKind::OpenRouter | ProviderKind::Runpod | ProviderKind::Aws => None,
}
}
pub(crate) async fn collect_local_usage(providers: Vec<ProviderKind>) -> LocalUsageReport {
match tokio::task::spawn_blocking(move || collect_blocking(None, Vec::new(), &providers)).await
{
Ok(report) => report,
Err(error) => unavailable(format!("local insights task failed: {error}")),
}
}
fn unavailable(message: String) -> LocalUsageReport {
LocalUsageReport {
available: false,
message: Some(message),
providers: Vec::new(),
generated_at: Utc::now(),
}
}
fn collect_blocking(
state_dir: Option<PathBuf>,
scope: Vec<Provider>,
providers: &[ProviderKind],
) -> LocalUsageReport {
match try_collect(state_dir, scope, providers) {
Ok(report) => report,
Err(error) => unavailable(format!("local insights unavailable: {error:#}")),
}
}
fn try_collect(
state_dir: Option<PathBuf>,
scope: Vec<Provider>,
providers: &[ProviderKind],
) -> Result<LocalUsageReport> {
let mut client = Claudex::with_config(ClaudexConfig {
state_dir,
providers: scope,
})?;
let now = Local::now();
let mut rows = Vec::new();
let mut seen: Vec<ProviderKind> = Vec::new();
for &provider in providers {
if seen.contains(&provider) {
continue;
}
seen.push(provider);
let Some(kinds) = claudex_kinds(provider) else {
continue;
};
if let Some(usage) = provider_usage(&mut client, provider, &kinds, now)? {
rows.push(usage);
}
}
let message = rows
.is_empty()
.then(|| "No local session history found yet.".to_string());
Ok(LocalUsageReport {
available: true,
message,
providers: rows,
generated_at: Utc::now(),
})
}
fn provider_usage(
client: &mut Claudex,
provider: ProviderKind,
kinds: &[Provider],
now: DateTime<Local>,
) -> Result<Option<ProviderLocalUsage>> {
let since = |value: String| Filter {
providers: kinds.to_vec(),
since: Some(value),
..Filter::default()
};
let month_filter = since(start_of_month(now));
let month = client.cost_summary(None, month_filter.clone())?;
let week = client.cost_summary(None, since("7d".to_string()))?;
if month.session_count == 0 && month.cost_usd <= 0.0 && week.session_count == 0 {
return Ok(None);
}
let today = client.cost_summary(None, since(start_of_day(now)))?;
let timeline = client.timeline(since(format!("{TIMELINE_DAYS}d")), false, TIMELINE_DAYS)?;
let (top_model, model_distribution) =
model_breakdown(client.models(None, month_filter.clone())?);
let top_projects = client
.costs_by_project(None, month_filter, TOP_LIMIT)?
.into_iter()
.map(|row| LocalProjectCost {
project: row.project,
sessions: row.session_count,
cost_usd: row.cost_usd,
})
.collect();
Ok(Some(ProviderLocalUsage {
provider,
today_cost_usd: today.cost_usd,
today_sessions: today.session_count,
week_cost_usd: week.cost_usd,
month_cost_usd: month.cost_usd,
projected_month_cost_usd: projected_month_cost(month.cost_usd, now.date_naive()),
month_input_tokens: month.input_tokens,
month_output_tokens: month.output_tokens,
top_model,
model_distribution,
top_projects,
daily: daily_series(timeline),
}))
}
fn daily_series(rows: Vec<TimelineRow>) -> Vec<LocalDailyUsage> {
let mut daily: Vec<LocalDailyUsage> = rows
.into_iter()
.map(|row| LocalDailyUsage {
date: row.bucket,
cost_usd: row.cost_usd,
sessions: row.session_count,
})
.collect();
daily.sort_by(|a, b| a.date.cmp(&b.date));
daily
}
fn model_breakdown(rows: Vec<ModelUsageRow>) -> (Option<String>, Vec<LocalModelUsage>) {
let mut distribution: Vec<LocalModelUsage> = rows
.into_iter()
.filter(|row| row.session_count > 0 || row.cost_usd > 0.0)
.map(|row| LocalModelUsage {
model: row.model,
sessions: row.session_count,
cost_usd: row.cost_usd,
})
.collect();
distribution.sort_by(|a, b| {
b.cost_usd
.total_cmp(&a.cost_usd)
.then(b.sessions.cmp(&a.sessions))
});
distribution.truncate(TOP_LIMIT);
let top_model = distribution.first().map(|row| row.model.clone());
(top_model, distribution)
}
pub(crate) fn projected_month_cost(month_cost: f64, today: NaiveDate) -> Option<f64> {
if month_cost <= 0.0 {
return None;
}
let day = f64::from(today.day());
let days = f64::from(days_in_month(today.year(), today.month()));
Some(month_cost * days / day)
}
fn days_in_month(year: i32, month: u32) -> u32 {
let next_month_start = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1)
};
next_month_start
.and_then(|date| date.pred_opt())
.map(|date| date.day())
.expect("every month has a last day")
}
fn start_of_day(now: DateTime<Local>) -> String {
local_midnight(now.date_naive()).to_rfc3339()
}
fn start_of_month(now: DateTime<Local>) -> String {
let first = now.date_naive().with_day(1).expect("day 1 exists");
local_midnight(first).to_rfc3339()
}
fn local_midnight(date: NaiveDate) -> DateTime<Utc> {
let naive = date.and_hms_opt(0, 0, 0).expect("midnight exists");
naive
.and_local_timezone(Local)
.earliest()
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| naive.and_utc())
}
pub(crate) async fn copilot_premium_requests_mtd() -> Result<u64> {
match tokio::task::spawn_blocking(|| premium_requests_blocking(None)).await {
Ok(result) => result,
Err(error) => Err(anyhow::anyhow!("premium request scan task failed: {error}")),
}
}
fn premium_requests_blocking(state_dir: Option<PathBuf>) -> Result<u64> {
let mut client = Claudex::with_config(ClaudexConfig {
state_dir,
providers: vec![Provider::Copilot],
})?;
let filter = Filter {
providers: vec![Provider::Copilot],
since: Some(start_of_month(Local::now())),
..Filter::default()
};
let sessions = client.sessions(None, None, filter, 100_000)?;
Ok(sum_premium_requests(&sessions))
}
pub(crate) fn sum_premium_requests(sessions: &[claudex::index::IndexedSession]) -> u64 {
sessions
.iter()
.filter_map(|session| session.extras.as_deref())
.filter_map(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
.filter_map(|extras| extras.get("premium_requests").and_then(|v| v.as_u64()))
.sum()
}
#[cfg(test)]
pub(crate) mod test_support {
use std::fs;
use std::path::Path;
use std::sync::Mutex;
use chrono::{DateTime, Utc};
static ENV_LOCK: Mutex<()> = Mutex::new(());
pub(crate) fn with_claudex_env<R>(
state_dir: Option<&Path>,
copilot_dir: Option<&Path>,
body: impl FnOnce() -> R,
) -> R {
let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
let apply = |key: &str, value: Option<&Path>| match value {
Some(dir) => unsafe { std::env::set_var(key, dir) },
None => unsafe { std::env::remove_var(key) },
};
apply("CLAUDEX_DIR", state_dir);
apply("CLAUDEX_COPILOT_DIR", copilot_dir);
struct ResetOnDrop;
impl Drop for ResetOnDrop {
fn drop(&mut self) {
unsafe {
std::env::remove_var("CLAUDEX_DIR");
std::env::remove_var("CLAUDEX_COPILOT_DIR");
}
}
}
let _reset = ResetOnDrop;
body()
}
pub(crate) fn write_copilot_fixture(
base: &Path,
session_id: &str,
at: DateTime<Utc>,
premium_requests: u64,
) {
let dir = base.join("session-state").join(session_id);
fs::create_dir_all(&dir).unwrap();
let ts = at.to_rfc3339();
let later = (at + chrono::Duration::minutes(5)).to_rfc3339();
let events = format!(
concat!(
"{{\"type\":\"session.start\",\"timestamp\":\"{ts}\",\"data\":{{\"sessionId\":\"{id}\",",
"\"selectedModel\":\"gpt-5\",\"context\":{{\"cwd\":\"/work/demo\"}}}}}}\n",
"{{\"type\":\"user.message\",\"timestamp\":\"{ts}\",\"data\":{{\"content\":\"hi\"}}}}\n",
"{{\"type\":\"session.shutdown\",\"timestamp\":\"{later}\",\"data\":{{",
"\"totalPremiumRequests\":{premium},",
"\"modelMetrics\":{{\"gpt-5\":{{\"usage\":{{\"inputTokens\":120,\"outputTokens\":40,",
"\"cacheReadTokens\":0,\"cacheWriteTokens\":0}},\"requests\":{{\"count\":2}}}}}}}}}}\n",
),
ts = ts,
later = later,
id = session_id,
premium = premium_requests,
);
fs::write(dir.join("events.jsonl"), events).unwrap();
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use tempfile::tempdir;
use super::test_support::{with_claudex_env, write_copilot_fixture};
use super::*;
fn with_copilot_dir<R>(dir: &Path, body: impl FnOnce() -> R) -> R {
with_claudex_env(None, Some(dir), body)
}
#[test]
fn maps_burnrate_providers_to_claudex_kinds() {
assert_eq!(
claudex_kinds(ProviderKind::ClaudeCode),
Some(vec![Provider::Claude])
);
assert_eq!(
claudex_kinds(ProviderKind::Codex),
Some(vec![Provider::Codex])
);
assert_eq!(
claudex_kinds(ProviderKind::Copilot),
Some(vec![Provider::Copilot, Provider::CopilotVscode])
);
assert_eq!(claudex_kinds(ProviderKind::OpenRouter), None);
assert_eq!(claudex_kinds(ProviderKind::Runpod), None);
assert_eq!(claudex_kinds(ProviderKind::Aws), None);
}
#[test]
fn projects_month_cost_linearly() {
let mid_june = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
assert_eq!(projected_month_cost(50.0, mid_june), Some(100.0));
let first = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
assert_eq!(projected_month_cost(3.0, first), Some(90.0));
let last = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
assert_eq!(projected_month_cost(90.0, last), Some(90.0));
let leap = NaiveDate::from_ymd_opt(2028, 2, 29).unwrap();
assert_eq!(projected_month_cost(29.0, leap), Some(29.0));
assert_eq!(
projected_month_cost(0.0, mid_june),
None,
"no spend, no projection"
);
}
#[test]
fn daily_series_sorts_ascending_by_date() {
let rows = vec![
timeline_row("2026-06-10", 2, 4.0),
timeline_row("2026-06-08", 1, 1.0),
timeline_row("2026-06-09", 3, 2.5),
];
let daily = daily_series(rows);
let dates: Vec<&str> = daily.iter().map(|d| d.date.as_str()).collect();
assert_eq!(dates, vec!["2026-06-08", "2026-06-09", "2026-06-10"]);
assert_eq!(daily[2].cost_usd, 4.0);
assert_eq!(daily[2].sessions, 2);
}
#[test]
fn model_breakdown_ranks_by_cost_and_caps_the_list() {
let rows = vec![
model_row("claude-haiku-4-5", 9, 1.0),
model_row("claude-fable-5", 3, 42.0),
model_row("idle-model", 0, 0.0),
model_row("m3", 1, 5.0),
model_row("m4", 1, 4.0),
model_row("m5", 1, 3.0),
model_row("m6", 1, 2.0),
];
let (top, distribution) = model_breakdown(rows);
assert_eq!(top.as_deref(), Some("claude-fable-5"));
assert_eq!(distribution.len(), TOP_LIMIT, "capped at TOP_LIMIT");
assert_eq!(distribution[0].model, "claude-fable-5");
assert!(
!distribution.iter().any(|m| m.model == "idle-model"),
"zero-activity rows are dropped"
);
}
#[test]
fn month_and_day_starts_are_valid_local_instants() {
let now = Local::now();
let day = DateTime::parse_from_rfc3339(&start_of_day(now)).unwrap();
let month = DateTime::parse_from_rfc3339(&start_of_month(now)).unwrap();
assert!(day <= now.fixed_offset());
assert!(month <= day);
assert_eq!(month.with_timezone(&Local).day(), 1);
}
#[test]
fn collects_copilot_usage_from_a_hermetic_fixture() {
let state = tempdir().unwrap();
let copilot_home = tempdir().unwrap();
write_copilot_fixture(copilot_home.path(), "11111111-aaaa", Utc::now(), 7);
let report = with_copilot_dir(copilot_home.path(), || {
collect_blocking(
Some(state.path().to_path_buf()),
vec![Provider::Copilot],
&[ProviderKind::Copilot, ProviderKind::OpenRouter],
)
});
assert!(report.available, "report: {:?}", report.message);
assert_eq!(report.providers.len(), 1, "openrouter has no local source");
let usage = &report.providers[0];
assert_eq!(usage.provider, ProviderKind::Copilot);
assert_eq!(usage.today_sessions, 1);
assert!(usage.month_input_tokens > 0);
assert_eq!(usage.top_model.as_deref(), Some("gpt-5"));
assert!(!usage.daily.is_empty());
}
#[test]
fn sums_premium_requests_from_session_extras() {
let session = |extras: Option<&str>| claudex::index::IndexedSession {
rowid: 0,
provider: "copilot".to_string(),
project_name: "demo".to_string(),
session_id: None,
file_path: String::new(),
first_timestamp_ms: None,
last_timestamp_ms: None,
message_count: 0,
duration_ms: 0,
model: None,
extras: extras.map(str::to_string),
present_on_disk: true,
archived_at: None,
};
let sessions = vec![
session(Some(r#"{"premium_requests":12,"branch":"main"}"#)),
session(Some(r#"{"premium_requests":30}"#)),
session(Some(r#"{"branch":"no-counter"}"#)),
session(Some("{not json")),
session(None),
];
assert_eq!(sum_premium_requests(&sessions), 42);
assert_eq!(sum_premium_requests(&[]), 0);
}
#[test]
fn counts_month_to_date_premium_requests_from_fixture_sessions() {
let state = tempdir().unwrap();
let copilot_home = tempdir().unwrap();
let now = Utc::now();
write_copilot_fixture(copilot_home.path(), "22222222-bbbb", now, 5);
write_copilot_fixture(copilot_home.path(), "33333333-cccc", now, 9);
write_copilot_fixture(
copilot_home.path(),
"44444444-dddd",
now - chrono::Duration::days(40),
100,
);
let counted = with_copilot_dir(copilot_home.path(), || {
premium_requests_blocking(Some(state.path().to_path_buf()))
})
.unwrap();
assert_eq!(counted, 14);
}
#[test]
fn broken_state_dir_degrades_to_unavailable_instead_of_erroring() {
let dir = tempdir().unwrap();
let bogus = dir.path().join("not-a-dir");
fs::write(&bogus, b"x").unwrap();
let report = collect_blocking(Some(bogus), vec![], &[ProviderKind::ClaudeCode]);
assert!(!report.available);
assert!(report.message.is_some());
assert!(report.providers.is_empty());
}
fn timeline_row(bucket: &str, sessions: i64, cost: f64) -> TimelineRow {
TimelineRow {
bucket: bucket.to_string(),
session_count: sessions,
cost_usd: cost,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
tool_calls: 0,
pr_count: 0,
avg_turn_duration_ms: None,
}
}
fn model_row(model: &str, sessions: i64, cost: f64) -> ModelUsageRow {
ModelUsageRow {
model: model.to_string(),
session_count: sessions,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cost_usd: cost,
avg_cost_per_session_usd: 0.0,
avg_tokens_per_session: 0.0,
service_tiers: Vec::new(),
inference_geos: Vec::new(),
avg_speed: None,
total_iterations: 0,
}
}
}