From 7588e48e14a52f800db5e17946bd2f81aae84e39 Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 27 Feb 2026 17:44:54 -0700 Subject: [PATCH 1/5] feat: add Gemini 3.1 Pro pricing and model aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tiered pricing: $2.00/$12.00 per 1M tokens (≤200k), $4.00/$18.00 (>200k) - Google-style caching tiers: $0.20 (≤200k), $0.40 (>200k) - Aliases: gemini-3.1-pro-preview, gemini-3.1-pro - Fix pre-existing clippy::collapsible_match warnings --- src/analyzers/codex_cli.rs | 76 +++++++++---------- src/analyzers/copilot.rs | 13 ++-- src/models.rs | 31 ++++++++ src/tui.rs | 152 ++++++++++++++++++------------------- 4 files changed, 148 insertions(+), 124 deletions(-) diff --git a/src/analyzers/codex_cli.rs b/src/analyzers/codex_cli.rs index 69de15e..060f50c 100644 --- a/src/analyzers/codex_cli.rs +++ b/src/analyzers/codex_cli.rs @@ -392,45 +392,43 @@ pub(crate) fn parse_codex_cli_jsonl_file( session_name: effective_name, }); } - "assistant" => { - // Token usage is now emitted immediately when processing token_count - // events. We still track assistant messages without additional stats - // to avoid double-counting when Codex emits separate reasoning/tool - // outputs. - if !saw_token_usage { - let model_state = session_model.clone().unwrap_or_else(|| { - let fallback = SessionModel::inferred( - DEFAULT_FALLBACK_MODEL.to_string(), - ); - warn_once(format!( - "WARNING: session {file_path_str} missing model metadata; using fallback model {} for cost estimation.", - fallback.name - )); - session_model = Some(fallback.clone()); - fallback - }); - - entries.push(ConversationMessage { - application: Application::CodexCli, - model: Some(model_state.name.clone()), - global_hash: hash_text(&format!( - "{}_{}_assistant_{}", - file_path_str, - wrapper.timestamp.to_rfc3339(), - entries.len() - )), - local_hash: None, - conversation_hash: hash_text(&file_path_str), - date: wrapper.timestamp, - project_hash: "".to_string(), - stats: Stats::default(), - role: MessageRole::Assistant, - uuid: None, - session_name: session_name - .clone() - .or_else(|| fallback_session_name.clone()), - }); - } + // Token usage is now emitted immediately when processing token_count + // events. We still track assistant messages without additional stats + // to avoid double-counting when Codex emits separate reasoning/tool + // outputs. + "assistant" if !saw_token_usage => { + let model_state = session_model.clone().unwrap_or_else(|| { + let fallback = SessionModel::inferred( + DEFAULT_FALLBACK_MODEL.to_string(), + ); + warn_once(format!( + "WARNING: session {file_path_str} missing model metadata; using fallback model {} for cost estimation.", + fallback.name + )); + session_model = Some(fallback.clone()); + fallback + }); + + entries.push(ConversationMessage { + application: Application::CodexCli, + model: Some(model_state.name.clone()), + global_hash: hash_text(&format!( + "{}_{}_assistant_{}", + file_path_str, + wrapper.timestamp.to_rfc3339(), + entries.len() + )), + local_hash: None, + conversation_hash: hash_text(&file_path_str), + date: wrapper.timestamp, + project_hash: "".to_string(), + stats: Stats::default(), + role: MessageRole::Assistant, + uuid: None, + session_name: session_name + .clone() + .or_else(|| fallback_session_name.clone()), + }); } _ => {} } diff --git a/src/analyzers/copilot.rs b/src/analyzers/copilot.rs index 1ec28f2..40e94a0 100644 --- a/src/analyzers/copilot.rs +++ b/src/analyzers/copilot.rs @@ -149,15 +149,14 @@ fn count_tokens(text: &str) -> u64 { // Recursively extract all text content from a nested JSON structure fn extract_text_from_value(value: &simd_json::OwnedValue, accumulated_text: &mut String) { match value { - simd_json::OwnedValue::String(s) => { - // Only accumulate if it's a "text" field value, not metadata like URIs + // Only accumulate if it's a "text" field value, not metadata like URIs + simd_json::OwnedValue::String(s) if !s.starts_with("vscode-") && !s.starts_with("file://") - && !s.starts_with("ssh-remote") - { - accumulated_text.push_str(s); - accumulated_text.push(' '); - } + && !s.starts_with("ssh-remote") => + { + accumulated_text.push_str(s); + accumulated_text.push(' '); } simd_json::OwnedValue::Object(obj) => { // Look for "text" fields specifically diff --git a/src/models.rs b/src/models.rs index 18f2228..65fb594 100644 --- a/src/models.rs +++ b/src/models.rs @@ -506,6 +506,35 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { }, is_estimated: false, }, + "gemini-3.1-pro-preview" => ModelInfo { + pricing: PricingStructure::Tiered { + tiers: &[ + PricingTier { + max_tokens: Some(200_000), + input_per_1m: 2.0, + output_per_1m: 12.0, + }, + PricingTier { + max_tokens: None, + input_per_1m: 4.0, + output_per_1m: 18.0, + }, + ], + }, + caching: CachingSupport::Google { + tiers: &[ + CachingTier { + max_tokens: Some(200_000), + cached_input_per_1m: 0.20, + }, + CachingTier { + max_tokens: None, + cached_input_per_1m: 0.40, + }, + ], + }, + is_estimated: false, + }, "gemini-3-pro-preview-11-2025" => ModelInfo { pricing: PricingStructure::Tiered { tiers: &[ @@ -875,6 +904,8 @@ static MODEL_ALIASES: phf::Map<&'static str, &'static str> = phf_map! { "gemini-3-flash-preview" => "gemini-3-flash-preview", "gemini-3-flash-preview-12-2025" => "gemini-3-flash-preview", "gemini-3-flash" => "gemini-3-flash-preview", + "gemini-3.1-pro-preview" => "gemini-3.1-pro-preview", + "gemini-3.1-pro" => "gemini-3.1-pro-preview", "gemini-3-pro-preview-11-2025" => "gemini-3-pro-preview-11-2025", "gemini-3-pro-preview" => "gemini-3-pro-preview-11-2025", "gemini-3-pro" => "gemini-3-pro-preview-11-2025", diff --git a/src/tui.rs b/src/tui.rs index e9b8ca6..59329f9 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -365,61 +365,59 @@ async fn run_app( } match key.code { - KeyCode::Left | KeyCode::Char('h') => { - if *selected_tab > 0 { - *selected_tab -= 1; + KeyCode::Left | KeyCode::Char('h') if *selected_tab > 0 => { + *selected_tab -= 1; - if let StatsViewMode::Session = *stats_view_mode - && let Some(table_state) = table_states.get_mut(*selected_tab) - && let Some(view) = filtered_stats.get(*selected_tab) + if let StatsViewMode::Session = *stats_view_mode + && let Some(table_state) = table_states.get_mut(*selected_tab) + && let Some(view) = filtered_stats.get(*selected_tab) + { + let view = view.read(); + let target_len = match session_day_filters + .get(*selected_tab) + .and_then(|f| f.as_ref()) { - let view = view.read(); - let target_len = match session_day_filters - .get(*selected_tab) - .and_then(|f| f.as_ref()) - { - Some(day) => view - .session_aggregates - .iter() - .filter(|s| &s.date == day) - .count(), - None => view.session_aggregates.len(), - }; - if target_len > 0 { - table_state.select(Some(target_len.saturating_sub(1))); - } + Some(day) => view + .session_aggregates + .iter() + .filter(|s| &s.date == day) + .count(), + None => view.session_aggregates.len(), + }; + if target_len > 0 { + table_state.select(Some(target_len.saturating_sub(1))); } - - needs_redraw = true; } + + needs_redraw = true; } - KeyCode::Right | KeyCode::Char('l') => { - if *selected_tab < filtered_stats.len().saturating_sub(1) { - *selected_tab += 1; + KeyCode::Right | KeyCode::Char('l') + if *selected_tab < filtered_stats.len().saturating_sub(1) => + { + *selected_tab += 1; - if let StatsViewMode::Session = *stats_view_mode - && let Some(table_state) = table_states.get_mut(*selected_tab) - && let Some(view) = filtered_stats.get(*selected_tab) + if let StatsViewMode::Session = *stats_view_mode + && let Some(table_state) = table_states.get_mut(*selected_tab) + && let Some(view) = filtered_stats.get(*selected_tab) + { + let view = view.read(); + let target_len = match session_day_filters + .get(*selected_tab) + .and_then(|f| f.as_ref()) { - let view = view.read(); - let target_len = match session_day_filters - .get(*selected_tab) - .and_then(|f| f.as_ref()) - { - Some(day) => view - .session_aggregates - .iter() - .filter(|s| &s.date == day) - .count(), - None => view.session_aggregates.len(), - }; - if target_len > 0 { - table_state.select(Some(target_len.saturating_sub(1))); - } + Some(day) => view + .session_aggregates + .iter() + .filter(|s| &s.date == day) + .count(), + None => view.session_aggregates.len(), + }; + if target_len > 0 { + table_state.select(Some(target_len.saturating_sub(1))); } - - needs_redraw = true; } + + needs_redraw = true; } KeyCode::Down | KeyCode::Char('j') => { if let Some(table_state) = table_states.get_mut(*selected_tab) @@ -629,43 +627,41 @@ async fn run_app( needs_redraw = true; } } - KeyCode::Char('t') => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - *stats_view_mode = match *stats_view_mode { - StatsViewMode::Daily => { - session_day_filters[*selected_tab] = None; - StatsViewMode::Session - } - StatsViewMode::Session => StatsViewMode::Daily, - }; + KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { + *stats_view_mode = match *stats_view_mode { + StatsViewMode::Daily => { + session_day_filters[*selected_tab] = None; + StatsViewMode::Session + } + StatsViewMode::Session => StatsViewMode::Daily, + }; - date_jump_active = false; - date_jump_buffer.clear(); + date_jump_active = false; + date_jump_buffer.clear(); - if let StatsViewMode::Session = *stats_view_mode - && let Some(table_state) = table_states.get_mut(*selected_tab) - && let Some(view) = filtered_stats.get(*selected_tab) - { - let v = view.read(); - if !v.session_aggregates.is_empty() { - let target_len = session_day_filters - .get(*selected_tab) - .and_then(|f| f.as_ref()) - .map(|day| { - v.session_aggregates - .iter() - .filter(|s| &s.date == day) - .count() - }) - .unwrap_or_else(|| v.session_aggregates.len()); - if target_len > 0 { - table_state.select(Some(target_len.saturating_sub(1))); - } + if let StatsViewMode::Session = *stats_view_mode + && let Some(table_state) = table_states.get_mut(*selected_tab) + && let Some(view) = filtered_stats.get(*selected_tab) + { + let v = view.read(); + if !v.session_aggregates.is_empty() { + let target_len = session_day_filters + .get(*selected_tab) + .and_then(|f| f.as_ref()) + .map(|day| { + v.session_aggregates + .iter() + .filter(|s| &s.date == day) + .count() + }) + .unwrap_or_else(|| v.session_aggregates.len()); + if target_len > 0 { + table_state.select(Some(target_len.saturating_sub(1))); } } - - needs_redraw = true; } + + needs_redraw = true; } KeyCode::Enter => { if let StatsViewMode::Daily = *stats_view_mode From 744d9893a5eb8101cfb6589e2c1c56dbb41fbfaa Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 11 Mar 2026 17:48:16 -0600 Subject: [PATCH 2/5] fix(models): mark GPT-5.3 Codex pricing as official --- src/models.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/models.rs b/src/models.rs index 00312cc..7d2707c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -266,7 +266,7 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { }, is_estimated: false, }, - // GPT-5.1 Codex models (estimated pricing - API not yet published) + // GPT-5.1 Codex models "gpt-5.1-codex" => ModelInfo { pricing: PricingStructure::Flat { input_per_1m: 1.25, @@ -325,7 +325,7 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { }, is_estimated: false, }, - // GPT-5.3 Codex (estimated pricing - API not yet published) + // GPT-5.3 Codex "gpt-5.3-codex" => ModelInfo { pricing: PricingStructure::Flat { input_per_1m: 1.75, @@ -334,7 +334,7 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { caching: CachingSupport::OpenAI { cached_input_per_1m: 0.175, }, - is_estimated: true, + is_estimated: false, }, "gpt-5-pro" => ModelInfo { pricing: PricingStructure::Flat { From a5da9ad6262be8dae0330700c200509e090b706c Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 11 Mar 2026 17:59:42 -0600 Subject: [PATCH 3/5] Fix --- Cargo.lock | 41 ++++------------------------------------- Cargo.toml | 3 +-- 2 files changed, 5 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 917f78c..4691df1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,26 +160,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - [[package]] name = "bit-set" version = "0.5.3" @@ -1968,9 +1948,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", @@ -2237,9 +2217,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", @@ -2709,7 +2689,6 @@ version = "3.3.3" dependencies = [ "anyhow", "async-trait", - "bincode", "c2rust-bitfields", "chrono", "chrono-tz", @@ -3243,12 +3222,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "url" version = "2.5.8" @@ -3309,12 +3282,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - [[package]] name = "vtparse" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 6f5adf1..7117e98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ tiktoken-rs = "0.9.1" parking_lot = "0.12" tinyvec = { version = "1.8", features = ["alloc"] } c2rust-bitfields = "0.18" -bincode = "2.0.1" dirs = "6.0" chrono-tz = "0.10" rusqlite = { version = "0.38.0", features = ["bundled"] } @@ -56,7 +55,7 @@ version = "4.5.53" features = ["derive"] [dependencies.reqwest] -version = "0.13.1" +version = "0.13.2" default-features = false features = ["rustls"] From 2472ffa2b59d991b46bde5ca9e701c5e83c74bab Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 11 Mar 2026 18:37:30 -0600 Subject: [PATCH 4/5] Fix --- src/models.rs | 213 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 164 insertions(+), 49 deletions(-) diff --git a/src/models.rs b/src/models.rs index 7d2707c..133429a 100644 --- a/src/models.rs +++ b/src/models.rs @@ -14,6 +14,14 @@ pub struct PricingTier { pub output_per_1m: f64, } +#[derive(Debug, Clone)] +pub struct TieredPricing { + /// Pricing tiers ordered from lowest threshold to highest. + pub tiers: &'static [PricingTier], + /// If true, bill the entire token count at the single matching tier's rate. + pub bracket_pricing: bool, +} + /// Different pricing structures supported by various model providers #[derive(Debug, Clone)] pub enum PricingStructure { @@ -23,7 +31,7 @@ pub enum PricingStructure { output_per_1m: f64, }, /// Tiered pricing (different costs based on token thresholds) - Tiered { tiers: &'static [PricingTier] }, + Tiered(TieredPricing), } /// Caching tier for models with tiered cache pricing @@ -35,6 +43,14 @@ pub struct CachingTier { pub cached_input_per_1m: f64, } +#[derive(Debug, Clone)] +pub struct TieredCaching { + /// Cache tiers ordered from lowest threshold to highest. + pub tiers: &'static [CachingTier], + /// If true, bill the entire token count at the single matching tier's rate. + pub bracket_pricing: bool, +} + /// Different caching support models #[derive(Debug, Clone)] pub enum CachingSupport { @@ -48,7 +64,7 @@ pub enum CachingSupport { cache_read_per_1m: f64, }, /// Google-style caching (may have tiers like input/output) - Google { tiers: &'static [CachingTier] }, + Google(TieredCaching), } /// Complete model information with all pricing details @@ -345,7 +361,7 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { is_estimated: false, }, "gpt-5.4" => ModelInfo { - pricing: PricingStructure::Tiered { + pricing: PricingStructure::Tiered(TieredPricing { tiers: &[ PricingTier { max_tokens: Some(272_000), @@ -358,8 +374,9 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { output_per_1m: 22.5, }, ], - }, - caching: CachingSupport::Google { + bracket_pricing: false, + }), + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: Some(272_000), @@ -370,7 +387,8 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { cached_input_per_1m: 0.50, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, @@ -525,18 +543,19 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { input_per_1m: 0.5, output_per_1m: 3.0, }, - caching: CachingSupport::Google { + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: None, cached_input_per_1m: 0.05, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-3.1-pro-preview" => ModelInfo { - pricing: PricingStructure::Tiered { + pricing: PricingStructure::Tiered(TieredPricing { tiers: &[ PricingTier { max_tokens: Some(200_000), @@ -549,8 +568,9 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { output_per_1m: 18.0, }, ], - }, - caching: CachingSupport::Google { + bracket_pricing: true, + }), + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: Some(200_000), @@ -561,11 +581,12 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { cached_input_per_1m: 0.40, }, ], - }, + bracket_pricing: true, + }), is_estimated: false, }, "gemini-3-pro-preview-11-2025" => ModelInfo { - pricing: PricingStructure::Tiered { + pricing: PricingStructure::Tiered(TieredPricing { tiers: &[ PricingTier { max_tokens: Some(200_000), @@ -578,12 +599,13 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { output_per_1m: 18.0, }, ], - }, + bracket_pricing: false, + }), caching: CachingSupport::None, is_estimated: false, }, "gemini-2.5-pro" => ModelInfo { - pricing: PricingStructure::Tiered { + pricing: PricingStructure::Tiered(TieredPricing { tiers: &[ PricingTier { max_tokens: Some(200_000), @@ -596,8 +618,9 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { output_per_1m: 15.0, }, ], - }, - caching: CachingSupport::Google { + bracket_pricing: false, + }), + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: Some(200_000), @@ -608,7 +631,8 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { cached_input_per_1m: 0.625, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-2.5-flash" => ModelInfo { @@ -616,14 +640,15 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { input_per_1m: 0.3, output_per_1m: 2.5, }, - caching: CachingSupport::Google { + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: None, cached_input_per_1m: 0.075, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-2.5-flash-lite" => ModelInfo { @@ -631,14 +656,15 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { input_per_1m: 0.1, output_per_1m: 0.4, }, - caching: CachingSupport::Google { + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: None, cached_input_per_1m: 0.025, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-2.0-pro-exp-02-05" => ModelInfo { @@ -646,14 +672,15 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { input_per_1m: 0.0, output_per_1m: 0.0, }, - caching: CachingSupport::Google { + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: None, cached_input_per_1m: 0.0, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-2.0-flash" => ModelInfo { @@ -661,14 +688,15 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { input_per_1m: 0.1, output_per_1m: 0.4, }, - caching: CachingSupport::Google { + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: None, cached_input_per_1m: 0.025, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-2.0-flash-lite" => ModelInfo { @@ -680,7 +708,7 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { is_estimated: false, }, "gemini-1.5-flash" => ModelInfo { - pricing: PricingStructure::Tiered { + pricing: PricingStructure::Tiered(TieredPricing { tiers: &[ PricingTier { max_tokens: Some(128_000), @@ -693,8 +721,9 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { output_per_1m: 0.6, }, ], - }, - caching: CachingSupport::Google { + bracket_pricing: false, + }), + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: Some(128_000), @@ -705,11 +734,12 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { cached_input_per_1m: 0.0375, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-1.5-flash-8b" => ModelInfo { - pricing: PricingStructure::Tiered { + pricing: PricingStructure::Tiered(TieredPricing { tiers: &[ PricingTier { max_tokens: Some(128_000), @@ -722,8 +752,9 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { output_per_1m: 0.3, }, ], - }, - caching: CachingSupport::Google { + bracket_pricing: false, + }), + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: Some(128_000), @@ -734,11 +765,12 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { cached_input_per_1m: 0.02, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, "gemini-1.5-pro" => ModelInfo { - pricing: PricingStructure::Tiered { + pricing: PricingStructure::Tiered(TieredPricing { tiers: &[ PricingTier { max_tokens: Some(128_000), @@ -751,8 +783,9 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { output_per_1m: 10.0, }, ], - }, - caching: CachingSupport::Google { + bracket_pricing: false, + }), + caching: CachingSupport::Google(TieredCaching { tiers: &[ CachingTier { max_tokens: Some(128_000), @@ -763,7 +796,8 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { cached_input_per_1m: 0.625, }, ], - }, + bracket_pricing: false, + }), is_estimated: false, }, @@ -1144,7 +1178,9 @@ pub fn calculate_input_cost(model_name: &str, input_tokens: u64) -> f64 { PricingStructure::Flat { input_per_1m, .. } => { (input_tokens as f64 / 1_000_000.0) * input_per_1m } - PricingStructure::Tiered { tiers } => calculate_tiered_cost(input_tokens, tiers, true), + PricingStructure::Tiered(tiered) => { + calculate_tiered_cost(input_tokens, tiered.tiers, tiered.bracket_pricing, true) + } }, None => { warn_once(format!( @@ -1162,8 +1198,8 @@ pub fn calculate_output_cost(model_name: &str, output_tokens: u64) -> f64 { PricingStructure::Flat { output_per_1m, .. } => { (output_tokens as f64 / 1_000_000.0) * output_per_1m } - PricingStructure::Tiered { tiers } => { - calculate_tiered_cost(output_tokens, tiers, false) + PricingStructure::Tiered(tiered) => { + calculate_tiered_cost(output_tokens, tiered.tiers, tiered.bracket_pricing, false) } }, None => { @@ -1200,9 +1236,13 @@ pub fn calculate_cache_cost( let read_cost = (cache_read_tokens as f64 / 1_000_000.0) * cache_read_per_1m; creation_cost + read_cost } - CachingSupport::Google { tiers } => { + CachingSupport::Google(tiered) => { // Google only has read cost, calculate based on tiers - calculate_tiered_cache_cost(cache_read_tokens, tiers) + calculate_tiered_cache_cost( + cache_read_tokens, + tiered.tiers, + tiered.bracket_pricing, + ) } } } @@ -1230,17 +1270,38 @@ pub fn calculate_total_cost( input_cost + output_cost + cache_cost } -fn calculate_tiered_cost(tokens: u64, tiers: &[PricingTier], is_input: bool) -> f64 { +fn calculate_tiered_cost( + tokens: u64, + tiers: &[PricingTier], + bracket_pricing: bool, + is_input: bool, +) -> f64 { + if bracket_pricing { + if let Some(tier) = find_tier(tokens, tiers, |tier| tier.max_tokens) { + let rate = if is_input { + tier.input_per_1m + } else { + tier.output_per_1m + }; + + return (tokens as f64 / 1_000_000.0) * rate; + } + + return 0.0; + } + let mut total_cost = 0.0; let mut remaining_tokens = tokens; + let mut lower_bound = 0; for tier in tiers { if remaining_tokens == 0 { break; } - let tier_limit = tier.max_tokens.unwrap_or(u64::MAX); - let tokens_in_tier = remaining_tokens.min(tier_limit); + let upper_bound = tier.max_tokens.unwrap_or(u64::MAX); + let tier_width = upper_bound.saturating_sub(lower_bound); + let tokens_in_tier = remaining_tokens.min(tier_width); let rate = if is_input { tier.input_per_1m @@ -1250,27 +1311,81 @@ fn calculate_tiered_cost(tokens: u64, tiers: &[PricingTier], is_input: bool) -> total_cost += (tokens_in_tier as f64 / 1_000_000.0) * rate; remaining_tokens = remaining_tokens.saturating_sub(tokens_in_tier); + lower_bound = upper_bound; } total_cost } -fn calculate_tiered_cache_cost(tokens: u64, tiers: &[CachingTier]) -> f64 { +fn calculate_tiered_cache_cost(tokens: u64, tiers: &[CachingTier], bracket_pricing: bool) -> f64 { + if bracket_pricing { + if let Some(tier) = find_tier(tokens, tiers, |tier| tier.max_tokens) { + return (tokens as f64 / 1_000_000.0) * tier.cached_input_per_1m; + } + + return 0.0; + } + let mut total_cost = 0.0; let mut remaining_tokens = tokens; + let mut lower_bound = 0; for tier in tiers { if remaining_tokens == 0 { break; } - let tier_limit = tier.max_tokens.unwrap_or(u64::MAX); - let tokens_in_tier = remaining_tokens.min(tier_limit); + let upper_bound = tier.max_tokens.unwrap_or(u64::MAX); + let tier_width = upper_bound.saturating_sub(lower_bound); + let tokens_in_tier = remaining_tokens.min(tier_width); total_cost += (tokens_in_tier as f64 / 1_000_000.0) * tier.cached_input_per_1m; remaining_tokens = remaining_tokens.saturating_sub(tokens_in_tier); + lower_bound = upper_bound; } total_cost } + +fn find_tier<'a, T, F>(tokens: u64, tiers: &'a [T], max_tokens: F) -> Option<&'a T> +where + F: Fn(&T) -> Option, +{ + for tier in tiers { + match max_tokens(tier) { + Some(limit) if tokens <= limit => return Some(tier), + None => return Some(tier), + _ => continue, + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::{calculate_cache_cost, calculate_input_cost}; + + fn approx_eq(left: f64, right: f64) { + assert!((left - right).abs() < 1e-9, "left={left}, right={right}"); + } + + #[test] + fn gemini_3_1_pro_preview_uses_bracket_pricing_for_input() { + let cost = calculate_input_cost("gemini-3.1-pro-preview", 250_000); + approx_eq(cost, 1.0); + } + + #[test] + fn gemini_3_1_pro_preview_uses_bracket_pricing_for_cache_reads() { + let cost = calculate_cache_cost("gemini-3.1-pro-preview", 0, 250_000); + approx_eq(cost, 0.1); + } + + #[test] + fn gemini_2_5_pro_remains_progressive() { + let cost = calculate_input_cost("gemini-2.5-pro", 250_000); + approx_eq(cost, 0.375); + } +} From fc277dd2bce54859a93c629a5fcac9de643fb58a Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 11 Mar 2026 18:42:48 -0600 Subject: [PATCH 5/5] Fix --- src/models.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models.rs b/src/models.rs index 133429a..eb90ba9 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1348,7 +1348,7 @@ fn calculate_tiered_cache_cost(tokens: u64, tiers: &[CachingTier], bracket_prici total_cost } -fn find_tier<'a, T, F>(tokens: u64, tiers: &'a [T], max_tokens: F) -> Option<&'a T> +fn find_tier(tokens: u64, tiers: &[T], max_tokens: F) -> Option<&T> where F: Fn(&T) -> Option, {