Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/statsai-adapters/src/claude/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 46 additions & 3 deletions crates/statsai-adapters/src/claude/tests/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
8 changes: 7 additions & 1 deletion crates/statsai-adapters/src/codex/parse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions crates/statsai-adapters/src/codex/tests/parse/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down
38 changes: 25 additions & 13 deletions crates/statsai-pricing/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions crates/statsai-pricing/src/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -189,13 +190,25 @@ 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"
| "gpt-5.6-sol"
| "gpt-5.6-terra"
| "gpt-5.6-luna"
| "gpt-6-astra"
| "gpt-6.1-sol"
| "gpt-6-sol"
| "gpt-6-luna"
);
Expand Down
4 changes: 2 additions & 2 deletions crates/statsai-pricing/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions crates/statsai-pricing/src/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
}
Expand All @@ -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");
}
Expand All @@ -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");
}
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
Loading
Loading