Skip to content

Commit f0a617b

Browse files
committed
fix(tui): bill Anthropic cache-write tokens at published rates
Keep cache-creation usage separate from cache-miss and wire CurrencyPricing.cache_write so TUI cost estimates stop undercounting Anthropic/Qwen write premiums (#4318). Signed-off-by: knqiufan <knqiufan@foxmail.com>
1 parent 3e97b27 commit f0a617b

13 files changed

Lines changed: 244 additions & 46 deletions

File tree

crates/tui/src/client.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2238,6 +2238,7 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
22382238
output_tokens: output_tokens.min(u64::from(u32::MAX)) as u32,
22392239
prompt_cache_hit_tokens,
22402240
prompt_cache_miss_tokens,
2241+
prompt_cache_write_tokens: None,
22412242
reasoning_tokens,
22422243
reasoning_replay_tokens: None,
22432244
server_tool_use,

crates/tui/src/client/anthropic.rs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
//! CodeWhale's `reasoning_effort` tiers, sampling-parameter rules for
1212
//! models that reject them, and `cache_control` breakpoint placement
1313
//! aligned with the prefix-zone model in `prefix_cache.rs`;
14-
//! - usage normalization (#2961): `prompt_cache_hit_tokens` comes from
15-
//! `cache_read_input_tokens`, `prompt_cache_miss_tokens` is `input_tokens`
16-
//! plus `cache_creation_input_tokens`, and the normalized `input_tokens`
17-
//! is the sum of all three (total prompt, the DeepSeek convention);
14+
//! - usage normalization (#2961 / #4318): `prompt_cache_hit_tokens` comes from
15+
//! `cache_read_input_tokens`, `prompt_cache_write_tokens` from
16+
//! `cache_creation_input_tokens`, `prompt_cache_miss_tokens` is the raw
17+
//! non-cached `input_tokens`, and the normalized `input_tokens` is the sum
18+
//! of all three (total prompt, the DeepSeek convention);
1819
//! - signed-thinking handling: `signature_delta` is captured into
1920
//! [`crate::models::Delta::SignatureDelta`] and assistant thinking blocks
2021
//! replay verbatim (signature included); unsigned thinking blocks are
@@ -496,8 +497,8 @@ fn convert_anthropic_sse_data(data: &str) -> Option<Result<StreamEvent>> {
496497
}
497498

498499
/// Map Anthropic's usage payload onto the normalized [`Usage`] convention
499-
/// (#2961): hit = cache reads, miss = uncached input + cache writes,
500-
/// `input_tokens` = the total prompt across all three.
500+
/// (#2961 / #4318): hit = cache reads, write = cache creation, miss = raw
501+
/// uncached input, `input_tokens` = the total prompt across all three.
501502
fn parse_anthropic_usage(usage: &Value) -> Usage {
502503
let field = |name: &str| {
503504
usage
@@ -517,7 +518,8 @@ fn parse_anthropic_usage(usage: &Value) -> Usage {
517518
.saturating_add(cache_read),
518519
output_tokens: output,
519520
prompt_cache_hit_tokens: Some(cache_read),
520-
prompt_cache_miss_tokens: Some(input_raw.saturating_add(cache_creation)),
521+
prompt_cache_miss_tokens: Some(input_raw),
522+
prompt_cache_write_tokens: Some(cache_creation),
521523
reasoning_tokens: None,
522524
reasoning_replay_tokens: None,
523525
server_tool_use: None,
@@ -852,7 +854,8 @@ mod tests {
852854
};
853855
assert_eq!(message.usage.input_tokens, 3 + 2045 + 18000);
854856
assert_eq!(message.usage.prompt_cache_hit_tokens, Some(18000));
855-
assert_eq!(message.usage.prompt_cache_miss_tokens, Some(3 + 2045));
857+
assert_eq!(message.usage.prompt_cache_miss_tokens, Some(3));
858+
assert_eq!(message.usage.prompt_cache_write_tokens, Some(2045));
856859

857860
assert!(matches!(
858861
&decoded[1],
@@ -927,6 +930,21 @@ mod tests {
927930
assert_eq!(usage.output_tokens, 5);
928931
assert_eq!(usage.prompt_cache_hit_tokens, Some(0));
929932
assert_eq!(usage.prompt_cache_miss_tokens, Some(10));
933+
assert_eq!(usage.prompt_cache_write_tokens, Some(0));
934+
}
935+
936+
#[test]
937+
fn usage_mapping_keeps_cache_write_separate_from_miss() {
938+
let usage = parse_anthropic_usage(&json!({
939+
"input_tokens": 3,
940+
"cache_creation_input_tokens": 2045,
941+
"cache_read_input_tokens": 18000,
942+
"output_tokens": 1,
943+
}));
944+
assert_eq!(usage.input_tokens, 3 + 2045 + 18000);
945+
assert_eq!(usage.prompt_cache_hit_tokens, Some(18000));
946+
assert_eq!(usage.prompt_cache_miss_tokens, Some(3));
947+
assert_eq!(usage.prompt_cache_write_tokens, Some(2045));
930948
}
931949

932950
#[test]

crates/tui/src/client/responses.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,7 @@ fn parse_responses_usage(val: &Value) -> Usage {
707707
output_tokens: output,
708708
prompt_cache_hit_tokens,
709709
prompt_cache_miss_tokens,
710+
prompt_cache_write_tokens: None,
710711
reasoning_tokens,
711712
reasoning_replay_tokens: None,
712713
server_tool_use: None,

crates/tui/src/core/engine.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,8 +2069,8 @@ impl Engine {
20692069
if let Some(hit_tokens) = usage.cache_read_input_tokens {
20702070
line.push_str(&format!(", cache hits {hit_tokens}"));
20712071
}
2072-
if let Some(miss_tokens) = usage.cache_creation_input_tokens {
2073-
line.push_str(&format!(", cache misses {miss_tokens}"));
2072+
if let Some(write_tokens) = usage.cache_creation_input_tokens {
2073+
line.push_str(&format!(", cache writes {write_tokens}"));
20742074
}
20752075
Some(line)
20762076
}

crates/tui/src/core/engine/tests.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5461,6 +5461,7 @@ fn turn_metadata_surfaces_context_and_resource_usage() {
54615461
output_tokens: 300,
54625462
prompt_cache_hit_tokens: Some(800),
54635463
prompt_cache_miss_tokens: Some(400),
5464+
prompt_cache_write_tokens: Some(400),
54645465
..Default::default()
54655466
});
54665467
{
@@ -5487,7 +5488,7 @@ fn turn_metadata_surfaces_context_and_resource_usage() {
54875488
"session usage should be model-visible: {text}"
54885489
);
54895490
assert!(text.contains("cache hits 800"), "got: {text}");
5490-
assert!(text.contains("cache misses 400"), "got: {text}");
5491+
assert!(text.contains("cache writes 400"), "got: {text}");
54915492
assert!(
54925493
text.contains("Active goal resource usage:"),
54935494
"active goal resource usage should be model-visible: {text}"

crates/tui/src/core/session.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ pub struct Session {
9898
pub struct SessionUsage {
9999
pub input_tokens: u64,
100100
pub output_tokens: u64,
101-
/// Cache creation (miss) tokens. `None` when never observed by the API —
102-
/// do NOT display as 0, which would be indistinguishable from "no misses".
101+
/// Cache creation (write) tokens. `None` when never observed by the API —
102+
/// do NOT display as 0, which would be indistinguishable from "no writes".
103103
pub cache_creation_input_tokens: Option<u64>,
104104
/// Cache read (hit) tokens. `None` when never observed by the API —
105105
/// do NOT display as 0, which would be indistinguishable from "no hits".
@@ -111,7 +111,7 @@ impl SessionUsage {
111111
pub fn add(&mut self, usage: &Usage) {
112112
self.input_tokens += u64::from(usage.input_tokens);
113113
self.output_tokens += u64::from(usage.output_tokens);
114-
if let Some(tokens) = usage.prompt_cache_miss_tokens {
114+
if let Some(tokens) = usage.prompt_cache_write_tokens {
115115
self.cache_creation_input_tokens =
116116
Some(self.cache_creation_input_tokens.unwrap_or(0) + u64::from(tokens));
117117
}
@@ -218,6 +218,7 @@ mod tests {
218218
output_tokens: 50,
219219
prompt_cache_hit_tokens: None,
220220
prompt_cache_miss_tokens: None,
221+
prompt_cache_write_tokens: None,
221222
reasoning_tokens: None,
222223
reasoning_replay_tokens: None,
223224
server_tool_use: None,
@@ -234,17 +235,18 @@ mod tests {
234235
input_tokens: 100,
235236
output_tokens: 50,
236237
prompt_cache_hit_tokens: Some(30),
237-
prompt_cache_miss_tokens: Some(70),
238+
prompt_cache_miss_tokens: Some(50),
239+
prompt_cache_write_tokens: Some(20),
238240
reasoning_tokens: None,
239241
reasoning_replay_tokens: None,
240242
server_tool_use: None,
241243
};
242244
usage.add(&api_usage);
243245
assert_eq!(usage.cache_read_input_tokens, Some(30));
244-
assert_eq!(usage.cache_creation_input_tokens, Some(70));
246+
assert_eq!(usage.cache_creation_input_tokens, Some(20));
245247
usage.add(&api_usage);
246248
assert_eq!(usage.cache_read_input_tokens, Some(60));
247-
assert_eq!(usage.cache_creation_input_tokens, Some(140));
249+
assert_eq!(usage.cache_creation_input_tokens, Some(40));
248250
}
249251

250252
#[test]
@@ -254,7 +256,8 @@ mod tests {
254256
input_tokens: 100,
255257
output_tokens: 50,
256258
prompt_cache_hit_tokens: Some(0), // explicit zero from provider
257-
prompt_cache_miss_tokens: Some(1234),
259+
prompt_cache_miss_tokens: Some(50),
260+
prompt_cache_write_tokens: Some(1234),
258261
reasoning_tokens: None,
259262
reasoning_replay_tokens: None,
260263
server_tool_use: None,

crates/tui/src/core/turn.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ impl TurnContext {
108108
self.usage.prompt_cache_miss_tokens,
109109
usage.prompt_cache_miss_tokens,
110110
);
111+
self.usage.prompt_cache_write_tokens = add_optional_usage(
112+
self.usage.prompt_cache_write_tokens,
113+
usage.prompt_cache_write_tokens,
114+
);
111115
self.usage.reasoning_tokens =
112116
add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens);
113117
}

crates/tui/src/hooks.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,7 @@ pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value {
13011301
"output_tokens": input.usage.output_tokens,
13021302
"prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens,
13031303
"prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens,
1304+
"prompt_cache_write_tokens": input.usage.prompt_cache_write_tokens,
13041305
"reasoning_tokens": input.usage.reasoning_tokens,
13051306
"reasoning_replay_tokens": input.usage.reasoning_replay_tokens,
13061307
},
@@ -1567,6 +1568,7 @@ NOEQUAL line dropped
15671568
output_tokens: 9,
15681569
prompt_cache_hit_tokens: Some(10),
15691570
prompt_cache_miss_tokens: Some(30),
1571+
prompt_cache_write_tokens: None,
15701572
reasoning_tokens: Some(4),
15711573
reasoning_replay_tokens: Some(2),
15721574
server_tool_use: None,
@@ -1873,6 +1875,7 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}'
18731875
output_tokens: 3,
18741876
prompt_cache_hit_tokens: None,
18751877
prompt_cache_miss_tokens: None,
1878+
prompt_cache_write_tokens: None,
18761879
reasoning_tokens: None,
18771880
reasoning_replay_tokens: None,
18781881
server_tool_use: None,

crates/tui/src/llm_response_cache.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ mod tests {
9494
output_tokens: 7,
9595
prompt_cache_hit_tokens: Some(3),
9696
prompt_cache_miss_tokens: Some(39),
97+
prompt_cache_write_tokens: None,
9798
reasoning_tokens: Some(5),
9899
reasoning_replay_tokens: Some(2),
99100
server_tool_use: None,

crates/tui/src/models.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ pub struct Usage {
213213
pub prompt_cache_hit_tokens: Option<u32>,
214214
#[serde(skip_serializing_if = "Option::is_none")]
215215
pub prompt_cache_miss_tokens: Option<u32>,
216+
/// Cache-creation / cache-write tokens (Anthropic `cache_creation_input_tokens`).
217+
/// Billed at the cache-write rate when the pricing row publishes one (#4318).
218+
#[serde(skip_serializing_if = "Option::is_none")]
219+
pub prompt_cache_write_tokens: Option<u32>,
216220
#[serde(skip_serializing_if = "Option::is_none")]
217221
pub reasoning_tokens: Option<u32>,
218222
/// Approximate input tokens spent re-sending prior `reasoning_content`

0 commit comments

Comments
 (0)