Skip to content
This repository was archived by the owner on Jul 30, 2026. It is now read-only.
Merged
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
251 changes: 215 additions & 36 deletions crates/tokscale-cli/src/tui/app.rs

Large diffs are not rendered by default.

277 changes: 275 additions & 2 deletions crates/tokscale-cli/src/tui/cache.rs

Large diffs are not rendered by default.

110 changes: 89 additions & 21 deletions crates/tokscale-cli/src/tui/colors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ fn parse_catalog_color(hex: &str) -> Color {
parse_hex_color(hex).expect("client catalog colors are validated as #RRGGBB")
}

pub fn get_model_color(_model: &str) -> Color {
get_provider_shade("unknown", 0)
}

/// Returns the shade for a given `(provider, rank)` pair.
/// Honors `[colors.providers]` config overrides at every rank by deriving
/// a 7-step lighten-to-white palette from the override base color.
Expand Down Expand Up @@ -168,38 +164,72 @@ pub fn model_shade_key(provider: &str, model: &str) -> String {
format!("{provider}\0{model}")
}

/// Color assignment for the model charts: per-`(provider, model)` shades plus
/// the resolved provider for every canonical model.
#[derive(Debug, Default)]
pub struct ModelShadeMap {
pub shades: HashMap<String, Color>,
pub providers: HashMap<String, String>,
}

/// Builds a `(provider, model) -> Color` map where each provider's models are
/// cost-ranked; rank 0 (highest cost) gets the base provider color and later
/// ranks get progressively lighter shades.
///
/// Aggregates cost per (provider, model) so the same model appearing in
/// multiple group-by buckets (e.g. `GroupBy::WorkspaceModel`) doesn't inflate
/// the rank count. Ties on cost are resolved by model name so shade assignment
/// stays deterministic across refreshes.
pub fn build_model_shade_map(models: &[ModelUsage]) -> HashMap<String, Color> {
let mut by_provider: HashMap<&str, HashMap<&str, f64>> = HashMap::new();
/// Aggregation is canonical (ADR 0026): cost is summed per bare model id, and
/// each model resolves to one deterministic provider — the lexicographically
/// smallest provider color key across its entries, which matches the first
/// segment of the sorted merged-provider string a `GroupBy::Model` projection
/// produces. The same model therefore gets the same shade no matter how the
/// active grouping buckets `UsageData.models` (e.g. `ClientProviderModel`
/// splitting providers, `WorkspaceModel` repeating rows per workspace). Ties
/// on cost are resolved by model name so shade assignment stays deterministic
/// across refreshes.
pub fn build_model_shade_map(models: &[ModelUsage]) -> ModelShadeMap {
let mut cost_by_model: HashMap<&str, f64> = HashMap::new();
let mut provider_by_model: HashMap<&str, &str> = HashMap::new();
for m in models {
let provider = provider_color_key(&m.provider);
let cost = if m.cost.is_finite() { m.cost } else { 0.0 };
*by_provider
*cost_by_model.entry(m.model.as_str()).or_insert(0.0) += cost;
provider_by_model
.entry(m.model.as_str())
.and_modify(|current| {
if provider < *current {
*current = provider;
}
})
.or_insert(provider);
}

let mut by_provider: HashMap<&str, Vec<(&str, f64)>> = HashMap::new();
for (model, cost) in &cost_by_model {
let provider = provider_by_model[model];
by_provider
.entry(provider)
.or_default()
.entry(m.model.as_str())
.or_insert(0.0) += cost;
.push((*model, *cost));
}

let mut map = HashMap::new();
let mut shades = HashMap::new();
for (provider, models_map) in by_provider {
let mut ranked: Vec<(&str, f64)> = models_map.into_iter().collect();
let mut ranked = models_map;
ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0)));
for (rank, (name, _)) in ranked.iter().enumerate() {
map.insert(
shades.insert(
model_shade_key(provider, name),
get_provider_shade(provider, rank),
);
}
}
map

ModelShadeMap {
shades,
providers: provider_by_model
.into_iter()
.map(|(model, provider)| (model.to_string(), provider.to_string()))
.collect(),
}
}

#[cfg(test)]
Expand All @@ -226,17 +256,55 @@ mod tests {
TokscaleConfig::initialize_default_for_tests();
let map = build_model_shade_map(&[model_usage("", "u2")]);

assert!(map.contains_key(&model_shade_key("unknown", "u2")));
assert!(!map.contains_key(&model_shade_key("unisound", "u2")));
assert!(map.shades.contains_key(&model_shade_key("unknown", "u2")));
assert!(!map.shades.contains_key(&model_shade_key("unisound", "u2")));
}

#[test]
fn merged_provider_uses_first_provider_shade_key() {
TokscaleConfig::initialize_default_for_tests();
let map = build_model_shade_map(&[model_usage("openai, anthropic", "shared-model")]);

assert!(map.contains_key(&model_shade_key("openai", "shared-model")));
assert!(!map.contains_key(&model_shade_key("anthropic", "shared-model")));
assert!(map
.shades
.contains_key(&model_shade_key("openai", "shared-model")));
assert!(!map
.shades
.contains_key(&model_shade_key("anthropic", "shared-model")));
}

