Skip to main content

vct_core/usage/
summary.rs

1//! Priced, aggregated `usage` summary shared by every frontend.
2//!
3//! Turns the roll-up's per-model and per-provider usage maps into the priced
4//! [`UsageSummary`] (sorted per-model rows, column totals, per-provider
5//! totals). This is ratatui-free business logic, so a non-CLI backend (e.g. a
6//! future GUI) builds the same summary without depending on `display`; the
7//! display layer only renders the result.
8
9use crate::models::{PerProviderUsage, ProviderActiveDays, UsageResult};
10use crate::pricing::CostSource;
11use crate::usage::StoredCosts;
12use serde_json::Value;
13use std::borrow::Cow;
14
15/// Per-provider cost basis, resolved to a [`CostSource`] per model.
16#[derive(Clone, Copy)]
17enum ProviderPricing<'a> {
18    /// File-based providers priced purely from LiteLLM.
19    Litellm,
20    /// Grok's context-gauge estimate: LiteLLM with an input-rate fallback for
21    /// entries that publish no cache-read price.
22    GrokGauge,
23    /// OpenCode: exact LiteLLM match, else its stored cost.
24    OpenCode(&'a crate::constants::FastHashMap<String, f64>),
25    /// Cursor local estimate: exact LiteLLM pricing, else zero.
26    CursorEstimate,
27    /// Hermes: exact LiteLLM match, else its stored cost.
28    Hermes(&'a crate::constants::FastHashMap<String, f64>),
29}
30
31impl ProviderPricing<'_> {
32    /// The [`CostSource`] to use for `model` under this provider's basis.
33    fn source_for(&self, model: &str) -> CostSource {
34        let stored =
35            |m: &crate::constants::FastHashMap<String, f64>| m.get(model).copied().unwrap_or(0.0);
36        match self {
37            Self::Litellm => CostSource::Litellm,
38            Self::GrokGauge => CostSource::GrokGauge,
39            Self::OpenCode(m) => CostSource::OpenCodeStored(stored(m)),
40            Self::CursorEstimate => CostSource::OpenCodeStored(0.0),
41            Self::Hermes(m) => CostSource::HermesStored(stored(m)),
42        }
43    }
44}
45
46/// Data structure for a usage row.
47///
48/// `output_tokens` is the user-visible response the model emitted;
49/// `reasoning_tokens` is the separately-billed "thinking" budget (Gemini
50/// `thoughts_tokens`, Codex `reasoning_output_tokens`, Copilot
51/// `reasoningTokens`). Display layers typically present their sum in a
52/// single "Output" column via `output_with_reasoning()` so the per-row
53/// numbers reconcile with `total`, while `cost` is computed against the
54/// per-token reasoning rate (when the model publishes one) via
55/// `calculate_cost`.
56#[derive(Default, Clone)]
57pub struct UsageRow {
58    /// Raw model name as reported by the session (the pricing-lookup key).
59    pub model: String, // 原始模型名稱
60    /// Name shown in the table; appends the fuzzy-matched pricing model in
61    /// parentheses when the lookup was not exact.
62    pub display_model: String, // 可能含 fuzzy match 提示的顯示名稱
63    /// Prompt (input) tokens.
64    pub input_tokens: i64,
65    /// User-visible response tokens, excluding reasoning.
66    pub output_tokens: i64,
67    /// Separately-billed "thinking" tokens.
68    pub reasoning_tokens: i64,
69    /// Tokens served from the prompt cache.
70    pub cache_read: i64,
71    /// Tokens written to the prompt cache.
72    pub cache_creation: i64,
73    /// Sum of all token buckets for this model.
74    pub total: i64,
75    /// LiteLLM-priced cost in USD for this model's tokens.
76    pub cost: f64,
77}
78
79impl UsageRow {
80    /// Sum of output and reasoning tokens — the "total model-emitted
81    /// tokens" figure most display tables want to show in an Output
82    /// column so the row adds up to `total`.
83    #[inline]
84    pub fn output_with_reasoning(&self) -> i64 {
85        self.output_tokens + self.reasoning_tokens
86    }
87}
88
89/// Column-wise totals across every [`UsageRow`] in a summary.
90#[derive(Default)]
91pub struct UsageTotals {
92    /// Summed prompt (input) tokens.
93    pub input_tokens: i64,
94    /// Summed response tokens, excluding reasoning.
95    pub output_tokens: i64,
96    /// Summed reasoning ("thinking") tokens.
97    pub reasoning_tokens: i64,
98    /// Summed cache-read tokens.
99    pub cache_read: i64,
100    /// Summed cache-creation tokens.
101    pub cache_creation: i64,
102    /// Summed total tokens.
103    pub total: i64,
104    /// Summed cost in USD.
105    pub cost: f64,
106}
107
108impl UsageTotals {
109    /// Adds every token bucket and the cost of `row` into these totals.
110    pub fn accumulate(&mut self, row: &UsageRow) {
111        self.input_tokens += row.input_tokens;
112        self.output_tokens += row.output_tokens;
113        self.reasoning_tokens += row.reasoning_tokens;
114        self.cache_read += row.cache_read;
115        self.cache_creation += row.cache_creation;
116        self.total += row.total;
117        self.cost += row.cost;
118    }
119
120    /// Same helper as [`UsageRow::output_with_reasoning`] for totals.
121    #[inline]
122    pub fn output_with_reasoning(&self) -> i64 {
123        self.output_tokens + self.reasoning_tokens
124    }
125}
126
127/// Per-provider totals for usage. `days_count` records how many distinct
128/// days contributed to these totals so the display layer can show readers
129/// the spread without computing a rate.
130#[derive(Default, Clone)]
131pub struct ProviderStats {
132    /// Total tokens attributed to this provider.
133    pub total_tokens: i64,
134    /// Total cost in USD attributed to this provider.
135    pub total_cost: f64,
136    /// Number of distinct days that contributed to these totals.
137    pub days_count: usize,
138}
139
140impl ProviderStats {
141    /// Adds `row`'s total tokens and cost into these stats.
142    fn accumulate_row(&mut self, row: &UsageRow) {
143        self.total_tokens += row.total;
144        self.total_cost += row.cost;
145    }
146}
147
148/// Type alias for usage totals grouped by provider.
149pub type UsageProviderTotals = crate::models::ProviderTotals<ProviderStats>;
150
151/// Fully priced usage view shared by every output mode.
152#[derive(Default)]
153pub struct UsageSummary {
154    /// Per-model rows, sorted by ascending cost (model name as tie-break).
155    pub rows: Vec<UsageRow>,
156    /// Column-wise totals across all rows.
157    pub totals: UsageTotals,
158    /// Per-provider totals for the summary footer.
159    pub provider_totals: UsageProviderTotals,
160}
161
162/// Calculate per-provider totals using **source-directory** attribution.
163///
164/// Token aggregation is fed directly from the `per_provider` map that
165/// `usage::aggregator` populates from each session's source directory, so the
166/// provider assignment is exact regardless of what model name the session
167/// happens to carry. The previous "averages" variant divided by
168/// `provider_days` to render a per-day rate; the structure is otherwise
169/// identical.
170pub fn calculate_provider_totals_from_per_provider(
171    per_provider: &PerProviderUsage,
172    provider_days: &ProviderActiveDays,
173    pricing_map: &crate::pricing::ModelPricingMap,
174    stored_costs: &StoredCosts,
175) -> UsageProviderTotals {
176    let mut totals = UsageProviderTotals::default();
177
178    totals.claude.days_count = provider_days.claude;
179    totals.codex.days_count = provider_days.codex;
180    totals.copilot.days_count = provider_days.copilot;
181    totals.gemini.days_count = provider_days.gemini;
182    totals.grok.days_count = provider_days.grok;
183    totals.opencode.days_count = provider_days.opencode;
184    totals.cursor.days_count = provider_days.cursor;
185    totals.hermes.days_count = provider_days.hermes;
186    totals.overall.days_count = provider_days.total;
187
188    accumulate_provider(
189        &mut totals.claude,
190        &per_provider.claude,
191        pricing_map,
192        ProviderPricing::Litellm,
193    );
194    accumulate_provider(
195        &mut totals.codex,
196        &per_provider.codex,
197        pricing_map,
198        ProviderPricing::Litellm,
199    );
200    accumulate_provider(
201        &mut totals.copilot,
202        &per_provider.copilot,
203        pricing_map,
204        ProviderPricing::Litellm,
205    );
206    accumulate_provider(
207        &mut totals.gemini,
208        &per_provider.gemini,
209        pricing_map,
210        ProviderPricing::Litellm,
211    );
212    accumulate_provider(
213        &mut totals.grok,
214        &per_provider.grok,
215        pricing_map,
216        ProviderPricing::GrokGauge,
217    );
218    accumulate_provider(
219        &mut totals.opencode,
220        &per_provider.opencode,
221        pricing_map,
222        ProviderPricing::OpenCode(&stored_costs.opencode),
223    );
224    accumulate_provider(
225        &mut totals.cursor,
226        &per_provider.cursor,
227        pricing_map,
228        ProviderPricing::CursorEstimate,
229    );
230    accumulate_provider(
231        &mut totals.hermes,
232        &per_provider.hermes,
233        pricing_map,
234        ProviderPricing::Hermes(&stored_costs.hermes),
235    );
236
237    // "All Providers" row sums every provider's totals directly rather
238    // than reusing the cross-provider merged `UsageData.models` map.
239    // That merged map de-duplicates a shared model like `claude-sonnet-4-6`
240    // (used by both Claude Code and Copilot CLI) into a single row, so
241    // the underlying tokens are *not* double-counted — but we lose the
242    // provider attribution needed to populate per-provider cost columns
243    // on the same table, and the single merged row would price with one
244    // model-lookup where summing per-provider already-priced stats keeps
245    // cost consistent with each provider's own row above.
246    totals.overall.total_tokens = totals.claude.total_tokens
247        + totals.codex.total_tokens
248        + totals.copilot.total_tokens
249        + totals.gemini.total_tokens
250        + totals.grok.total_tokens
251        + totals.opencode.total_tokens
252        + totals.cursor.total_tokens
253        + totals.hermes.total_tokens;
254    totals.overall.total_cost = totals.claude.total_cost
255        + totals.codex.total_cost
256        + totals.copilot.total_cost
257        + totals.gemini.total_cost
258        + totals.grok.total_cost
259        + totals.opencode.total_cost
260        + totals.cursor.total_cost
261        + totals.hermes.total_cost;
262
263    totals
264}
265
266/// Prices every model in `usage` under `pricing`'s cost basis and folds the
267/// results into `stats`.
268fn accumulate_provider(
269    stats: &mut ProviderStats,
270    usage: &UsageResult,
271    pricing_map: &crate::pricing::ModelPricingMap,
272    pricing: ProviderPricing,
273) {
274    for (model, raw_usage) in usage {
275        let row = extract_usage_row(model, raw_usage, pricing_map, pricing.source_for(model));
276        stats.accumulate_row(&row);
277    }
278}
279
280/// Build a summary from raw usage data.
281///
282/// `usage_data` is the cross-provider merged map (drives the per-model
283/// table); `per_provider` is the source-directory-scoped map (drives the
284/// per-provider footer). Keeping the two aggregations independent is what
285/// lets Copilot-originated Claude tokens stay attributed to Copilot even
286/// though they share a row with Claude Code tokens in the main table.
287pub fn build_usage_summary(
288    usage_data: &UsageResult,
289    per_provider: &PerProviderUsage,
290    provider_days: &ProviderActiveDays,
291    pricing_map: &crate::pricing::ModelPricingMap,
292    stored_costs: &StoredCosts,
293) -> UsageSummary {
294    if usage_data.is_empty() {
295        return UsageSummary::default();
296    }
297
298    let mut summary = UsageSummary::default();
299
300    // Pre-allocate rows vector
301    summary.rows.reserve(usage_data.len());
302
303    // Extract rows first so we can sort by cost
304    for (model, usage) in usage_data.iter() {
305        let (cost, matched_model) =
306            crate::usage::resolve_merged_model_cost(model, per_provider, pricing_map, stored_costs)
307                .unwrap_or_else(|| price_usage(model, usage, pricing_map, CostSource::Litellm));
308        let row = build_usage_row(model, usage, cost, matched_model);
309        summary.rows.push(row);
310    }
311
312    // Sort by cost ascending (higher cost at the bottom); tie-break by model name for stability
313    summary.rows.sort_by(|a, b| {
314        a.cost
315            .partial_cmp(&b.cost)
316            .unwrap_or(std::cmp::Ordering::Equal)
317            .then_with(|| a.model.cmp(&b.model))
318    });
319
320    for row in &summary.rows {
321        summary.totals.accumulate(row);
322    }
323
324    summary.provider_totals = calculate_provider_totals_from_per_provider(
325        per_provider,
326        provider_days,
327        pricing_map,
328        stored_costs,
329    );
330    summary
331}
332
333/// Builds one priced [`UsageRow`] from a model's raw usage `Value`.
334///
335/// Token counts come from [`extract_token_counts`](crate::utils::extract_token_counts);
336/// cost is resolved by [`resolve_model_cost`](crate::pricing::resolve_model_cost)
337/// under `source` (LiteLLM for file providers, the stored cost for OpenCode /
338/// Cursor). When a non-exact LiteLLM key was used, the matched model name is
339/// appended to `display_model` in parentheses.
340fn extract_usage_row(
341    model: &str,
342    usage: &Value,
343    pricing_map: &crate::pricing::ModelPricingMap,
344    source: CostSource,
345) -> UsageRow {
346    use crate::pricing::resolve_model_cost;
347    use crate::utils::extract_token_counts;
348
349    // Extract once and reuse for both pricing and the row (was extracted twice).
350    let counts = extract_token_counts(usage);
351    let (cost, matched_model) = resolve_model_cost(model, &counts, pricing_map, source);
352    build_usage_row_from_counts(model, &counts, cost, matched_model)
353}
354
355/// Prices one raw usage value under `source`.
356fn price_usage(
357    model: &str,
358    usage: &Value,
359    pricing_map: &crate::pricing::ModelPricingMap,
360    source: CostSource,
361) -> (f64, Option<String>) {
362    use crate::pricing::resolve_model_cost;
363    use crate::utils::extract_token_counts;
364
365    let counts = extract_token_counts(usage);
366    resolve_model_cost(model, &counts, pricing_map, source)
367}
368
369/// Builds one display row using an already-resolved cost.
370fn build_usage_row(
371    model: &str,
372    usage: &Value,
373    cost: f64,
374    matched_model: Option<String>,
375) -> UsageRow {
376    let counts = crate::utils::extract_token_counts(usage);
377    build_usage_row_from_counts(model, &counts, cost, matched_model)
378}
379
380/// Builds one display row from already-extracted token counts and a resolved
381/// cost, so callers that already have the counts do not re-walk the usage value.
382fn build_usage_row_from_counts(
383    model: &str,
384    counts: &crate::utils::TokenCounts,
385    cost: f64,
386    matched_model: Option<String>,
387) -> UsageRow {
388    // Use Cow<str> for display_model to avoid allocation when no annotation
389    let display_model = if let Some(matched) = &matched_model {
390        Cow::Owned(format!("{} ({})", model, matched))
391    } else {
392        Cow::Borrowed(model)
393    };
394
395    UsageRow {
396        model: model.to_string(),
397        display_model: display_model.into_owned(),
398        input_tokens: counts.input_tokens,
399        output_tokens: counts.output_tokens,
400        reasoning_tokens: counts.reasoning_tokens,
401        cache_read: counts.cache_read,
402        cache_creation: counts.cache_creation,
403        total: counts.total,
404        cost,
405    }
406}
407
408/// Returns the base model key used to merge rows across provider-routing
409/// prefixes: everything after the first `/`, or the whole name when there is no
410/// `/`. Unlike [`normalize_model_name`](crate::pricing::normalize_model_name)
411/// this does **not** strip version/date suffixes, so `gpt-5.5` and `gpt-5.4`
412/// stay distinct.
413fn base_model_key(model: &str) -> &str {
414    model.split_once('/').map(|(_, rest)| rest).unwrap_or(model)
415}
416
417/// Collapses rows by [`base_model_key`], summing every token bucket and the
418/// already-resolved cost, and shows every row under its bare base name.
419///
420/// Costs are summed verbatim: each input row was already priced against its
421/// full model name, so a merged `gpt-5.5` correctly adds the differently-priced
422/// `openai/gpt-5.5`, `azure/gpt-5.5`, and bare `gpt-5.5` pieces — re-pricing the
423/// merged token bucket under a single name would be wrong. Every output row is
424/// labeled with just the base name (the provider prefix is dropped even when a
425/// model has no duplicate, e.g. `opencode/big-pickle` -> `big-pickle`) with no
426/// count suffix, so the merged view reads uniformly. The result is re-sorted by
427/// ascending cost, tie-broken by model name, matching [`build_usage_summary`].
428pub fn merge_rows_by_base_model(rows: &[UsageRow]) -> Vec<UsageRow> {
429    use std::collections::HashMap;
430
431    let mut groups: HashMap<&str, Vec<&UsageRow>> = HashMap::new();
432    for row in rows {
433        groups
434            .entry(base_model_key(&row.model))
435            .or_default()
436            .push(row);
437    }
438
439    let mut merged: Vec<UsageRow> = Vec::with_capacity(groups.len());
440    for (key, members) in groups {
441        let mut acc = UsageRow {
442            model: key.to_string(),
443            display_model: key.to_string(),
444            ..UsageRow::default()
445        };
446        for m in members {
447            acc.input_tokens += m.input_tokens;
448            acc.output_tokens += m.output_tokens;
449            acc.reasoning_tokens += m.reasoning_tokens;
450            acc.cache_read += m.cache_read;
451            acc.cache_creation += m.cache_creation;
452            acc.total += m.total;
453            acc.cost += m.cost;
454        }
455        merged.push(acc);
456    }
457
458    // Same ordering as build_usage_summary so the merged view reads identically.
459    merged.sort_by(|a, b| {
460        a.cost
461            .partial_cmp(&b.cost)
462            .unwrap_or(std::cmp::Ordering::Equal)
463            .then_with(|| a.model.cmp(&b.model))
464    });
465    merged
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::pricing::{ModelPricing, ModelPricingMap, clear_pricing_cache};
472    use serde_json::json;
473
474    #[test]
475    fn merged_rows_include_grok_source_cost() {
476        clear_pricing_cache();
477        let mut raw_pricing = std::collections::HashMap::new();
478        raw_pricing.insert(
479            "shared-model".to_string(),
480            ModelPricing {
481                input_cost_per_token: 0.01,
482                ..Default::default()
483            },
484        );
485        let pricing_map = ModelPricingMap::new(raw_pricing);
486        let mut usage_data = UsageResult::default();
487        usage_data.insert("shared-model".to_string(), json!({"input_tokens": 200}));
488        let mut per_provider = PerProviderUsage::default();
489        per_provider
490            .claude
491            .insert("shared-model".to_string(), json!({"input_tokens": 100}));
492        per_provider
493            .grok
494            .insert("shared-model".to_string(), json!({"input_tokens": 100}));
495
496        let summary = build_usage_summary(
497            &usage_data,
498            &per_provider,
499            &ProviderActiveDays::default(),
500            &pricing_map,
501            &StoredCosts::default(),
502        );
503
504        assert!((summary.rows[0].cost - 2.0).abs() < 1e-9);
505    }
506
507    #[test]
508    fn merged_rows_price_opencode_fallback_only_for_opencode_tokens() {
509        clear_pricing_cache();
510
511        let mut raw_pricing = std::collections::HashMap::new();
512        raw_pricing.insert(
513            "shared".to_string(),
514            ModelPricing {
515                input_cost_per_token: 0.01,
516                ..Default::default()
517            },
518        );
519        let pricing_map = ModelPricingMap::new(raw_pricing);
520
521        let mut usage_data = UsageResult::default();
522        usage_data.insert("shared-pro".to_string(), json!({"input_tokens": 200}));
523
524        let mut per_provider = PerProviderUsage::default();
525        per_provider
526            .claude
527            .insert("shared-pro".to_string(), json!({"input_tokens": 100}));
528        per_provider
529            .opencode
530            .insert("shared-pro".to_string(), json!({"input_tokens": 100}));
531
532        let mut stored_costs = StoredCosts::default();
533        stored_costs.opencode.insert("shared-pro".to_string(), 7.0);
534
535        let summary = build_usage_summary(
536            &usage_data,
537            &per_provider,
538            &ProviderActiveDays::default(),
539            &pricing_map,
540            &stored_costs,
541        );
542
543        assert_eq!(summary.rows.len(), 1);
544        assert!((summary.rows[0].cost - 8.0).abs() < 1e-9);
545        assert_eq!(summary.rows[0].display_model, "shared-pro (shared)");
546    }
547
548    #[test]
549    fn cursor_row_uses_exact_litellm_price_and_ignores_legacy_stored_cost() {
550        clear_pricing_cache();
551
552        // An exact LiteLLM price exists for the model Cursor reports.
553        let mut raw_pricing = std::collections::HashMap::new();
554        raw_pricing.insert(
555            "gemini-2.5-pro".to_string(),
556            ModelPricing {
557                input_cost_per_token: 0.01,
558                ..Default::default()
559            },
560        );
561        let pricing_map = ModelPricingMap::new(raw_pricing);
562
563        let mut usage_data = UsageResult::default();
564        usage_data.insert("gemini-2.5-pro".to_string(), json!({"input_tokens": 1000}));
565
566        let mut per_provider = PerProviderUsage::default();
567        per_provider
568            .cursor
569            .insert("gemini-2.5-pro".to_string(), json!({"input_tokens": 1000}));
570
571        // A legacy caller may still populate the retained public field.
572        let mut stored_costs = StoredCosts::default();
573        stored_costs
574            .cursor
575            .insert("gemini-2.5-pro".to_string(), 0.3425);
576
577        let summary = build_usage_summary(
578            &usage_data,
579            &per_provider,
580            &ProviderActiveDays::default(),
581            &pricing_map,
582            &stored_costs,
583        );
584
585        assert_eq!(summary.rows.len(), 1);
586        assert!((summary.rows[0].cost - 10.0).abs() < 1e-9);
587    }
588
589    #[test]
590    fn stored_costs_do_not_cross_contaminate_on_name_collision() {
591        clear_pricing_cache();
592        // Empty pricing: OpenCode falls back to its stored cost while Cursor's
593        // local estimate stays unpriced.
594        let pricing_map = ModelPricingMap::new(std::collections::HashMap::new());
595
596        // The same bare model name appears under both OpenCode and Cursor.
597        let mut usage_data = UsageResult::default();
598        usage_data.insert("collide".to_string(), json!({"input_tokens": 10}));
599
600        let mut per_provider = PerProviderUsage::default();
601        per_provider
602            .opencode
603            .insert("collide".to_string(), json!({"input_tokens": 5}));
604        per_provider
605            .cursor
606            .insert("collide".to_string(), json!({"input_tokens": 5}));
607
608        let mut stored_costs = StoredCosts::default();
609        stored_costs.opencode.insert("collide".to_string(), 5.0);
610        stored_costs.cursor.insert("collide".to_string(), 3.0);
611
612        let summary = build_usage_summary(
613            &usage_data,
614            &per_provider,
615            &ProviderActiveDays::default(),
616            &pricing_map,
617            &stored_costs,
618        );
619
620        assert_eq!(summary.rows.len(), 1);
621        assert!((summary.rows[0].cost - 5.0).abs() < 1e-9);
622        assert!((summary.provider_totals.opencode.total_cost - 5.0).abs() < 1e-9);
623        assert!(summary.provider_totals.cursor.total_cost.abs() < 1e-9);
624    }
625
626    fn row(model: &str, input: i64, total: i64, cost: f64) -> UsageRow {
627        UsageRow {
628            model: model.to_string(),
629            display_model: model.to_string(),
630            input_tokens: input,
631            total,
632            cost,
633            ..UsageRow::default()
634        }
635    }
636
637    #[test]
638    fn merge_collapses_prefixed_and_bare_names_and_sums() {
639        let rows = vec![
640            row("openai/gpt-5.5", 100, 100, 0.20),
641            row("azure/gpt-5.5", 200, 200, 3.00),
642            row("gpt-5.5", 300, 300, 5.00),
643        ];
644
645        let merged = merge_rows_by_base_model(&rows);
646
647        assert_eq!(merged.len(), 1);
648        let m = &merged[0];
649        assert_eq!(m.model, "gpt-5.5");
650        assert_eq!(m.display_model, "gpt-5.5");
651        assert_eq!(m.input_tokens, 600);
652        assert_eq!(m.total, 600);
653        assert!((m.cost - 8.20).abs() < 1e-9);
654    }
655
656    #[test]
657    fn merge_keeps_different_versions_apart() {
658        // gpt-5.5 has two provider variants (they merge); gpt-5.4 has one. The
659        // key check: the 5.4 tokens never fold into the 5.5 row.
660        let rows = vec![
661            row("openai/gpt-5.5", 10, 10, 1.0),
662            row("azure/gpt-5.5", 30, 30, 3.0),
663            row("openai/gpt-5.4", 20, 20, 2.0),
664        ];
665
666        let merged = merge_rows_by_base_model(&rows);
667
668        assert_eq!(merged.len(), 2);
669        // The two gpt-5.5 rows collapse to one base row; gpt-5.4 stays separate.
670        let five_five = merged.iter().find(|r| r.model == "gpt-5.5").unwrap();
671        assert_eq!(five_five.display_model, "gpt-5.5");
672        assert_eq!(five_five.total, 40);
673        // The lone 5.4 also shows under its bare base name, keeping its tokens.
674        let five_four = merged.iter().find(|r| r.model == "gpt-5.4").unwrap();
675        assert_eq!(five_four.display_model, "gpt-5.4");
676        assert_eq!(five_four.total, 20);
677    }
678
679    #[test]
680    fn merge_strips_prefix_from_single_row() {
681        // Even a model with no duplicate drops its provider prefix (and any
682        // fuzzy-match hint) so the merged view reads uniformly.
683        let mut only = row("deepseek/deepseek-v4-pro", 5, 5, 1.5);
684        only.display_model = "deepseek/deepseek-v4-pro (deepseek-v4)".to_string();
685
686        let merged = merge_rows_by_base_model(std::slice::from_ref(&only));
687
688        assert_eq!(merged.len(), 1);
689        assert_eq!(merged[0].model, "deepseek-v4-pro");
690        assert_eq!(merged[0].display_model, "deepseek-v4-pro");
691        assert_eq!(merged[0].total, 5);
692    }
693
694    #[test]
695    fn merge_only_strips_first_slash_segment() {
696        assert_eq!(base_model_key("a/b/c"), "b/c");
697        assert_eq!(base_model_key("gpt-5.5"), "gpt-5.5");
698        assert_eq!(base_model_key("openai/gpt-5.5"), "gpt-5.5");
699    }
700
701    #[test]
702    fn merge_reorders_by_ascending_cost() {
703        let rows = vec![
704            row("openai/gpt-5.5", 1, 1, 9.0),
705            row("azure/gpt-5.5", 1, 1, 9.0),
706            row("cheap-model", 1, 1, 0.01),
707        ];
708
709        let merged = merge_rows_by_base_model(&rows);
710
711        assert_eq!(merged.len(), 2);
712        // cheap-model (0.01) sorts before the merged gpt-5.5 row (18.0).
713        assert_eq!(merged[0].model, "cheap-model");
714        assert_eq!(merged[1].model, "gpt-5.5");
715        assert!((merged[1].cost - 18.0).abs() < 1e-9);
716    }
717}