From 397e25e449d875eb689b311fcaa542e27ec14d0e Mon Sep 17 00:00:00 2001 From: Dmitry Starkov <21260939+starkdmi@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:28:28 +0300 Subject: [PATCH 1/4] Add GPT-6.1 Sol pricing GPT-6.1 Sol keeps GPT-6 Sol's input, cache-write and output rates and halves cached input to $0.10/M. Fast mode is 2x standard, and prompts above 272K tokens reprice the whole request at 2x input and cache and 1.5x output. Bump the pricing ruleset so stored usage is repriced. --- crates/statsai-pricing/src/catalog.rs | 5 ++++- crates/statsai-pricing/src/cost.rs | 1 + crates/statsai-pricing/src/lib.rs | 4 ++-- crates/statsai-pricing/src/normalize.rs | 4 ++++ crates/statsai-pricing/src/tests/catalog.rs | 20 +++++++++++++++++++ crates/statsai-pricing/src/tests/cost.rs | 3 ++- crates/statsai-pricing/src/tests/normalize.rs | 8 ++++++++ 7 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/statsai-pricing/src/catalog.rs b/crates/statsai-pricing/src/catalog.rs index c8a101a..f76aae2 100644 --- a/crates/statsai-pricing/src/catalog.rs +++ b/crates/statsai-pricing/src/catalog.rs @@ -58,7 +58,7 @@ pub(crate) fn pricing_for_effective_speed( let fast = match model_name { "claude-opus-5-5" => pricing_with_cache_creation(8.0, 10.0, 0.4, 40.0), "claude-opus-5" | "claude-opus-4-8" => pricing_with_cache_creation(10.0, 12.5, 1.0, 50.0), - "gpt-6-sol" | "gpt-6-luna" => pricing_with_cache_creation( + "gpt-6.1-sol" | "gpt-6-sol" | "gpt-6-luna" => pricing_with_cache_creation( standard.input_per_million * 2.0, standard.cache_creation_per_million * 2.0, standard.cached_input_per_million * 2.0, @@ -171,6 +171,9 @@ pub(crate) fn pricing_for_model_on( // default. A larger prompt reprices the whole request at 20/25/2/75, // applied by `pricing_multipliers` where a record is one known request. "gpt-6-astra" => Some(pricing_with_cache_creation(10.0, 12.5, 1.0, 50.0)), + // Same input, cache-write and output rates as GPT-6 Sol; cached input + // is 5% of input rather than 10%. + "gpt-6.1-sol" => Some(pricing_with_cache_creation(2.0, 2.5, 0.1, 10.0)), "gpt-6-sol" => Some(pricing_with_cache_creation(2.0, 2.5, 0.2, 10.0)), "gpt-6-luna" => Some(pricing_with_cache_creation(0.1, 0.125, 0.01, 0.5)), // GPT-5.6 uses a 1.25x cache-write multiplier and a 90% cache-read discount. diff --git a/crates/statsai-pricing/src/cost.rs b/crates/statsai-pricing/src/cost.rs index 61ef5a3..9189fd6 100644 --- a/crates/statsai-pricing/src/cost.rs +++ b/crates/statsai-pricing/src/cost.rs @@ -196,6 +196,7 @@ fn pricing_multipliers( | "gpt-5.6-terra" | "gpt-5.6-luna" | "gpt-6-astra" + | "gpt-6.1-sol" | "gpt-6-sol" | "gpt-6-luna" ); diff --git a/crates/statsai-pricing/src/lib.rs b/crates/statsai-pricing/src/lib.rs index 7c57406..207e7e8 100644 --- a/crates/statsai-pricing/src/lib.rs +++ b/crates/statsai-pricing/src/lib.rs @@ -14,10 +14,10 @@ /// Increment this constant on every semantic pricing-rule change so persisted /// stores can reprice automatically. The descriptive catalog string is not an /// ordering key. -pub const PRICING_RULESET_VERSION: u64 = 4; +pub const PRICING_RULESET_VERSION: u64 = 5; /// Descriptive identifier for the compiled price list. Not an ordering key. -pub const PRICING_CATALOG_VERSION: &str = "official:2026-09-22"; +pub const PRICING_CATALOG_VERSION: &str = "official:2026-10-08"; mod catalog; mod cost; diff --git a/crates/statsai-pricing/src/normalize.rs b/crates/statsai-pricing/src/normalize.rs index 1c5f349..66341df 100644 --- a/crates/statsai-pricing/src/normalize.rs +++ b/crates/statsai-pricing/src/normalize.rs @@ -91,6 +91,9 @@ fn normalize_proxy_wrapped_model_name(lower: &str) -> Option<&'static str> { if lower.contains("gpt-6-astra") { return Some("gpt-6-astra"); } + if lower.contains("gpt-6.1-sol") { + return Some("gpt-6.1-sol"); + } if lower.contains("gpt-6-sol") { return Some("gpt-6-sol"); } @@ -274,6 +277,7 @@ pub fn normalize_model_name(name: &str) -> String { "gpt-5.4" => "gpt-5.4".to_string(), "gpt-5.4-mini" => "gpt-5.4-mini".to_string(), "gpt-6-astra" | "gpt-6-astra-codex" => "gpt-6-astra".to_string(), + "gpt-6.1-sol" => "gpt-6.1-sol".to_string(), "gpt-6-sol" => "gpt-6-sol".to_string(), "gpt-6-luna" => "gpt-6-luna".to_string(), "gpt-5.6-sol" => "gpt-5.6-sol".to_string(), diff --git a/crates/statsai-pricing/src/tests/catalog.rs b/crates/statsai-pricing/src/tests/catalog.rs index 6cfd7a6..c8a96d3 100644 --- a/crates/statsai-pricing/src/tests/catalog.rs +++ b/crates/statsai-pricing/src/tests/catalog.rs @@ -1,4 +1,6 @@ use super::*; +use crate::catalog::pricing_for_model_on; + #[test] fn sonnet_5_reports_aggregate_periods_that_cross_its_price_change() { let before = chrono::NaiveDate::from_ymd_opt(2026, 8, 31).expect("before boundary"); @@ -80,3 +82,21 @@ fn gpt_5_6_sol_reports_aggregate_periods_that_cross_its_promotional_cut() { assert!(!pricing_changes_between("gpt-5.6-luna", before, after)); assert!(!pricing_changes_between("gpt-5.6-terra", before, after)); } + +#[test] +fn gpt_6_1_sol_halves_gpt_6_sol_cached_input_with_no_price_boundary() { + let launch = chrono::NaiveDate::from_ymd_opt(2026, 9, 29).expect("launch date"); + let later = chrono::NaiveDate::from_ymd_opt(2026, 10, 8).expect("later date"); + let sol_6_1 = pricing_for_model_on("gpt-6.1-sol", launch).expect("gpt-6.1-sol priced"); + let sol_6 = pricing_for_model_on("gpt-6-sol", launch).expect("gpt-6-sol priced"); + + assert!((sol_6_1.input_per_million - 2.0).abs() < f64::EPSILON); + assert!((sol_6_1.cache_creation_per_million - 2.5).abs() < f64::EPSILON); + assert!((sol_6_1.cached_input_per_million - 0.1).abs() < f64::EPSILON); + assert!((sol_6_1.output_per_million - 10.0).abs() < f64::EPSILON); + assert!( + (sol_6_1.cached_input_per_million * 2.0 - sol_6.cached_input_per_million).abs() + < f64::EPSILON + ); + assert!(!pricing_changes_between("gpt-6.1-sol", launch, later)); +} diff --git a/crates/statsai-pricing/src/tests/cost.rs b/crates/statsai-pricing/src/tests/cost.rs index c783fb4..d3a729d 100644 --- a/crates/statsai-pricing/src/tests/cost.rs +++ b/crates/statsai-pricing/src/tests/cost.rs @@ -943,9 +943,10 @@ fn opus_5_5_prices_both_cache_write_lifetimes_without_long_context_surcharge() { } #[test] -fn gpt_6_sol_and_luna_reprice_entire_request_above_272k() { +fn gpt_6_sol_6_1_sol_and_luna_reprice_entire_request_above_272k() { let at = parse_utc("2026-09-22T12:00:00Z"); for (name, short_cost, long_cost, fast_long_cost) in [ + ("gpt-6.1-sol", 557_200, 1_064_600, 2_129_200), ("gpt-6-sol", 564_400, 1_079_200, 2_158_400), ("gpt-6-luna", 28_220, 53_960, 107_920), ] { diff --git a/crates/statsai-pricing/src/tests/normalize.rs b/crates/statsai-pricing/src/tests/normalize.rs index 65ab410..7ffcf53 100644 --- a/crates/statsai-pricing/src/tests/normalize.rs +++ b/crates/statsai-pricing/src/tests/normalize.rs @@ -54,6 +54,14 @@ fn normalizes_gpt_6_astra() { ); } +#[test] +fn normalizes_gpt_6_1_sol_without_collapsing_into_gpt_6_sol() { + assert_eq!(normalize_model_name("gpt-6.1-sol"), "gpt-6.1-sol"); + assert_eq!(normalize_model_name("GPT-6.1-Sol"), "gpt-6.1-sol"); + assert_eq!(normalize_model_name("openai/gpt-6.1-sol"), "gpt-6.1-sol"); + assert_eq!(normalize_model_name("openai/gpt-6-sol"), "gpt-6-sol"); +} + #[test] fn normalizes_new_opus_and_gpt_6_models_without_collapsing_opus_5_5() { assert_eq!(normalize_model_name("claude-opus-5-5"), "claude-opus-5-5"); From 5e0c8d3ec7d5f3e3e053a62036e8217ea0b8b4fc Mon Sep 17 00:00:00 2001 From: Dmitry Starkov <21260939+starkdmi@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:06:26 +0300 Subject: [PATCH 2/4] Add Haiku 5.5 and Sonnet 5.5 pricing, and drop the cancelled Sonnet 5 rise Haiku 5.5 is $0.10/$0.50 with $0.125 cache writes and $0.01 cache reads for prompts up to 100K tokens. A single request above that reprices every rate at 5x. Sonnet 5.5 was folded into Sonnet 5 by the normalizer. It now has its own entry, and its cache reads drop from $0.20/M to $0.10/M from 2026-10-07, with aggregates spanning that date left unpriced. Sonnet 5's $2/$10 launch price became standard and the increase to $3/$15 scheduled for 2026-09-01 was cancelled, so that boundary is gone. --- .../src/claude/tests/parse.rs | 6 +- crates/statsai-pricing/src/catalog.rs | 33 ++++--- crates/statsai-pricing/src/cost.rs | 12 +++ crates/statsai-pricing/src/normalize.rs | 12 +++ crates/statsai-pricing/src/tests/catalog.rs | 33 +++++-- crates/statsai-pricing/src/tests/cost.rs | 94 ++++++++++++++++--- crates/statsai-pricing/src/tests/normalize.rs | 32 +++++++ 7 files changed, 188 insertions(+), 34 deletions(-) diff --git a/crates/statsai-adapters/src/claude/tests/parse.rs b/crates/statsai-adapters/src/claude/tests/parse.rs index 5570abf..6b9be1c 100644 --- a/crates/statsai-adapters/src/claude/tests/parse.rs +++ b/crates/statsai-adapters/src/claude/tests/parse.rs @@ -748,12 +748,12 @@ fn claude_stats_cache_does_not_estimate_aggregate_across_pricing_boundary() { dir.path().join("stats-cache.json"), r#"{ "version": 2, - "lastComputedDate": "2026-09-01", - "firstSessionDate": "2026-08-31T00:00:00Z", + "lastComputedDate": "2026-10-07", + "firstSessionDate": "2026-10-06T00:00:00Z", "totalSessions": 2, "totalMessages": 4, "modelUsage": { - "claude-sonnet-5": { + "claude-sonnet-5-5": { "inputTokens": 1000000, "outputTokens": 1000000, "cacheReadInputTokens": 0, diff --git a/crates/statsai-pricing/src/catalog.rs b/crates/statsai-pricing/src/catalog.rs index f76aae2..8896442 100644 --- a/crates/statsai-pricing/src/catalog.rs +++ b/crates/statsai-pricing/src/catalog.rs @@ -11,7 +11,8 @@ pub struct ModelPricing { pub output_per_million: f64, } -const CLAUDE_SONNET_5_STANDARD_PRICING_START: (i32, u32, u32) = (2026, 9, 1); +/// Sonnet 5.5 cache reads dropped from $0.20/M to $0.10/M; its other rates held. +const CLAUDE_SONNET_5_5_CACHE_READ_CUT_START: (i32, u32, u32) = (2026, 10, 7); const GPT_5_6_LUNA_TERRA_PRICE_CUT_START: (i32, u32, u32) = (2026, 7, 30); /// Promotional, announced to hold at least through 2026-11-21. When it lapses, /// add an end date here rather than reverting: usage priced under the promotion @@ -109,6 +110,14 @@ pub(crate) fn api_equivalent_pricing_model( } } +fn claude_sonnet_5_5_pricing(usage_date: chrono::NaiveDate) -> ModelPricing { + if date_tuple(usage_date) >= CLAUDE_SONNET_5_5_CACHE_READ_CUT_START { + pricing_with_cache_creation(2.0, 2.5, 0.1, 10.0) + } else { + pricing_with_cache_creation(2.0, 2.5, 0.2, 10.0) + } +} + fn gpt_5_6_luna_pricing(usage_date: chrono::NaiveDate) -> ModelPricing { if date_tuple(usage_date) >= GPT_5_6_LUNA_TERRA_PRICE_CUT_START { pricing_with_cache_creation(0.2, 0.25, 0.02, 1.2) @@ -149,14 +158,10 @@ pub(crate) fn pricing_for_model_on( } "claude-opus-5-5" => Some(pricing_with_cache_creation(4.0, 5.0, 0.2, 20.0)), "claude-opus-5" => Some(pricing_with_cache_creation(5.0, 6.25, 0.5, 25.0)), - "claude-sonnet-5" => { - let date = (usage_date.year(), usage_date.month(), usage_date.day()); - if date >= CLAUDE_SONNET_5_STANDARD_PRICING_START { - Some(pricing_with_cache_creation(3.0, 3.75, 0.3, 15.0)) - } else { - Some(pricing_with_cache_creation(2.0, 2.5, 0.2, 10.0)) - } - } + "claude-sonnet-5-5" => Some(claude_sonnet_5_5_pricing(usage_date)), + // The $2/$10 launch price became standard; the rise to $3/$15 scheduled + // for 2026-09-01 was cancelled. + "claude-sonnet-5" => Some(pricing_with_cache_creation(2.0, 2.5, 0.2, 10.0)), "claude-opus-4" | "claude-opus-4-1" => { Some(pricing_with_cache_creation(15.0, 18.75, 1.5, 75.0)) } @@ -166,6 +171,10 @@ pub(crate) fn pricing_for_model_on( "claude-sonnet-4" | "claude-sonnet-4-5" | "claude-sonnet-4-6" => { Some(pricing_with_cache_creation(3.0, 3.75, 0.3, 15.0)) } + // The rate for prompts up to 100k tokens. A larger prompt reprices the + // whole request at 5x, applied by `pricing_multipliers` where a record + // is one known request. + "claude-haiku-5-5" => Some(pricing_with_cache_creation(0.1, 0.125, 0.01, 0.5)), "claude-haiku-4-5" => Some(pricing_with_cache_creation(1.0, 1.25, 0.1, 5.0)), // The default rate, covering the 272k context window Codex runs by // default. A larger prompt reprices the whole request at 20/25/2/75, @@ -229,9 +238,9 @@ pub fn pricing_changes_between( let end = date_tuple(period_end); match normalize_model_name(model_name).as_str() { - "claude-sonnet-5" => { - start < CLAUDE_SONNET_5_STANDARD_PRICING_START - && end >= CLAUDE_SONNET_5_STANDARD_PRICING_START + "claude-sonnet-5-5" => { + start < CLAUDE_SONNET_5_5_CACHE_READ_CUT_START + && end >= CLAUDE_SONNET_5_5_CACHE_READ_CUT_START } "gpt-5.6-luna" | "gpt-5.6-terra" | "codex-auto-review" => { start < GPT_5_6_LUNA_TERRA_PRICE_CUT_START && end >= GPT_5_6_LUNA_TERRA_PRICE_CUT_START diff --git a/crates/statsai-pricing/src/cost.rs b/crates/statsai-pricing/src/cost.rs index 9189fd6..3d829e4 100644 --- a/crates/statsai-pricing/src/cost.rs +++ b/crates/statsai-pricing/src/cost.rs @@ -167,6 +167,7 @@ fn pricing_multipliers( const OPENAI_LONG_CONTEXT_THRESHOLD: u64 = 272_000; const XAI_LONG_CONTEXT_THRESHOLD: u64 = 200_000; const CURSOR_GROK_4_7_LONG_CONTEXT_THRESHOLD: u64 = 256_000; + const CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD: u64 = 100_000; let prompt_tokens = usage .input_tokens @@ -189,6 +190,17 @@ fn pricing_multipliers( return (MULTIPLIER_SCALE, MULTIPLIER_SCALE); } + if model_name == "claude-haiku-5-5" { + // Every Haiku 5.5 rate, cache writes and reads included, is 5x above 100k. + return if usage.requests == Some(1) + && prompt_tokens > CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD + { + (50_000, 50_000) + } else { + (MULTIPLIER_SCALE, MULTIPLIER_SCALE) + }; + } + let is_openai_long_context_model = matches!( model_name, "gpt-5.4" diff --git a/crates/statsai-pricing/src/normalize.rs b/crates/statsai-pricing/src/normalize.rs index 66341df..1eeb21a 100644 --- a/crates/statsai-pricing/src/normalize.rs +++ b/crates/statsai-pricing/src/normalize.rs @@ -17,12 +17,14 @@ fn normalize_reversed_claude_model_name(lower: &str) -> Option<&'static str> { ("opus", "4.5") => "claude-opus-4-5", ("opus", "4.1") => "claude-opus-4-1", ("opus", "4") => "claude-opus-4", + ("sonnet", "5.5") => "claude-sonnet-5-5", ("sonnet", "5") => "claude-sonnet-5", ("sonnet", "4.6") => "claude-sonnet-4-6", ("sonnet", "4.5") => "claude-sonnet-4-5", ("sonnet", "4") => "claude-sonnet-4", ("sonnet", "3.7") => "claude-sonnet-3-7", ("sonnet", "3.5") => "claude-sonnet-3-5", + ("haiku", "5.5") => "claude-haiku-5-5", ("haiku", "4.5") => "claude-haiku-4-5", ("haiku", "3.5") => "claude-haiku-3-5", _ => return None, @@ -46,6 +48,9 @@ fn normalize_proxy_wrapped_model_name(lower: &str) -> Option<&'static str> { if lower.contains("claude-opus-5") { return Some("claude-opus-5"); } + if lower.contains("claude-sonnet-5-5") || lower.contains("claude-sonnet-5.5") { + return Some("claude-sonnet-5-5"); + } if lower.contains("claude-sonnet-5") { return Some("claude-sonnet-5"); } @@ -70,6 +75,9 @@ fn normalize_proxy_wrapped_model_name(lower: &str) -> Option<&'static str> { if lower.contains("claude-sonnet-4-5") || lower.contains("claude-sonnet-4.5") { return Some("claude-sonnet-4-5"); } + if lower.contains("claude-haiku-5-5") || lower.contains("claude-haiku-5.5") { + return Some("claude-haiku-5-5"); + } if lower.contains("claude-haiku-4-5") || lower.contains("claude-haiku-4.5") { return Some("claude-haiku-4-5"); } @@ -242,6 +250,9 @@ pub fn normalize_model_name(name: &str) -> String { "claude-opus-5-5".to_string() } "claude-opus-5" | "claude-opus-5-thinking" => "claude-opus-5".to_string(), + "claude-sonnet-5-5" | "claude-sonnet-5-5-thinking" | "claude-sonnet-5.5" => { + "claude-sonnet-5-5".to_string() + } "claude-sonnet-5" | "claude-sonnet-5-thinking" => "claude-sonnet-5".to_string(), "claude-3-5-sonnet-20241022" | "claude-sonnet-3-5" => "claude-sonnet-3-5".to_string(), "claude-3-7-sonnet" | "claude-sonnet-3-7" => "claude-sonnet-3-7".to_string(), @@ -264,6 +275,7 @@ pub fn normalize_model_name(name: &str) -> String { "claude-sonnet-4-6" | "claude-sonnet-4-6-thinking" | "claude-sonnet-4.6" => { "claude-sonnet-4-6".to_string() } + "claude-haiku-5-5" | "claude-haiku-5.5" => "claude-haiku-5-5".to_string(), "claude-haiku-4-5" | "claude-haiku-4.5" => "claude-haiku-4-5".to_string(), "claude-haiku-3-5" | "claude-haiku-3.5" => "claude-haiku-3-5".to_string(), "gpt-5" | "gpt-5-chat-latest" => "gpt-5".to_string(), diff --git a/crates/statsai-pricing/src/tests/catalog.rs b/crates/statsai-pricing/src/tests/catalog.rs index c8a96d3..5199a8b 100644 --- a/crates/statsai-pricing/src/tests/catalog.rs +++ b/crates/statsai-pricing/src/tests/catalog.rs @@ -2,19 +2,36 @@ use super::*; use crate::catalog::pricing_for_model_on; #[test] -fn sonnet_5_reports_aggregate_periods_that_cross_its_price_change() { - let before = chrono::NaiveDate::from_ymd_opt(2026, 8, 31).expect("before boundary"); - let boundary = chrono::NaiveDate::from_ymd_opt(2026, 9, 1).expect("boundary"); - let after = chrono::NaiveDate::from_ymd_opt(2026, 9, 2).expect("after boundary"); +fn sonnet_5_5_reports_aggregate_periods_that_cross_its_cache_read_cut() { + let before = chrono::NaiveDate::from_ymd_opt(2026, 10, 6).expect("before boundary"); + let boundary = chrono::NaiveDate::from_ymd_opt(2026, 10, 7).expect("boundary"); + let after = chrono::NaiveDate::from_ymd_opt(2026, 10, 8).expect("after boundary"); - assert!(pricing_changes_between("claude-sonnet-5", before, boundary)); assert!(pricing_changes_between( - "anthropic/claude-sonnet-5", + "claude-sonnet-5-5", + before, + boundary + )); + assert!(pricing_changes_between( + "anthropic/claude-sonnet-5.5", after, before )); - assert!(!pricing_changes_between("claude-sonnet-5", boundary, after)); - assert!(!pricing_changes_between("claude-opus-5", before, after)); + assert!(!pricing_changes_between( + "claude-sonnet-5-5", + boundary, + after + )); + assert!(!pricing_changes_between("claude-sonnet-5", before, after)); + assert!(!pricing_changes_between("claude-haiku-5-5", before, after)); +} + +#[test] +fn sonnet_5_has_no_price_boundary_at_the_cancelled_september_increase() { + let before = chrono::NaiveDate::from_ymd_opt(2026, 8, 31).expect("before"); + let after = chrono::NaiveDate::from_ymd_opt(2026, 9, 2).expect("after"); + + assert!(!pricing_changes_between("claude-sonnet-5", before, after)); } #[test] fn codex_auto_review_reports_aggregate_periods_that_cross_its_equivalent_change() { diff --git a/crates/statsai-pricing/src/tests/cost.rs b/crates/statsai-pricing/src/tests/cost.rs index d3a729d..ee89149 100644 --- a/crates/statsai-pricing/src/tests/cost.rs +++ b/crates/statsai-pricing/src/tests/cost.rs @@ -871,7 +871,7 @@ fn applies_gpt_5_6_long_context_rates() { } #[test] -fn sonnet_5_pricing_uses_usage_date() { +fn sonnet_5_keeps_launch_pricing_after_the_cancelled_september_increase() { let model = statsai_core::ModelInfo { name: Some("claude-sonnet-5".to_string()), normalized_name: Some("claude-sonnet-5".to_string()), @@ -885,18 +885,90 @@ fn sonnet_5_pricing_uses_usage_date() { output_tokens: Some(1_000_000), ..UsageCounts::default() }; - let introductory_date = chrono::DateTime::parse_from_rfc3339("2026-08-31T23:59:59Z") - .expect("valid timestamp") - .with_timezone(&chrono::Utc); - let standard_date = chrono::DateTime::parse_from_rfc3339("2026-09-01T00:00:00Z") - .expect("valid timestamp") - .with_timezone(&chrono::Utc); + let before = estimate_cost_at( + "claude_code", + Some(&model), + &usage, + &parse_utc("2026-08-31T23:59:59Z"), + ); + let after = estimate_cost_at( + "claude_code", + Some(&model), + &usage, + &parse_utc("2026-09-01T00:00:00Z"), + ); + + assert_eq!(before.estimated_api_equivalent_usd, Some(1_200)); + assert_eq!(after.estimated_api_equivalent_usd, Some(1_200)); +} + +#[test] +fn sonnet_5_5_halves_cache_reads_from_october_7_and_is_not_priced_as_sonnet_5() { + let model = test_model("claude-sonnet-5.5"); + let usage = UsageCounts { + cache_creation_tokens: Some(100_000), + cache_creation_1h_tokens: Some(100_000), + cache_read_tokens: Some(1_000_000), + requests: Some(1), + ..UsageCounts::default() + }; + + let before = estimate_cost_at( + "claude_code", + Some(&model), + &usage, + &parse_utc("2026-10-06T23:59:59Z"), + ); + let after = estimate_cost_at( + "claude_code", + Some(&model), + &usage, + &parse_utc("2026-10-07T00:00:00Z"), + ); - let introductory = estimate_cost_at("claude_code", Some(&model), &usage, &introductory_date); - let standard = estimate_cost_at("claude_code", Some(&model), &usage, &standard_date); + assert_eq!(before.estimated_api_equivalent_micro_usd, Some(600_000)); + assert_eq!(after.estimated_api_equivalent_micro_usd, Some(500_000)); + assert_eq!( + after.pricing_source.as_deref(), + Some("claude_code_api_pricing:claude-sonnet-5-5") + ); +} - assert_eq!(introductory.estimated_api_equivalent_usd, Some(1_200)); - assert_eq!(standard.estimated_api_equivalent_usd, Some(1_800)); +#[test] +fn haiku_5_5_reprices_a_single_request_above_100k_at_five_times_every_rate() { + let model = test_model("claude-haiku-5-5"); + let at = parse_utc("2026-10-07T12:00:00Z"); + let short_usage = UsageCounts { + input_tokens: Some(50_000), + cache_creation_tokens: Some(30_000), + cache_creation_5m_tokens: Some(20_000), + cache_creation_1h_tokens: Some(10_000), + cache_read_tokens: Some(20_000), + output_tokens: Some(10_000), + requests: Some(1), + ..UsageCounts::default() + }; + let long_usage = UsageCounts { + cache_read_tokens: Some(30_000), + ..short_usage.clone() + }; + let aggregate_usage = UsageCounts { + requests: Some(2), + ..long_usage.clone() + }; + + let short = estimate_cost_at("claude_code", Some(&model), &short_usage, &at); + let long = estimate_cost_at("claude_code", Some(&model), &long_usage, &at); + let aggregate = estimate_cost_at("claude_code", Some(&model), &aggregate_usage, &at); + + assert_eq!(short.estimated_api_equivalent_micro_usd, Some(14_700)); + assert_eq!(long.estimated_api_equivalent_micro_usd, Some(74_000)); + // Tokens split across several requests cannot be assigned a tier. + assert_eq!(aggregate.estimated_api_equivalent_micro_usd, Some(14_800)); + assert_eq!( + long.pricing_source.as_deref(), + Some("claude_code_api_pricing:claude-haiku-5-5") + ); } fn test_model(name: &str) -> statsai_core::ModelInfo { statsai_core::ModelInfo { diff --git a/crates/statsai-pricing/src/tests/normalize.rs b/crates/statsai-pricing/src/tests/normalize.rs index 7ffcf53..cee27c2 100644 --- a/crates/statsai-pricing/src/tests/normalize.rs +++ b/crates/statsai-pricing/src/tests/normalize.rs @@ -54,6 +54,38 @@ fn normalizes_gpt_6_astra() { ); } +#[test] +fn normalizes_sonnet_and_haiku_5_5_without_collapsing_into_older_models() { + assert_eq!( + normalize_model_name("claude-sonnet-5-5"), + "claude-sonnet-5-5" + ); + assert_eq!( + normalize_model_name("claude-sonnet-5.5"), + "claude-sonnet-5-5" + ); + assert_eq!( + normalize_model_name("claude-5.5-sonnet"), + "claude-sonnet-5-5" + ); + assert_eq!( + normalize_model_name("openrouter/claude-sonnet-5-5-thinking"), + "claude-sonnet-5-5" + ); + assert_eq!(normalize_model_name("claude-sonnet-5"), "claude-sonnet-5"); + assert_eq!( + normalize_model_name("openrouter/claude-sonnet-5"), + "claude-sonnet-5" + ); + assert_eq!(normalize_model_name("claude-haiku-5-5"), "claude-haiku-5-5"); + assert_eq!(normalize_model_name("claude-haiku-5.5"), "claude-haiku-5-5"); + assert_eq!(normalize_model_name("claude-5.5-haiku"), "claude-haiku-5-5"); + assert_eq!( + normalize_model_name("anthropic/claude-haiku-5-5"), + "claude-haiku-5-5" + ); +} + #[test] fn normalizes_gpt_6_1_sol_without_collapsing_into_gpt_6_sol() { assert_eq!(normalize_model_name("gpt-6.1-sol"), "gpt-6.1-sol"); From 09a0f03aaadad0eea4b9985a0c2d8d695d16ba0e Mon Sep 17 00:00:00 2001 From: Dmitry Starkov <21260939+starkdmi@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:42:00 +0300 Subject: [PATCH 3/4] Keep Claude stats-cache totals out of per-request pricing tiers The stats-cache parser reused the per-message usage reader, which stamps `requests: 1`. Its per-model totals span every cached session, so Haiku 5.5's above-100K tier repriced combined usage at 5x. Parse the totals without a request count, and clear the stale count on stored summaries when they are repriced. --- crates/statsai-adapters/src/claude/parse.rs | 7 ++- .../src/claude/tests/parse.rs | 43 +++++++++++++++++++ crates/statsai-store/src/pricing/mod.rs | 17 +++++++- .../src/pricing/tests/repricing.rs | 30 +++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/statsai-adapters/src/claude/parse.rs b/crates/statsai-adapters/src/claude/parse.rs index fb87194..ea16686 100644 --- a/crates/statsai-adapters/src/claude/parse.rs +++ b/crates/statsai-adapters/src/claude/parse.rs @@ -549,7 +549,12 @@ pub(crate) fn parse_claude_stats_cache( for (model_name, usage_value) in model_usage { scan.diagnostics.candidate_usage_rows += 1; - let usage = claude_usage_counts_from_value(usage_value); + // A per-model total over every cached session, not one request, so it + // must not carry the single-request count that selects a context tier. + let usage = UsageCounts { + requests: None, + ..claude_usage_counts_from_value(usage_value) + }; if usage.computed_total() == 0 { scan.diagnostics.skipped_zero_events += 1; continue; diff --git a/crates/statsai-adapters/src/claude/tests/parse.rs b/crates/statsai-adapters/src/claude/tests/parse.rs index 6b9be1c..fdb8400 100644 --- a/crates/statsai-adapters/src/claude/tests/parse.rs +++ b/crates/statsai-adapters/src/claude/tests/parse.rs @@ -785,6 +785,49 @@ fn claude_stats_cache_does_not_estimate_aggregate_across_pricing_boundary() { ); } +#[test] +fn claude_stats_cache_totals_never_take_a_per_request_context_tier() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(dir.path().join("projects")).expect("projects"); + std::fs::write( + dir.path().join("stats-cache.json"), + r#"{ + "version": 2, + "lastComputedDate": "2026-10-08", + "firstSessionDate": "2026-10-07T00:00:00Z", + "totalSessions": 2, + "totalMessages": 4, + "modelUsage": { + "claude-haiku-5-5": { + "inputTokens": 120000, + "outputTokens": 20000, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0 + } + } + }"#, + ) + .expect("stats cache"); + let source = SourceLocation::local_adapter( + CLAUDE_CODE_PROVIDER, + "test", + "0", + dir.path(), + LocationOrigin::Configured, + ); + + let scan = scan_claude_source(&ClaudeCodeAdapter, &source, &options()).expect("scan"); + + assert_eq!(scan.summaries.len(), 1); + assert_eq!(scan.summaries[0].usage.requests, None); + // 120k input and 20k output at the up-to-100k rates; the aggregate cannot + // show that any one request crossed the threshold. + assert_eq!( + scan.summaries[0].cost.estimated_api_equivalent_micro_usd, + Some(22_000) + ); +} + #[test] fn claude_scan_respects_selected_cache_keys() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/statsai-store/src/pricing/mod.rs b/crates/statsai-store/src/pricing/mod.rs index d0b0abe..b796f67 100644 --- a/crates/statsai-store/src/pricing/mod.rs +++ b/crates/statsai-store/src/pricing/mod.rs @@ -466,7 +466,7 @@ fn reprice_summary(summary: &UsageSummary) -> Option { let (period_start, period_end) = summary_period_bounds(summary); let pricing_at = summary.period_end.unwrap_or(summary.observed_at); let mut updated = summary.clone(); - let mut changed = false; + let mut changed = clear_stats_cache_request_counts(&mut updated); if !updated.models.is_empty() { for model_usage in &mut updated.models { @@ -503,6 +503,21 @@ fn reprice_summary(summary: &UsageSummary) -> Option { changed.then_some(updated) } +/// Clears the `requests: 1` older parsers stamped on Claude stats-cache totals. +/// +/// Those totals span every cached session, and a single-request count would +/// let the estimator apply a per-request context tier to the whole aggregate. +fn clear_stats_cache_request_counts(summary: &mut UsageSummary) -> bool { + if summary.metadata.summary_format != "claude_stats_cache" { + return false; + } + let mut changed = summary.usage.requests.take().is_some(); + for model_usage in &mut summary.models { + changed |= model_usage.usage.requests.take().is_some(); + } + changed +} + fn estimated_summary_cost( provider: &str, model: Option<&ModelInfo>, diff --git a/crates/statsai-store/src/pricing/tests/repricing.rs b/crates/statsai-store/src/pricing/tests/repricing.rs index 1f5ca3b..85b1702 100644 --- a/crates/statsai-store/src/pricing/tests/repricing.rs +++ b/crates/statsai-store/src/pricing/tests/repricing.rs @@ -600,6 +600,36 @@ fn dangling_task_span_links_clear_stale_cost() { assert!(stored[0].estimated_cost_micro_usd.is_none()); } +#[test] +fn stats_cache_totals_drop_their_request_count_and_never_take_a_per_request_tier() { + let (store, source) = store_with_source("/tmp/claude-stats-cache-haiku"); + let start = parse_utc("2026-10-07T00:00:00Z"); + let end = parse_utc("2026-10-08T00:00:00Z"); + let mut summary = test_summary(&source, "claude-haiku-5-5", start, end, missing_cost()); + summary.provider = "claude_code".to_string(); + summary.source.source_kind = SourceKind::LocalSummary; + summary.metadata.summary_format = "claude_stats_cache".to_string(); + summary.usage = UsageCounts { + input_tokens: Some(120_000), + output_tokens: Some(20_000), + requests: Some(1), + ..UsageCounts::default() + }; + store.upsert_summary(&summary).expect("summary"); + + let report = store.ensure_current_pricing().expect("reprice"); + let stored = store + .summaries() + .expect("summaries") + .into_iter() + .next() + .expect("one summary"); + + assert_eq!(report.changed_summaries, 1); + assert_eq!(stored.usage.requests, None); + assert_eq!(stored.cost.estimated_api_equivalent_micro_usd, Some(22_000)); +} + #[test] fn summary_inside_one_pricing_window_is_repriced() { let (store, source) = store_with_source("/tmp/codex-window-summary"); From 04e5f0db0517fe6847a28a694cf58fb561e6f77b Mon Sep 17 00:00:00 2001 From: Dmitry Starkov <21260939+starkdmi@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:54:47 +0300 Subject: [PATCH 4/4] Keep loose Codex usage lines out of per-request pricing tiers The Codex adapter's fallback for loose `usage` lines stamped `requests: 1`, but `codex exec --json` writes the thread's running total on `turn.completed`, so a combined total above 272K was billed at the GPT long-context rates. Parse these lines without a request count, and clear the stale count on stored events when they are repriced. --- .../statsai-adapters/src/codex/parse/mod.rs | 8 ++- .../src/codex/tests/parse/usage.rs | 31 +++++++++++ crates/statsai-store/src/pricing/mod.rs | 28 +++++++++- .../src/pricing/tests/repricing.rs | 54 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/crates/statsai-adapters/src/codex/parse/mod.rs b/crates/statsai-adapters/src/codex/parse/mod.rs index 1ea485f..04ac278 100644 --- a/crates/statsai-adapters/src/codex/parse/mod.rs +++ b/crates/statsai-adapters/src/codex/parse/mod.rs @@ -488,7 +488,13 @@ pub(crate) fn parse_codex_file( } }) } else { - codex_headless_usage_value(&value).map(codex_usage_counts_from_value) + // A loose `usage` line has no fixed granularity: `codex exec --json` + // writes the thread's running total on `turn.completed`. It must not + // carry the single-request count that selects a context tier. + codex_headless_usage_value(&value).map(|usage| UsageCounts { + requests: None, + ..codex_usage_counts_from_value(usage) + }) }; let quota_usage_sample = if is_token_count_event { token_count_usage diff --git a/crates/statsai-adapters/src/codex/tests/parse/usage.rs b/crates/statsai-adapters/src/codex/tests/parse/usage.rs index 8d5df7d..4f9cf31 100644 --- a/crates/statsai-adapters/src/codex/tests/parse/usage.rs +++ b/crates/statsai-adapters/src/codex/tests/parse/usage.rs @@ -211,6 +211,37 @@ fn codex_headless_usage_shapes_are_parsed() { assert_eq!(scan.events[0].usage.cache_read_tokens, Some(3)); } +#[test] +fn codex_headless_turn_totals_never_take_a_per_request_context_tier() { + let dir = tempfile::tempdir().expect("tempdir"); + let sessions = dir.path().join("sessions"); + std::fs::create_dir_all(&sessions).expect("sessions"); + let mut file = File::create(sessions.join("exec.jsonl")).expect("fixture"); + writeln!( + file, + r#"{{"type":"turn.completed","timestamp":"2026-10-07T12:00:00Z","model":"gpt-6-sol","usage":{{"input_tokens":300000,"cached_input_tokens":0,"output_tokens":10000}}}}"# + ) + .expect("write"); + let source = SourceLocation::local_adapter( + CODEX_PROVIDER, + "test", + "0", + dir.path(), + LocationOrigin::Configured, + ); + + let scan = scan_codex_source(&CodexAdapter, &source, &options()).expect("scan"); + + assert_eq!(scan.events.len(), 1); + assert_eq!(scan.events[0].usage.requests, None); + // `codex exec --json` writes the thread's running total here, so the + // 272k threshold says nothing about any one request. + assert_eq!( + scan.events[0].cost.estimated_api_equivalent_micro_usd, + Some(700_000) + ); +} + #[test] fn codex_usage_counts_normalize_inclusive_subtotals() { let value: Value = serde_json::json!({ diff --git a/crates/statsai-store/src/pricing/mod.rs b/crates/statsai-store/src/pricing/mod.rs index b796f67..49e9aad 100644 --- a/crates/statsai-store/src/pricing/mod.rs +++ b/crates/statsai-store/src/pricing/mod.rs @@ -427,6 +427,7 @@ fn reprice_event(event: &UsageEvent) -> Option { // feeds grouping as well as pricing, so refresh it here rather than only // working around it at lookup time. let model = event.model.as_ref().map(model_with_refreshed_normalization); + let usage = usage_without_stale_request_count(event); // An adapter that priced from per-request rows beat what this event's // aggregate can express, so only the model normalization is refreshed here. // A rescan, which re-reads those rows, is what refreshes the estimate. @@ -436,20 +437,43 @@ fn reprice_event(event: &UsageEvent) -> Option { let estimated = estimate_cost_at( &event.provider, model.as_ref(), - &event.usage, + &usage, &event.session.started_at, ); overlay_estimated_cost(&event.cost, estimated) }; - if cost == event.cost && model == event.model { + if cost == event.cost && model == event.model && usage == event.usage { return None; } let mut updated = event.clone(); updated.model = model; + updated.usage = usage; updated.cost = cost; Some(updated) } +/// Event kind of the Codex adapter's loose `usage` lines, read back from the +/// parse-evidence key `{key_version}:{event_kind}:...`. +const CODEX_HEADLESS_USAGE_EVENT_KIND: &str = "codex_headless_usage"; + +/// Usage with the `requests: 1` older Codex parsers stamped on loose `usage` +/// lines cleared. Such a line can be a thread's running total, and a +/// single-request count would apply a per-request context tier to all of it. +fn usage_without_stale_request_count(event: &UsageEvent) -> statsai_core::UsageCounts { + let is_codex_headless_usage = event.provider == "codex" + && event + .parse_evidence + .as_ref() + .and_then(|evidence| evidence.source_record_id.as_deref()) + .and_then(|key| key.split(':').nth(1)) + == Some(CODEX_HEADLESS_USAGE_EVENT_KIND); + let mut usage = event.usage.clone(); + if is_codex_headless_usage { + usage.requests = None; + } + usage +} + /// Overlays this binary's estimated pricing onto a summary. /// /// Daily rollups are left unchanged. Returns the original summary when diff --git a/crates/statsai-store/src/pricing/tests/repricing.rs b/crates/statsai-store/src/pricing/tests/repricing.rs index 85b1702..a927256 100644 --- a/crates/statsai-store/src/pricing/tests/repricing.rs +++ b/crates/statsai-store/src/pricing/tests/repricing.rs @@ -600,6 +600,60 @@ fn dangling_task_span_links_clear_stale_cost() { assert!(stored[0].estimated_cost_micro_usd.is_none()); } +#[test] +fn codex_headless_usage_events_drop_their_request_count_but_token_counts_keep_it() { + let (store, source) = store_with_source("/tmp/codex-headless-usage"); + let started_at = parse_utc("2026-10-07T12:00:00Z"); + let usage = UsageCounts { + input_tokens: Some(300_000), + output_tokens: Some(10_000), + requests: Some(1), + ..UsageCounts::default() + }; + let with_kind = |record_id: &str, event_kind: &str| { + let mut event = test_event( + &source, + started_at, + record_id, + "gpt-6-sol", + usage.clone(), + missing_cost(), + ); + event.parse_evidence = Some(statsai_core::ParseEvidence { + event_key_version: "semantic_usage_event.v1".to_string(), + source_file_path_hash: None, + source_line_number: Some(1), + source_record_id: Some(format!("semantic_usage_event.v1:{event_kind}:{record_id}")), + model_inferred: false, + timestamp_inferred: false, + account_identity_source: statsai_core::IdentitySource::Unresolved, + }); + event + }; + let headless = with_kind("headless", "codex_headless_usage"); + let mut token_count = with_kind("token-count", "codex_token_count"); + token_count.session.local_session_id_hash = Some("other-session".to_string()); + store.insert_event(&headless).expect("insert headless"); + store + .insert_event(&token_count) + .expect("insert token count"); + + store.ensure_current_pricing().expect("reprice"); + let headless = stored_event(&store, &headless.event_id.0); + let token_count = stored_event(&store, &token_count.event_id.0); + + assert_eq!(headless.usage.requests, None); + assert_eq!( + headless.cost.estimated_api_equivalent_micro_usd, + Some(700_000) + ); + assert_eq!(token_count.usage.requests, Some(1)); + assert_eq!( + token_count.cost.estimated_api_equivalent_micro_usd, + Some(1_350_000) + ); +} + #[test] fn stats_cache_totals_drop_their_request_count_and_never_take_a_per_request_tier() { let (store, source) = store_with_source("/tmp/claude-stats-cache-haiku");