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 5570abf..fdb8400 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, @@ -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-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-pricing/src/catalog.rs b/crates/statsai-pricing/src/catalog.rs index c8a101a..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 @@ -58,7 +59,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, @@ -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,11 +171,18 @@ 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, // 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. @@ -226,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 61ef5a3..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" @@ -196,6 +208,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..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"); } @@ -91,6 +99,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"); } @@ -239,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(), @@ -261,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(), @@ -274,6 +289,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..5199a8b 100644 --- a/crates/statsai-pricing/src/tests/catalog.rs +++ b/crates/statsai-pricing/src/tests/catalog.rs @@ -1,18 +1,37 @@ 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() { @@ -80,3 +99,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..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 { @@ -943,9 +1015,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..cee27c2 100644 --- a/crates/statsai-pricing/src/tests/normalize.rs +++ b/crates/statsai-pricing/src/tests/normalize.rs @@ -54,6 +54,46 @@ 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"); + 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"); diff --git a/crates/statsai-store/src/pricing/mod.rs b/crates/statsai-store/src/pricing/mod.rs index d0b0abe..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 @@ -466,7 +490,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 +527,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..a927256 100644 --- a/crates/statsai-store/src/pricing/tests/repricing.rs +++ b/crates/statsai-store/src/pricing/tests/repricing.rs @@ -600,6 +600,90 @@ 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"); + 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");