Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/tokscale-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ fn main() -> Result<()> {
since,
until,
year,
Some(Tab::Daily),
Some(Tab::Monthly),
)
}
}
Expand Down
97 changes: 87 additions & 10 deletions crates/tokscale-cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent,
use ratatui::layout::Rect;
use tokscale_core::ClientId;

use super::data::{AgentUsage, DailyUsage, DataLoader, ModelUsage, UsageData};
use super::data::{AgentUsage, DailyUsage, DataLoader, ModelUsage, MonthlyModelUsage, UsageData};
use super::settings::Settings;
use super::themes::{Theme, ThemeName};
use super::ui::dialog::{ClientPickerDialog, DialogStack};
Expand All @@ -30,6 +30,7 @@ pub enum Tab {
Overview,
Models,
Daily,
Monthly,
Stats,
Agents,
}
Expand All @@ -40,6 +41,7 @@ impl Tab {
Tab::Overview,
Tab::Models,
Tab::Daily,
Tab::Monthly,
Tab::Stats,
Tab::Agents,
]
Expand All @@ -50,6 +52,7 @@ impl Tab {
Tab::Overview => "Overview",
Tab::Models => "Models",
Tab::Daily => "Daily",
Tab::Monthly => "Monthly",
Tab::Stats => "Stats",
Tab::Agents => "Agents",
}
Expand All @@ -60,6 +63,7 @@ impl Tab {
Tab::Overview => "Ovw",
Tab::Models => "Mod",
Tab::Daily => "Day",
Tab::Monthly => "Mth",
Tab::Stats => "Sta",
Tab::Agents => "Agt",
}
Expand All @@ -69,7 +73,8 @@ impl Tab {
match self {
Tab::Overview => Tab::Models,
Tab::Models => Tab::Daily,
Tab::Daily => Tab::Stats,
Tab::Daily => Tab::Monthly,
Tab::Monthly => Tab::Stats,
Tab::Stats => Tab::Agents,
Tab::Agents => Tab::Overview,
}
Expand All @@ -80,7 +85,8 @@ impl Tab {
Tab::Overview => Tab::Agents,
Tab::Models => Tab::Overview,
Tab::Daily => Tab::Models,
Tab::Stats => Tab::Daily,
Tab::Monthly => Tab::Daily,
Tab::Stats => Tab::Monthly,
Tab::Agents => Tab::Stats,
}
}
Expand Down Expand Up @@ -597,6 +603,7 @@ impl App {
Tab::Overview | Tab::Models => self.data.models.len(),
Tab::Agents => self.data.agents.len(),
Tab::Daily => self.data.daily.len(),
Tab::Monthly => self.data.monthly_models.len(),
Tab::Stats => {
if self.selected_graph_cell.is_some() {
self.stats_breakdown_total_lines
Expand Down Expand Up @@ -750,6 +757,15 @@ impl App {
.get_sorted_daily()
.get(self.selected_index)
.map(|d| format!("{}: {} tokens, ${:.4}", d.date, d.tokens.total(), d.cost)),
Tab::Monthly => self.get_sorted_monthly().get(self.selected_index).map(|m| {
format!(
"{} {}: {} tokens, ${:.4}",
m.month,
m.model,
m.tokens.total(),
m.cost
)
}),
Tab::Stats => None,
};

Expand Down Expand Up @@ -938,6 +954,53 @@ impl App {
daily
}

pub fn get_sorted_monthly(&self) -> Vec<&MonthlyModelUsage> {
let mut monthly: Vec<&MonthlyModelUsage> = self.data.monthly_models.iter().collect();

match (self.sort_field, self.sort_direction) {
(SortField::Cost, SortDirection::Descending) => monthly.sort_by(|a, b| {
b.cost
.total_cmp(&a.cost)
.then_with(|| b.month.cmp(&a.month))
.then_with(|| a.model.cmp(&b.model))
}),
(SortField::Cost, SortDirection::Ascending) => monthly.sort_by(|a, b| {
a.cost
.total_cmp(&b.cost)
.then_with(|| a.month.cmp(&b.month))
.then_with(|| a.model.cmp(&b.model))
}),
(SortField::Tokens, SortDirection::Descending) => monthly.sort_by(|a, b| {
b.tokens
.total()
.cmp(&a.tokens.total())
.then_with(|| b.month.cmp(&a.month))
.then_with(|| a.model.cmp(&b.model))
}),
(SortField::Tokens, SortDirection::Ascending) => monthly.sort_by(|a, b| {
a.tokens
.total()
.cmp(&b.tokens.total())
.then_with(|| a.month.cmp(&b.month))
.then_with(|| a.model.cmp(&b.model))
}),
(SortField::Date, SortDirection::Descending) => monthly.sort_by(|a, b| {
b.month
.cmp(&a.month)
.then_with(|| b.cost.total_cmp(&a.cost))
.then_with(|| a.model.cmp(&b.model))
}),
(SortField::Date, SortDirection::Ascending) => monthly.sort_by(|a, b| {
a.month
.cmp(&b.month)
.then_with(|| b.cost.total_cmp(&a.cost))
.then_with(|| a.model.cmp(&b.model))
}),
}

monthly
}

pub fn is_narrow(&self) -> bool {
self.terminal_width < 80
}
Expand All @@ -955,19 +1018,21 @@ mod tests {
#[test]
fn test_tab_all() {
let tabs = Tab::all();
assert_eq!(tabs.len(), 5);
assert_eq!(tabs.len(), 6);
assert_eq!(tabs[0], Tab::Overview);
assert_eq!(tabs[1], Tab::Models);
assert_eq!(tabs[2], Tab::Daily);
assert_eq!(tabs[3], Tab::Stats);
assert_eq!(tabs[4], Tab::Agents);
assert_eq!(tabs[3], Tab::Monthly);
assert_eq!(tabs[4], Tab::Stats);
assert_eq!(tabs[5], Tab::Agents);
}

#[test]
fn test_tab_next() {
assert_eq!(Tab::Overview.next(), Tab::Models);
assert_eq!(Tab::Models.next(), Tab::Daily);
assert_eq!(Tab::Daily.next(), Tab::Stats);
assert_eq!(Tab::Daily.next(), Tab::Monthly);
assert_eq!(Tab::Monthly.next(), Tab::Stats);
assert_eq!(Tab::Stats.next(), Tab::Agents);
assert_eq!(Tab::Agents.next(), Tab::Overview);
}
Expand All @@ -977,26 +1042,29 @@ mod tests {
assert_eq!(Tab::Overview.prev(), Tab::Agents);
assert_eq!(Tab::Models.prev(), Tab::Overview);
assert_eq!(Tab::Daily.prev(), Tab::Models);
assert_eq!(Tab::Stats.prev(), Tab::Daily);
assert_eq!(Tab::Monthly.prev(), Tab::Daily);
assert_eq!(Tab::Stats.prev(), Tab::Monthly);
assert_eq!(Tab::Agents.prev(), Tab::Stats);
}

#[test]
fn test_tab_as_str() {
assert_eq!(Tab::Overview.as_str(), "Overview");
assert_eq!(Tab::Models.as_str(), "Models");
assert_eq!(Tab::Agents.as_str(), "Agents");
assert_eq!(Tab::Daily.as_str(), "Daily");
assert_eq!(Tab::Monthly.as_str(), "Monthly");
assert_eq!(Tab::Stats.as_str(), "Stats");
assert_eq!(Tab::Agents.as_str(), "Agents");
}

#[test]
fn test_tab_short_name() {
assert_eq!(Tab::Overview.short_name(), "Ovw");
assert_eq!(Tab::Models.short_name(), "Mod");
assert_eq!(Tab::Agents.short_name(), "Agt");
assert_eq!(Tab::Daily.short_name(), "Day");
assert_eq!(Tab::Monthly.short_name(), "Mth");
assert_eq!(Tab::Stats.short_name(), "Sta");
assert_eq!(Tab::Agents.short_name(), "Agt");
}

#[test]
Expand Down Expand Up @@ -1268,6 +1336,9 @@ mod tests {
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.current_tab, Tab::Daily);

app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.current_tab, Tab::Monthly);

app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.current_tab, Tab::Stats);

Expand All @@ -1289,11 +1360,17 @@ mod tests {
app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Stats);

app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Monthly);

app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Daily);