#[test]
fn shade_map_is_grouping_invariant_for_split_provider_buckets() {
TokscaleConfig::initialize_default_for_tests();
// GroupBy::Model shape: one entry per model with the sorted
// merged-provider string and the total cost.
let merged = build_model_shade_map(&[
model_usage("anthropic, openai", "shared-model"),
model_usage("openai", "openai-only"),
]);
// GroupBy::ClientProviderModel shape: the same messages bucketed per
// provider, so costs are split across entries.
let mut split_entries = vec![
model_usage("anthropic", "shared-model"),
model_usage("openai", "shared-model"),
model_usage("openai", "openai-only"),
];
split_entries[0].cost = 0.6;
split_entries[1].cost = 0.4;
let split = build_model_shade_map(&split_entries);

assert_eq!(merged.shades, split.shades);
assert_eq!(merged.providers, split.providers);
assert_eq!(
split.providers.get("shared-model").map(String::as_str),
Some("anthropic")
);
// Costs are summed per canonical model, so ranks match the merged
// projection instead of the per-bucket splits.
assert_eq!(
split.shades.get(&model_shade_key("openai", "openai-only")),
Some(&get_provider_shade("openai", 0))
);
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions crates/tokscale-cli/src/tui/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,8 +737,11 @@ after"#,
"claude-sonnet-4".to_string(),
DailyModelInfo {
provider: "anthropic".to_string(),
model_id: "claude-sonnet-4".to_string(),
display_name: "claude-sonnet-4".to_string(),
color_key: "claude-sonnet-4".to_string(),
workspace_key: None,
workspace_label: None,
tokens: tokens.clone(),
cost,
messages: 1,
Expand Down
102 changes: 85 additions & 17 deletions crates/tokscale-cli/src/tui/export.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,45 @@
use anyhow::Result;
use serde_json::json;
use tokscale_core::GroupBy;

use super::data::UsageData;

/// Serializes `UsageData` into the pretty-printed JSON payload used by the
/// `e` export hotkey. Pure: callers are responsible for file I/O and any
/// user-facing status messages.
pub fn build_export_json(data: &UsageData) -> Result<String> {
pub fn build_export_json(data: &UsageData, group_by: &GroupBy) -> Result<String> {
let export_data = json!({
"models": data.models.iter().map(|m| json!({
"model": m.model,
"provider": m.provider,
"client": m.client,
"tokens": {
"input": m.tokens.input,
"output": m.tokens.output,
"cacheRead": m.tokens.cache_read,
"cacheWrite": m.tokens.cache_write,
"total": m.tokens.total()
},
"cost": m.cost,
"performance": m.performance,
"sessionCount": m.session_count
})).collect::<Vec<_>>(),
"groupBy": group_by.to_string(),
"models": data.models.iter().map(|m| {
let mut entry = json!({
"model": m.model,
"provider": m.provider,
"client": m.client,
"tokens": {
"input": m.tokens.input,
"output": m.tokens.output,
"cacheRead": m.tokens.cache_read,
"cacheWrite": m.tokens.cache_write,
"total": m.tokens.total()
},
"cost": m.cost,
"performance": m.performance,
"sessionCount": m.session_count
});
// Workspace dimension rides in structured fields (ADR 0026),
// mirroring the `models` CLI JSON shape.
if *group_by == GroupBy::WorkspaceModel {
entry["workspaceKey"] = m
.workspace_key
.as_deref()
.map(serde_json::Value::from)
.unwrap_or(serde_json::Value::Null);
if let Some(label) = m.workspace_label.as_deref() {
entry["workspaceLabel"] = label.into();
}
}
entry
}).collect::<Vec<_>>(),
"agents": data.agents.iter().map(|a| json!({
"agent": a.agent,
"clients": a.clients,
Expand Down Expand Up @@ -63,6 +80,7 @@ pub fn build_export_json(data: &UsageData) -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::data::ModelUsage;

#[test]
fn exported_report_keeps_degraded_source_health() {
Expand All @@ -81,7 +99,7 @@ mod tests {
}];

let json: serde_json::Value =
serde_json::from_str(&build_export_json(&data).unwrap()).unwrap();
serde_json::from_str(&build_export_json(&data, &GroupBy::Model).unwrap()).unwrap();

assert_eq!(json["health"]["complete"], false);
assert_eq!(json["health"]["degradedSources"], 1);
Expand All @@ -91,4 +109,54 @@ mod tests {
assert_eq!(json["health"]["issues"][0]["issue"], "missing-model");
assert!(json["health"].get("sources").is_none());
}

fn model_entry(workspace_key: Option<&str>, workspace_label: Option<&str>) -> ModelUsage {
ModelUsage {
model: "claude-sonnet-4.5".to_string(),
provider: "anthropic".to_string(),
client: "claude".to_string(),
workspace_key: workspace_key.map(str::to_string),
workspace_label: workspace_label.map(str::to_string),
tokens: Default::default(),
cost: 1.0,
performance: Default::default(),
session_count: 1,
}
}

#[test]
fn exported_report_carries_group_by_and_workspace_fields() {
let data = UsageData {
models: vec![
model_entry(Some("/repo-a"), Some("repo-a")),
model_entry(None, Some("Unknown workspace")),
],
..UsageData::default()
};

let json: serde_json::Value =
serde_json::from_str(&build_export_json(&data, &GroupBy::WorkspaceModel).unwrap())
.unwrap();

assert_eq!(json["groupBy"], "workspace,model");
assert_eq!(json["models"][0]["workspaceKey"], "/repo-a");
assert_eq!(json["models"][0]["workspaceLabel"], "repo-a");
assert_eq!(json["models"][1]["workspaceKey"], serde_json::Value::Null);
assert_eq!(json["models"][1]["workspaceLabel"], "Unknown workspace");
}

#[test]
fn exported_report_omits_workspace_fields_outside_workspace_grouping() {
let data = UsageData {
models: vec![model_entry(Some("/repo-a"), Some("repo-a"))],
..UsageData::default()
};

let json: serde_json::Value =
serde_json::from_str(&build_export_json(&data, &GroupBy::Model).unwrap()).unwrap();

assert_eq!(json["groupBy"], "model");
assert!(json["models"][0].get("workspaceKey").is_none());
assert!(json["models"][0].get("workspaceLabel").is_none());
}
}
Loading
Loading