use std::collections::HashMap;
use ratatui::style::Color;
use super::data::ModelUsage;
use super::ui::widgets::{get_provider_from_model, get_provider_shade, provider_has_palette};
pub fn model_shade_key(provider: &str, model: &str) -> String {
format!("{provider}\0{model}")
}
pub fn build_model_shade_map(models: &[ModelUsage]) -> HashMap<String, Color> {
let mut by_provider: HashMap<&str, HashMap<&str, f64>> = HashMap::new();
for m in models {
let provider = provider_color_key(&m.provider, &m.color_key);
let cost = if m.cost.is_finite() { m.cost } else { 0.0 };
*by_provider
.entry(provider)
.or_default()
.entry(m.color_key.as_str())
.or_insert(0.0) += cost;
}
let mut map = HashMap::new();
for (provider, models_map) in by_provider {
let mut ranked: Vec<(&str, f64)> = models_map.into_iter().collect();
ranked.sort_by(|a, b| {
family_tier(provider, a.0)
.cmp(&family_tier(provider, b.0))
.then_with(|| model_version(b.0).cmp(&model_version(a.0)))
.then_with(|| b.1.total_cmp(&a.1))
.then_with(|| a.0.cmp(b.0))
});
for (rank, (name, _)) in ranked.iter().enumerate() {
map.insert(
model_shade_key(provider, name),
get_provider_shade(provider, rank),
);
}
}
map
}
pub(crate) fn provider_color_key<'a>(provider: &'a str, model: &'a str) -> &'a str {
if provider.is_empty() || provider.contains(", ") || !provider_has_palette(provider) {
get_provider_from_model(model)
} else {
provider
}
}
fn family_tier(provider: &str, model: &str) -> u8 {
if !provider.to_lowercase().contains("anthropic") {
return 0;
}
let lower = model.to_lowercase();
if lower
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|token| token == "fable")
{
0
} else if lower.contains("opus") {
1
} else if lower.contains("sonnet") {
2
} else if lower.contains("haiku") {
3
} else {
4
}
}
fn model_version(model: &str) -> (u32, u32) {
let tokens: Vec<&str> = model
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| !t.is_empty())
.collect();
for (i, token) in tokens.iter().enumerate() {
let Some(major) = leading_number(token) else {
continue;
};
if major >= 1000 {
return (0, 0);
}
let minor = tokens
.get(i + 1)
.and_then(|t| t.parse::<u32>().ok())
.filter(|&m| m < 1000)
.unwrap_or(0);
return (major, minor);
}
(0, 0)
}
fn leading_number(token: &str) -> Option<u32> {
let end = token
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map_or(token.len(), |(i, _)| i);
token[..end].parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::data::{ModelUsage, TokenBreakdown};
fn usage(model: &str, provider: &str, cost: f64) -> ModelUsage {
ModelUsage {
model: model.to_string(),
color_key: model.to_string(),
provider: provider.to_string(),
client: "claude".to_string(),
workspace_key: None,
workspace_label: None,
tokens: TokenBreakdown::default(),
cost,
performance: Default::default(),
session_count: 1,
}
}
fn shade(map: &HashMap<String, Color>, provider: &str, model: &str) -> Color {
map.get(&model_shade_key(provider, model)).copied().unwrap()
}
#[test]
fn provider_color_key_routes_gateways_to_model_vendor() {
assert_eq!(
provider_color_key("anthropic", "claude-opus-4-8"),
"anthropic"
);
assert_eq!(provider_color_key("openai", "gpt-5.4"), "openai");
assert_eq!(
provider_color_key("github-copilot", "claude-fable-5"),
"anthropic"
);
assert_eq!(
provider_color_key("github-copilot", "gpt-5.3-codex"),
"openai"
);
assert_eq!(provider_color_key("fireworks_ai", "GLM 5.2"), "zai");
assert_eq!(
provider_color_key("fireworks_ai", "Kimi K2.7 Code"),
"moonshotai"
);
assert_eq!(provider_color_key("", "claude-fable-5"), "anthropic");
assert_eq!(
provider_color_key("anthropic, github-copilot", "claude-fable-5"),
"anthropic"
);
}
#[test]
fn gateway_and_native_claude_share_one_shade_bucket() {
let map = build_model_shade_map(&[
usage("claude-fable-5", "github-copilot", 3.0),
usage("claude-opus-4-8", "anthropic", 100.0),
]);
assert_eq!(
shade(&map, "anthropic", "claude-fable-5"),
get_provider_shade("anthropic", 0),
"copilot fable takes the flagship Anthropic shade"
);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-8"),
get_provider_shade("anthropic", 1),
"opus ranks below fable in the shared bucket"
);
}
#[test]
fn model_version_parses_common_id_shapes() {
assert_eq!(model_version("claude-opus-4-6"), (4, 6));
assert_eq!(model_version("claude-4-5-opus-high-thinking"), (4, 5));
assert_eq!(model_version("claude-fable-5"), (5, 0));
assert_eq!(model_version("claude-fable-5[1m]"), (5, 0));
assert_eq!(model_version("gpt-5.4"), (5, 4));
assert_eq!(model_version("claude-3-5-sonnet-20241022"), (3, 5));
assert_eq!(model_version("gpt-4-turbo"), (4, 0));
assert_eq!(model_version("gpt-4o"), (4, 0));
assert_eq!(model_version("gpt-4o-mini"), (4, 0));
assert_eq!(model_version("gpt-3.5-turbo"), (3, 5));
assert_eq!(model_version("gpt-4o[1m]"), (4, 0));
assert_eq!(model_version("o1-2024-12-17"), (0, 0));
assert_eq!(model_version("codex-mini-latest"), (0, 0));
}
#[test]
fn fable_outranks_higher_cost_opus() {
let map = build_model_shade_map(&[
usage("claude-opus-4-6", "anthropic", 900.0),
usage("claude-fable-5", "anthropic", 1.0),
]);
assert_eq!(
shade(&map, "anthropic", "claude-fable-5"),
get_provider_shade("anthropic", 0)
);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-6"),
get_provider_shade("anthropic", 1)
);
}
#[test]
fn newer_opus_outranks_older_despite_lower_cost() {
let map = build_model_shade_map(&[
usage("claude-opus-4-5", "anthropic", 500.0),
usage("claude-opus-4-7", "anthropic", 10.0),
usage("claude-opus-4-6", "anthropic", 300.0),
]);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-7"),
get_provider_shade("anthropic", 0)
);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-6"),
get_provider_shade("anthropic", 1)
);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-5"),
get_provider_shade("anthropic", 2)
);
}
#[test]
fn family_tier_beats_version_within_anthropic() {
let map = build_model_shade_map(&[
usage("claude-sonnet-5", "anthropic", 800.0),
usage("claude-opus-4-1", "anthropic", 2.0),
]);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-1"),
get_provider_shade("anthropic", 0)
);
assert_eq!(
shade(&map, "anthropic", "claude-sonnet-5"),
get_provider_shade("anthropic", 1)
);
}
#[test]
fn variant_suffixes_group_with_their_base_version() {
let map = build_model_shade_map(&[
usage("claude-opus-4-5-thinking-high", "anthropic", 50.0),
usage("claude-opus-4-6", "anthropic", 10.0),
usage("claude-opus-4-5", "anthropic", 100.0),
]);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-6"),
get_provider_shade("anthropic", 0)
);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-5"),
get_provider_shade("anthropic", 1)
);
assert_eq!(
shade(&map, "anthropic", "claude-opus-4-5-thinking-high"),
get_provider_shade("anthropic", 2)
);
}
#[test]
fn non_anthropic_providers_rank_by_version_first() {
let map = build_model_shade_map(&[
usage("gpt-4", "openai", 700.0),
usage("gpt-5.4", "openai", 5.0),
]);
assert_eq!(
shade(&map, "openai", "gpt-5.4"),
get_provider_shade("openai", 0)
);
assert_eq!(
shade(&map, "openai", "gpt-4"),
get_provider_shade("openai", 1)
);
}
#[test]
fn alphanumeric_versions_outrank_older_numeric_ones() {
let map = build_model_shade_map(&[
usage("gpt-3.5-turbo", "openai", 900.0),
usage("gpt-4o", "openai", 1.0),
]);
assert_eq!(
shade(&map, "openai", "gpt-4o"),
get_provider_shade("openai", 0)
);
assert_eq!(
shade(&map, "openai", "gpt-3.5-turbo"),
get_provider_shade("openai", 1)
);
}
}