app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Models);

app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.current_tab, Tab::Overview);
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/tokscale-cli/src/tui/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ impl TryFrom<CachedUsageData> for UsageData {
models: u.models.into_iter().map(|m| m.into()).collect(),
agents: u.agents.into_iter().map(|a| a.into()).collect(),
daily: daily?,
monthly_models: Vec::new(),
total_months: 0,
graph: graph.transpose()?,
total_tokens: u.total_tokens,
total_cost: u.total_cost,
Expand Down
86 changes: 85 additions & 1 deletion crates/tokscale-cli/src/tui/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ pub struct DailyUsage {
pub models: BTreeMap<String, DailyModelInfo>,
}

#[derive(Debug, Clone)]
pub struct MonthlyModelUsage {
pub month: String,
pub model: String,
pub provider: String,
pub client: String,
pub tokens: TokenBreakdown,
pub cost: f64,
pub message_count: u32,
}

#[derive(Debug, Clone)]
pub struct ContributionDay {
pub date: NaiveDate,
Expand All @@ -82,6 +93,10 @@ pub struct UsageData {
pub models: Vec<ModelUsage>,
pub agents: Vec<AgentUsage>,
pub daily: Vec<DailyUsage>,
#[allow(dead_code)]
pub monthly_models: Vec<MonthlyModelUsage>,
#[allow(dead_code)]
pub total_months: u32,
pub graph: Option<GraphData>,
pub total_tokens: u64,
pub total_cost: f64,
Expand Down Expand Up @@ -175,6 +190,8 @@ impl DataLoader {
let mut agent_map: HashMap<String, AgentUsage> = HashMap::new();
let mut agent_clients: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut daily_map: HashMap<NaiveDate, DailyUsage> = HashMap::new();
let mut monthly_map: HashMap<(String, String), MonthlyModelUsage> = HashMap::new();
let mut month_set: BTreeSet<String> = BTreeSet::new();
let mut model_session_ids: HashMap<String, HashSet<String>> = HashMap::new();

for msg in &messages {
Expand Down Expand Up @@ -239,7 +256,7 @@ impl DataLoader {
model_entry.cost += msg_cost;

let session_key = format!("{}:{}", msg.client, msg.session_id);
let model_sessions = model_session_ids.entry(key).or_default();
let model_sessions = model_session_ids.entry(key.clone()).or_default();
if model_sessions.insert(session_key) {
model_entry.session_count += 1;
}
Expand Down Expand Up @@ -290,6 +307,62 @@ impl DataLoader {
}

if let Some(date) = parse_date(&msg.date) {
let month = date.format("%Y-%m").to_string();
month_set.insert(month.clone());
let monthly_key = (month.clone(), key.clone());

let monthly_entry =
monthly_map
.entry(monthly_key)
.or_insert_with(|| MonthlyModelUsage {
month: month.clone(),
model: normalized_model.clone(),
provider: msg.provider_id.clone(),
client: msg.client.clone(),
tokens: TokenBreakdown::default(),
cost: 0.0,
message_count: 0,
});

if *group_by == GroupBy::Model
&& !monthly_entry.client.split(", ").any(|s| s == msg.client)
{
monthly_entry.client = format!("{}, {}", monthly_entry.client, msg.client);
}

if *group_by != GroupBy::ClientProviderModel
&& !monthly_entry
.provider
.split(", ")
.any(|p| p == msg.provider_id)
{
monthly_entry.provider =
format!("{}, {}", monthly_entry.provider, msg.provider_id);
}

monthly_entry.tokens.input = monthly_entry
.tokens
.input
.saturating_add(msg.tokens.input.max(0) as u64);
monthly_entry.tokens.output = monthly_entry
.tokens
.output
.saturating_add(msg.tokens.output.max(0) as u64);
monthly_entry.tokens.cache_read = monthly_entry
.tokens
.cache_read
.saturating_add(msg.tokens.cache_read.max(0) as u64);
monthly_entry.tokens.cache_write = monthly_entry
.tokens
.cache_write
.saturating_add(msg.tokens.cache_write.max(0) as u64);
monthly_entry.tokens.reasoning = monthly_entry
.tokens
.reasoning
.saturating_add(msg.tokens.reasoning.max(0) as u64);
monthly_entry.cost += msg_cost;
monthly_entry.message_count = monthly_entry.message_count.saturating_add(1);

let daily_entry = daily_map.entry(date).or_insert_with(|| DailyUsage {
date,
tokens: TokenBreakdown::default(),
Expand Down Expand Up @@ -388,6 +461,15 @@ impl DataLoader {
let mut daily: Vec<DailyUsage> = daily_map.into_values().collect();
daily.sort_by(|a, b| b.date.cmp(&a.date));

let mut monthly_models: Vec<MonthlyModelUsage> = monthly_map.into_values().collect();
monthly_models.sort_by(|a, b| {
b.month
.cmp(&a.month)
.then_with(|| b.cost.total_cmp(&a.cost))
.then_with(|| a.model.cmp(&b.model))
});
let total_months = month_set.len() as u32;

let total_tokens: u64 = models.iter().map(|m| m.tokens.total()).sum();
let total_cost: f64 = models
.iter()
Expand All @@ -401,6 +483,8 @@ impl DataLoader {
models,
agents,
daily,
monthly_models,
total_months,
graph: Some(graph),
total_tokens,
total_cost,
Expand Down
1 change: 1 addition & 0 deletions crates/tokscale-cli/src/tui/ui/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ fn render_main_row(frame: &mut Frame, app: &mut App, area: Rect) {
if !is_very_narrow {
let count_label = match app.current_tab {
Tab::Agents => format!(" ({} agents)", app.data.agents.len()),
Tab::Monthly => format!(" ({} entries)", app.data.monthly_models.len()),
_ => format!(" ({} models)", app.data.models.len()),
};
right_spans.push(Span::styled(
Expand Down
Loading