diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index c3b4ded3c9..7e20dbf958 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -2238,6 +2238,7 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { output_tokens: output_tokens.min(u64::from(u32::MAX)) as u32, prompt_cache_hit_tokens, prompt_cache_miss_tokens, + prompt_cache_write_tokens: None, reasoning_tokens, reasoning_replay_tokens: None, server_tool_use, diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index b1b59d83dd..65414a3a5b 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -11,10 +11,11 @@ //! CodeWhale's `reasoning_effort` tiers, sampling-parameter rules for //! models that reject them, and `cache_control` breakpoint placement //! aligned with the prefix-zone model in `prefix_cache.rs`; -//! - usage normalization (#2961): `prompt_cache_hit_tokens` comes from -//! `cache_read_input_tokens`, `prompt_cache_miss_tokens` is `input_tokens` -//! plus `cache_creation_input_tokens`, and the normalized `input_tokens` -//! is the sum of all three (total prompt, the DeepSeek convention); +//! - usage normalization (#2961 / #4318): `prompt_cache_hit_tokens` comes from +//! `cache_read_input_tokens`, `prompt_cache_write_tokens` from +//! `cache_creation_input_tokens`, `prompt_cache_miss_tokens` is the raw +//! non-cached `input_tokens`, and the normalized `input_tokens` is the sum +//! of all three (total prompt, the DeepSeek convention); //! - signed-thinking handling: `signature_delta` is captured into //! [`crate::models::Delta::SignatureDelta`] and assistant thinking blocks //! replay verbatim (signature included); unsigned thinking blocks are @@ -496,8 +497,8 @@ fn convert_anthropic_sse_data(data: &str) -> Option> { } /// Map Anthropic's usage payload onto the normalized [`Usage`] convention -/// (#2961): hit = cache reads, miss = uncached input + cache writes, -/// `input_tokens` = the total prompt across all three. +/// (#2961 / #4318): hit = cache reads, write = cache creation, miss = raw +/// uncached input, `input_tokens` = the total prompt across all three. fn parse_anthropic_usage(usage: &Value) -> Usage { let field = |name: &str| { usage @@ -517,7 +518,8 @@ fn parse_anthropic_usage(usage: &Value) -> Usage { .saturating_add(cache_read), output_tokens: output, prompt_cache_hit_tokens: Some(cache_read), - prompt_cache_miss_tokens: Some(input_raw.saturating_add(cache_creation)), + prompt_cache_miss_tokens: Some(input_raw), + prompt_cache_write_tokens: Some(cache_creation), reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, @@ -852,7 +854,8 @@ mod tests { }; assert_eq!(message.usage.input_tokens, 3 + 2045 + 18000); assert_eq!(message.usage.prompt_cache_hit_tokens, Some(18000)); - assert_eq!(message.usage.prompt_cache_miss_tokens, Some(3 + 2045)); + assert_eq!(message.usage.prompt_cache_miss_tokens, Some(3)); + assert_eq!(message.usage.prompt_cache_write_tokens, Some(2045)); assert!(matches!( &decoded[1], @@ -927,6 +930,21 @@ mod tests { assert_eq!(usage.output_tokens, 5); assert_eq!(usage.prompt_cache_hit_tokens, Some(0)); assert_eq!(usage.prompt_cache_miss_tokens, Some(10)); + assert_eq!(usage.prompt_cache_write_tokens, Some(0)); + } + + #[test] + fn usage_mapping_keeps_cache_write_separate_from_miss() { + let usage = parse_anthropic_usage(&json!({ + "input_tokens": 3, + "cache_creation_input_tokens": 2045, + "cache_read_input_tokens": 18000, + "output_tokens": 1, + })); + assert_eq!(usage.input_tokens, 3 + 2045 + 18000); + assert_eq!(usage.prompt_cache_hit_tokens, Some(18000)); + assert_eq!(usage.prompt_cache_miss_tokens, Some(3)); + assert_eq!(usage.prompt_cache_write_tokens, Some(2045)); } #[test] diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 2a74837b04..590601aacc 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -707,6 +707,7 @@ fn parse_responses_usage(val: &Value) -> Usage { output_tokens: output, prompt_cache_hit_tokens, prompt_cache_miss_tokens, + prompt_cache_write_tokens: None, reasoning_tokens, reasoning_replay_tokens: None, server_tool_use: None, diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 64520cd9be..1e747e0395 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2069,8 +2069,8 @@ impl Engine { if let Some(hit_tokens) = usage.cache_read_input_tokens { line.push_str(&format!(", cache hits {hit_tokens}")); } - if let Some(miss_tokens) = usage.cache_creation_input_tokens { - line.push_str(&format!(", cache misses {miss_tokens}")); + if let Some(write_tokens) = usage.cache_creation_input_tokens { + line.push_str(&format!(", cache writes {write_tokens}")); } Some(line) } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 03de2cf7e7..eb414bb4b6 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -5461,6 +5461,7 @@ fn turn_metadata_surfaces_context_and_resource_usage() { output_tokens: 300, prompt_cache_hit_tokens: Some(800), prompt_cache_miss_tokens: Some(400), + prompt_cache_write_tokens: Some(400), ..Default::default() }); { @@ -5487,7 +5488,7 @@ fn turn_metadata_surfaces_context_and_resource_usage() { "session usage should be model-visible: {text}" ); assert!(text.contains("cache hits 800"), "got: {text}"); - assert!(text.contains("cache misses 400"), "got: {text}"); + assert!(text.contains("cache writes 400"), "got: {text}"); assert!( text.contains("Active goal resource usage:"), "active goal resource usage should be model-visible: {text}" diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 1ba0a1c619..eec91eb4cf 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -98,8 +98,8 @@ pub struct Session { pub struct SessionUsage { pub input_tokens: u64, pub output_tokens: u64, - /// Cache creation (miss) tokens. `None` when never observed by the API — - /// do NOT display as 0, which would be indistinguishable from "no misses". + /// Cache creation (write) tokens. `None` when never observed by the API — + /// do NOT display as 0, which would be indistinguishable from "no writes". pub cache_creation_input_tokens: Option, /// Cache read (hit) tokens. `None` when never observed by the API — /// do NOT display as 0, which would be indistinguishable from "no hits". @@ -111,7 +111,7 @@ impl SessionUsage { pub fn add(&mut self, usage: &Usage) { self.input_tokens += u64::from(usage.input_tokens); self.output_tokens += u64::from(usage.output_tokens); - if let Some(tokens) = usage.prompt_cache_miss_tokens { + if let Some(tokens) = usage.prompt_cache_write_tokens { self.cache_creation_input_tokens = Some(self.cache_creation_input_tokens.unwrap_or(0) + u64::from(tokens)); } @@ -218,6 +218,7 @@ mod tests { output_tokens: 50, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, + prompt_cache_write_tokens: None, reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, @@ -234,17 +235,18 @@ mod tests { input_tokens: 100, output_tokens: 50, prompt_cache_hit_tokens: Some(30), - prompt_cache_miss_tokens: Some(70), + prompt_cache_miss_tokens: Some(50), + prompt_cache_write_tokens: Some(20), reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, }; usage.add(&api_usage); assert_eq!(usage.cache_read_input_tokens, Some(30)); - assert_eq!(usage.cache_creation_input_tokens, Some(70)); + assert_eq!(usage.cache_creation_input_tokens, Some(20)); usage.add(&api_usage); assert_eq!(usage.cache_read_input_tokens, Some(60)); - assert_eq!(usage.cache_creation_input_tokens, Some(140)); + assert_eq!(usage.cache_creation_input_tokens, Some(40)); } #[test] @@ -254,7 +256,8 @@ mod tests { input_tokens: 100, output_tokens: 50, prompt_cache_hit_tokens: Some(0), // explicit zero from provider - prompt_cache_miss_tokens: Some(1234), + prompt_cache_miss_tokens: Some(50), + prompt_cache_write_tokens: Some(1234), reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, diff --git a/crates/tui/src/core/turn.rs b/crates/tui/src/core/turn.rs index 84b91b9f83..a00017f10c 100644 --- a/crates/tui/src/core/turn.rs +++ b/crates/tui/src/core/turn.rs @@ -108,6 +108,10 @@ impl TurnContext { self.usage.prompt_cache_miss_tokens, usage.prompt_cache_miss_tokens, ); + self.usage.prompt_cache_write_tokens = add_optional_usage( + self.usage.prompt_cache_write_tokens, + usage.prompt_cache_write_tokens, + ); self.usage.reasoning_tokens = add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens); } diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index 7edfb7f934..046417a1e0 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -1301,6 +1301,7 @@ pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value { "output_tokens": input.usage.output_tokens, "prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens, "prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens, + "prompt_cache_write_tokens": input.usage.prompt_cache_write_tokens, "reasoning_tokens": input.usage.reasoning_tokens, "reasoning_replay_tokens": input.usage.reasoning_replay_tokens, }, @@ -1567,6 +1568,7 @@ NOEQUAL line dropped output_tokens: 9, prompt_cache_hit_tokens: Some(10), prompt_cache_miss_tokens: Some(30), + prompt_cache_write_tokens: None, reasoning_tokens: Some(4), reasoning_replay_tokens: Some(2), server_tool_use: None, @@ -1873,6 +1875,7 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}' output_tokens: 3, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, + prompt_cache_write_tokens: None, reasoning_tokens: None, reasoning_replay_tokens: None, server_tool_use: None, diff --git a/crates/tui/src/llm_response_cache.rs b/crates/tui/src/llm_response_cache.rs index 8141f19454..1e67b7760a 100644 --- a/crates/tui/src/llm_response_cache.rs +++ b/crates/tui/src/llm_response_cache.rs @@ -94,6 +94,7 @@ mod tests { output_tokens: 7, prompt_cache_hit_tokens: Some(3), prompt_cache_miss_tokens: Some(39), + prompt_cache_write_tokens: None, reasoning_tokens: Some(5), reasoning_replay_tokens: Some(2), server_tool_use: None, diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index d8f35f1750..946da77eb0 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -213,6 +213,10 @@ pub struct Usage { pub prompt_cache_hit_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub prompt_cache_miss_tokens: Option, + /// Cache-creation / cache-write tokens (Anthropic `cache_creation_input_tokens`). + /// Billed at the cache-write rate when the pricing row publishes one (#4318). + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_write_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_tokens: Option, /// Approximate input tokens spent re-sending prior `reasoning_content` diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 76ba7127cb..4f02197ba2 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -6,7 +6,7 @@ //! reliable balance endpoint exists. use chrono::{DateTime, TimeZone, Utc}; -use codewhale_config::pricing::TokenUsage; +use codewhale_config::pricing::{OfferingPricing, TokenUsage}; use crate::config::ApiProvider; use crate::models::Usage; @@ -99,6 +99,9 @@ struct CurrencyPricing { input_cache_hit_per_million: f64, input_cache_miss_per_million: f64, output_per_million: f64, + /// Cache-write (creation) rate. `None` means write tokens are billed at + /// the cache-miss / input rate (providers without a separate write tier). + cache_write_per_million: Option, } /// Per-million-token pricing for a model. @@ -167,19 +170,19 @@ fn known_pricing_for_model(model_lower: &str) -> Option { "openai/gpt-5.6-luna" | "gpt-5.6-luna" => Some(usd_only_pricing(0.10, 1.00, 6.00)), "meta/muse-spark-1.1" | "muse-spark-1.1" => Some(usd_only_pricing(1.25, 1.25, 4.25)), // Anthropic first-party rates including the published cache-read - // discounts (2026-07-09 audit, + // discounts and 5-minute cache-write rates (2026-07-09 audit, // https://platform.claude.com/docs/en/about-claude/pricing). These sit - // above the catalog lookup because the bundled catalog cannot carry a - // cache-read rate yet. Cache-write rates (1.25x-2x input) exist - // upstream but `CurrencyPricing` has no cache-write field. - "claude-opus-4-8" => Some(usd_only_pricing(0.50, 5.00, 25.00)), - "claude-sonnet-4-6" => Some(usd_only_pricing(0.30, 3.00, 15.00)), - "claude-haiku-4-5" => Some(usd_only_pricing(0.10, 1.00, 5.00)), + // above the catalog lookup because the bundled catalog cannot carry + // cache-read/write rates yet. 1h write is 2x input; we price the + // common 5m tier (1.25x input) here (#4318). + "claude-opus-4-8" => Some(usd_pricing_with_write(0.50, 5.00, 25.00, 6.25)), + "claude-sonnet-4-6" => Some(usd_pricing_with_write(0.30, 3.00, 15.00, 3.75)), + "claude-haiku-4-5" => Some(usd_pricing_with_write(0.10, 1.00, 5.00, 1.25)), // Claude Fable 5 (GA 2026-06-09). Its newer tokenizer produces ~30% // more tokens for the same text than prior Claude models, so raw // per-token rate comparisons against other Claude rows undercount its // effective cost. Cache-write is 12.50 (5m) / 20.00 (1h) upstream. - "claude-fable-5" => Some(usd_only_pricing(1.00, 10.00, 50.00)), + "claude-fable-5" => Some(usd_pricing_with_write(1.00, 10.00, 50.00, 12.50)), // Z.ai GLM-5.2 cache-read rate per https://docs.z.ai/guides/overview/pricing // (cache storage limited-time free). "z-ai/glm-5.2" | "glm-5.2" => Some(usd_only_pricing(0.26, 1.40, 4.40)), @@ -228,8 +231,8 @@ fn known_pricing_for_model(model_lower: &str) -> Option { "qwen/qwen3.6-max-preview" => Some(usd_only_pricing(1.04, 1.04, 6.24)), "qwen/qwen3.6-27b" => Some(usd_only_pricing(0.15, 0.285, 2.40)), "qwen/qwen3.6-plus" => Some(usd_only_pricing(0.325, 0.325, 1.95)), - // Cache-write is 0.40 upstream; CurrencyPricing has no cache-write field. - "qwen/qwen3.7-plus" => Some(usd_only_pricing(0.064, 0.32, 1.28)), + // Cache-write is 0.40 upstream (#4318). + "qwen/qwen3.7-plus" => Some(usd_pricing_with_write(0.064, 0.32, 1.28, 0.40)), "qwen/qwen3.7-max" => Some(usd_only_pricing(0.25, 1.25, 3.75)), "google/gemma-4-31b-it" => Some(usd_only_pricing(0.09, 0.12, 0.35)), @@ -246,29 +249,59 @@ fn usd_only_pricing( input_cache_hit_per_million: f64, input_cache_miss_per_million: f64, output_per_million: f64, +) -> ModelPricing { + usd_pricing( + input_cache_hit_per_million, + input_cache_miss_per_million, + output_per_million, + None, + ) +} + +fn usd_pricing_with_write( + input_cache_hit_per_million: f64, + input_cache_miss_per_million: f64, + output_per_million: f64, + cache_write_per_million: f64, +) -> ModelPricing { + usd_pricing( + input_cache_hit_per_million, + input_cache_miss_per_million, + output_per_million, + Some(cache_write_per_million), + ) +} + +fn usd_pricing( + input_cache_hit_per_million: f64, + input_cache_miss_per_million: f64, + output_per_million: f64, + cache_write_per_million: Option, ) -> ModelPricing { ModelPricing { usd: CurrencyPricing { input_cache_hit_per_million, input_cache_miss_per_million, output_per_million, + cache_write_per_million, }, cny: None, } } /// Claude Sonnet 5 pricing (https://platform.claude.com/docs/en/about-claude/pricing): -/// introductory 2.00 / 10.00 (cache-read 0.20) through 2026-08-31 UTC, then -/// the standard 3.00 / 15.00 (cache-read 0.30). +/// introductory 2.00 / 10.00 (cache-read 0.20, cache-write 2.50) through +/// 2026-08-31 UTC, then the standard 3.00 / 15.00 (cache-read 0.30, +/// cache-write 3.75). Write rates are the published 5-minute tier (#4318). fn claude_sonnet_5_pricing(now: DateTime) -> ModelPricing { let intro_ends = Utc .with_ymd_and_hms(2026, 9, 1, 0, 0, 0) .single() .expect("valid intro-pricing cutoff"); if now < intro_ends { - usd_only_pricing(0.20, 2.00, 10.00) + usd_pricing_with_write(0.20, 2.00, 10.00, 2.50) } else { - usd_only_pricing(0.30, 3.00, 15.00) + usd_pricing_with_write(0.30, 3.00, 15.00, 3.75) } } @@ -278,11 +311,13 @@ fn deepseek_v4_pro_pricing() -> ModelPricing { input_cache_hit_per_million: 0.003625, input_cache_miss_per_million: 0.435, output_per_million: 0.87, + cache_write_per_million: None, }, cny: Some(CurrencyPricing { input_cache_hit_per_million: 0.025, input_cache_miss_per_million: 3.0, output_per_million: 6.0, + cache_write_per_million: None, }), } } @@ -293,11 +328,13 @@ fn deepseek_v4_flash_pricing() -> ModelPricing { input_cache_hit_per_million: 0.0028, input_cache_miss_per_million: 0.14, output_per_million: 0.28, + cache_write_per_million: None, }, cny: Some(CurrencyPricing { input_cache_hit_per_million: 0.02, input_cache_miss_per_million: 1.0, output_per_million: 2.0, + cache_write_per_million: None, }), } } @@ -334,23 +371,52 @@ pub fn calculate_turn_cost_estimate_for_provider( if provider == ApiProvider::OpenaiCodex { return None; } + if usage.prompt_cache_write_tokens.unwrap_or(0) > 0 + && let Some(estimate) = crate::provider_lake::catalog_offering_for_model(provider, model) + .as_ref() + .and_then(|offering| catalog_cost_estimate_from_offering(offering, usage)) + { + return Some(estimate); + } calculate_turn_cost_estimate_from_usage(model, usage) } +/// Estimate cache-write usage from a sourced catalog row when it publishes the +/// separate write tier. Other usage continues through the legacy table, which +/// retains CNY estimates and compatibility fallbacks. +fn catalog_cost_estimate_from_offering( + offering: &codewhale_config::catalog::CatalogOffering, + usage: &Usage, +) -> Option { + let usage = token_usage_for_pricing(usage); + let pricing = OfferingPricing::from_catalog_offering(offering)?; + if usage.cache_write == 0 || pricing.cache_write_per_million.is_none() { + return None; + } + + pricing.estimate_cost(&usage).map(CostEstimate::usd_only) +} + /// Project provider-normalized turn usage into canonical billable token -/// classes for the shared config pricing layer (#2961). +/// classes for the shared config pricing layer (#2961 / #4318). /// -/// `Usage::prompt_cache_miss_tokens` is billed as ordinary non-cached input in -/// current CodeWhale pricing rows. `cache_write` remains zero because the TUI -/// `Usage` shape does not yet distinguish cache creation/write tokens from -/// ordinary cache misses. +/// `Usage::prompt_cache_miss_tokens` is billed as ordinary non-cached input. +/// `Usage::prompt_cache_write_tokens` maps to `TokenUsage::cache_write` so +/// providers that publish a write premium (Anthropic 1.25x–2x) are not +/// undercounted. #[must_use] pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage { let cache_read = usage.prompt_cache_hit_tokens.unwrap_or(0); - let non_cached_reported = usage - .prompt_cache_miss_tokens - .unwrap_or_else(|| usage.input_tokens.saturating_sub(cache_read)); - let accounted_input = cache_read.saturating_add(non_cached_reported); + let cache_write = usage.prompt_cache_write_tokens.unwrap_or(0); + let non_cached_reported = usage.prompt_cache_miss_tokens.unwrap_or_else(|| { + usage + .input_tokens + .saturating_sub(cache_read) + .saturating_sub(cache_write) + }); + let accounted_input = cache_read + .saturating_add(non_cached_reported) + .saturating_add(cache_write); let uncategorized_input = usage.input_tokens.saturating_sub(accounted_input); let input = non_cached_reported.saturating_add(uncategorized_input); let output = usage @@ -361,7 +427,7 @@ pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage { input: u64::from(input), output: u64::from(output), cache_read: u64::from(cache_read), - cache_write: 0, + cache_write: u64::from(cache_write), } } @@ -369,8 +435,12 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: let usage = token_usage_for_pricing(usage); let hit_cost = (usage.cache_read as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million; let miss_cost = (usage.input as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million; + let write_rate = pricing + .cache_write_per_million + .unwrap_or(pricing.input_cache_miss_per_million); + let write_cost = (usage.cache_write as f64 / 1_000_000.0) * write_rate; let output_cost = (usage.output as f64 / 1_000_000.0) * pricing.output_per_million; - hit_cost + miss_cost + output_cost + hit_cost + miss_cost + write_cost + output_cost } /// Estimate how much money was saved by serving `cache_hit_tokens` from the @@ -517,6 +587,90 @@ mod tests { assert!(estimate.usd > 0.0, "expected positive USD for {model}"); assert_eq!(estimate.cny, 0.0); } + + // Anthropic / Qwen rows that publish a cache-write premium (#4318). + for (model, write) in [ + ("claude-opus-4-8", Some(6.25)), + ("claude-sonnet-4-6", Some(3.75)), + ("claude-haiku-4-5", Some(1.25)), + ("claude-fable-5", Some(12.50)), + ("qwen/qwen3.7-plus", Some(0.40)), + ("gpt-5.5", None), + ] { + let pricing = pricing_for_model_at(model, Utc::now()).expect(model); + assert_eq!( + pricing.usd.cache_write_per_million, write, + "cache-write rate for {model}" + ); + } + } + + #[test] + fn cache_write_tokens_increase_anthropic_cost_estimate() { + let with_write = Usage { + input_tokens: 12_048, + output_tokens: 1, + prompt_cache_hit_tokens: Some(10_000), + prompt_cache_miss_tokens: Some(3), + prompt_cache_write_tokens: Some(2_045), + ..Default::default() + }; + let write_as_miss = Usage { + input_tokens: 12_048, + output_tokens: 1, + prompt_cache_hit_tokens: Some(10_000), + prompt_cache_miss_tokens: Some(2_048), + prompt_cache_write_tokens: None, + ..Default::default() + }; + + let priced = + calculate_turn_cost_estimate_from_usage("claude-fable-5", &with_write).expect("priced"); + let undercounted = + calculate_turn_cost_estimate_from_usage("claude-fable-5", &write_as_miss) + .expect("priced"); + // 2045 write @ 12.50 vs same tokens @ miss 10.00 → ~0.005 USD premium. + assert!( + priced.usd > undercounted.usd, + "write premium should raise cost: priced={} undercounted={}", + priced.usd, + undercounted.usd + ); + let expected_premium = (2_045.0 / 1_000_000.0) * (12.50 - 10.00); + assert!( + (priced.usd - undercounted.usd - expected_premium).abs() < 1e-9, + "premium delta mismatch: {}", + priced.usd - undercounted.usd + ); + } + + #[test] + fn catalog_pricing_uses_its_cache_write_rate() { + let offering = codewhale_config::catalog::CatalogOffering { + provider: "anthropic".to_string(), + wire_model_id: "catalog-priced-model".to_string(), + endpoint_key: "chat".to_string(), + cost: Some(codewhale_config::models_dev::ModelsDevCost { + input: Some(10.0), + output: Some(50.0), + cache_read: Some(1.0), + cache_write: Some(12.5), + }), + ..Default::default() + }; + let usage = Usage { + input_tokens: 13, + output_tokens: 5, + prompt_cache_hit_tokens: Some(2), + prompt_cache_miss_tokens: Some(3), + prompt_cache_write_tokens: Some(8), + ..Default::default() + }; + + let estimate = + catalog_cost_estimate_from_offering(&offering, &usage).expect("catalog cost estimate"); + assert!((estimate.usd - 0.000_382).abs() < 1e-15); + assert_eq!(estimate.cny, 0.0); } #[test] @@ -526,6 +680,7 @@ mod tests { output_tokens: 100, prompt_cache_hit_tokens: Some(250), prompt_cache_miss_tokens: Some(700), + prompt_cache_write_tokens: Some(50), reasoning_tokens: Some(50), ..Default::default() }; @@ -533,10 +688,10 @@ mod tests { assert_eq!( token_usage_for_pricing(&usage), TokenUsage { - input: 750, + input: 700, output: 150, cache_read: 250, - cache_write: 0, + cache_write: 50, } ); } @@ -633,6 +788,7 @@ mod tests { assert_eq!(pricing.usd.input_cache_hit_per_million, 0.20); assert_eq!(pricing.usd.input_cache_miss_per_million, 2.00); assert_eq!(pricing.usd.output_per_million, 10.00); + assert_eq!(pricing.usd.cache_write_per_million, Some(2.50)); assert!(pricing.cny.is_none()); } @@ -644,6 +800,7 @@ mod tests { assert_eq!(pricing.usd.input_cache_hit_per_million, 0.30); assert_eq!(pricing.usd.input_cache_miss_per_million, 3.00); assert_eq!(pricing.usd.output_per_million, 15.00); + assert_eq!(pricing.usd.cache_write_per_million, Some(3.75)); assert!(pricing.cny.is_none()); assert!(has_pricing_for_model("claude-sonnet-5")); } diff --git a/crates/tui/src/rlm/mod.rs b/crates/tui/src/rlm/mod.rs index 2a5baf69b1..110d8d8b4e 100644 --- a/crates/tui/src/rlm/mod.rs +++ b/crates/tui/src/rlm/mod.rs @@ -42,6 +42,10 @@ fn add_usage_with_prompt_cache(total: &mut Usage, delta: &Usage) { total.prompt_cache_miss_tokens, delta.prompt_cache_miss_tokens, ); + total.prompt_cache_write_tokens = add_optional_usage( + total.prompt_cache_write_tokens, + delta.prompt_cache_write_tokens, + ); } fn add_optional_usage(total: Option, delta: Option) -> Option { diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index b886ea84bb..7456aec7b7 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -407,6 +407,7 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result