1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
//! Prompt-cache miss detection and session re-bill totals for the interactive TUI.
//!
//! One completed model call is one sample. The SDK already names that boundary
//! with `rho_sdk::RunEvent::ModelCallCompleted`, which it emits only
//! for the attempt that actually returned output, so failed and retried
//! attempts never reach this tracker and no retry bookkeeping is needed here.
//!
//! Each sample is compared to the previous one: prompt tokens that the previous
//! request already established as a prefix, but that this request did not read
//! from cache, were re-billed. Feature policy stays here: `/info` reads
//! [`CacheStatsTracker::rebilled`], and completed turns drain
//! [`CacheMissNotice`]s when the user opts into notices.
use std::time::{Duration, Instant};
use rho_providers::model::{ModelMetadata, ModelUsage};
use rho_sdk::{ModelCallMetrics, ModelCallProfile};
use super::usage_cost::{cost_component, format_token_count, format_usd};
/// Misses at or below this are cache-breakpoint granularity, not a real miss.
///
/// Receipt: Pi `packages/coding-agent/src/core/cache-stats.ts` uses 1024
/// because Anthropic cache breakpoints sit on ~1K-token alignment. Smaller
/// gaps are noise.
pub(super) const CACHE_MISS_NOISE_FLOOR_TOKENS: u64 = 1024;
/// Token tripwire for a transcript notice after a counted miss.
///
/// Receipt: Pi's significant-miss notice threshold. Initial Rho value until we
/// measure real sessions; treat as a named tripwire, not a guess in call sites.
pub(super) const SIGNIFICANT_MISS_TOKENS: u64 = 20_000;
/// Extra-cost tripwire for a transcript notice after a counted miss ($0.10).
///
/// Receipt: Pi's alternative notice threshold (`missedCost >= 0.1`). Stored in
/// USD micros to match [`ModelUsage::cost_usd_micros`].
pub(super) const SIGNIFICANT_MISS_EXTRA_COST_USD_MICROS: u64 = 100_000;
/// Idle gap that is worth naming as a likely TTL expiry.
///
/// Receipt: Anthropic's default prompt-cache TTL is 5 minutes. Used only to
/// attribute a cause, never to suppress a miss.
pub(super) const PROVIDER_CACHE_TTL_HINT: Duration = Duration::from_secs(300);
/// Session-level tokens and dollars re-billed by counted cache misses.
///
/// `/info` copies this snapshot and renders nothing while `miss_count` is zero.
/// `extra_cost_usd_micros` only sums priced misses; [`Self::unpriced_miss_count`]
/// is how the row marks that dollar figure as partial.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct CacheRebilled {
pub missed_tokens: u64,
pub miss_count: u64,
pub extra_cost_usd_micros: u64,
pub unpriced_miss_count: u64,
}
/// Why a counted miss happened, when the tracker can observe a cause.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum CacheMissCause {
ModelSwitch,
ToolListChanged,
Idle(Duration),
Unattributed,
}
/// A significant miss ready to render as a transcript notice.
///
/// Drained at turn end. Only completed main-agent turns insert these.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct CacheMissNotice {
pub missed_tokens: u64,
pub extra_cost_usd_micros: Option<u64>,
pub cause: CacheMissCause,
}
/// Provider and model that served one completed request.
///
/// Taken from the SDK's [`ModelCallProfile`] so a model switch is named from
/// the model that actually served the call, not the currently selected one.
/// Reasoning level and service tier are deliberately excluded: they do not
/// change the prompt prefix.
#[derive(Clone, Debug, PartialEq, Eq)]
struct ModelKey {
provider: String,
model: String,
}
impl ModelKey {
fn from_profile(profile: &ModelCallProfile) -> Self {
Self {
provider: profile.provider.clone(),
model: profile.model.clone(),
}
}
}
struct CompletedRequest {
prompt_tokens: u64,
model: ModelKey,
completed_at: Instant,
}
/// In-memory miss detector for the main-agent request stream.
///
/// Holds the previous completed request, the usage delta reported for the
/// in-flight one, and the session re-bill totals shown by `/info`.
#[derive(Default)]
pub(super) struct CacheStatsTracker {
previous: Option<CompletedRequest>,
/// Latest per-step usage delta, consumed by the next completed model call.
///
/// Taken (not copied) at record time so a request that never reported usage
/// cannot be sampled twice from a stale delta.
reported_usage: Option<ModelUsage>,
rebilled: CacheRebilled,
turn_notices: Vec<CacheMissNotice>,
/// Sticky until the next sampled request. A mid-session tool-list change
/// busts the cached prefix even when the system prompt stays put.
tool_list_changed: bool,
}
impl CacheStatsTracker {
/// Hold the latest per-step usage delta for the in-flight request.
pub(super) fn usage_updated(&mut self, step_delta: &ModelUsage) {
self.reported_usage = Some(step_delta.clone());
}
/// Sample one completed model call against the previous one.
pub(super) fn record_request(
&mut self,
profile: &ModelCallProfile,
metrics: ModelCallMetrics,
metadata: Option<&ModelMetadata>,
completed_at: Instant,
) {
let Some(usage) = self.reported_usage.take() else {
return;
};
let Some(prompt_tokens) = usage.inclusive_prompt_tokens().filter(|tokens| *tokens > 0)
else {
return;
};
let model = ModelKey::from_profile(profile);
if let Some(previous) = self.previous.take().filter(|_| reports_cache(&usage)) {
let started_at = completed_at
.checked_sub(metrics.total_latency)
.unwrap_or(completed_at);
self.count_miss(
&usage,
prompt_tokens,
&model,
started_at,
&previous,
metadata,
);
} else {
self.tool_list_changed = false;
}
self.previous = Some(CompletedRequest {
prompt_tokens,
model,
completed_at,
});
}
/// Compaction rewrites the prompt prefix, so the next request has nothing
/// to hit. Session totals stay.
pub(super) fn prompt_prefix_reset(&mut self) {
self.previous = None;
self.reported_usage = None;
self.tool_list_changed = false;
}
/// The advertised tool list changed since the last sampled request.
pub(super) fn note_tool_list_changed(&mut self) {
self.tool_list_changed = true;
}
/// Clear everything. Matches `/clear`, tree checkout, and new session.
pub(super) fn reset(&mut self) {
*self = Self::default();
}
pub(super) fn take_turn_notices(&mut self) -> Vec<CacheMissNotice> {
std::mem::take(&mut self.turn_notices)
}
pub(super) fn rebilled(&self) -> &CacheRebilled {
&self.rebilled
}
fn count_miss(
&mut self,
usage: &ModelUsage,
prompt_tokens: u64,
model: &ModelKey,
started_at: Instant,
previous: &CompletedRequest,
metadata: Option<&ModelMetadata>,
) {
let tool_list_changed = std::mem::take(&mut self.tool_list_changed);
let cache_read = usage.cache_read_tokens.unwrap_or(0);
// A shrunken prompt can only re-bill what it actually sent.
let missed = previous
.prompt_tokens
.min(prompt_tokens)
.saturating_sub(cache_read);
if missed <= CACHE_MISS_NOISE_FLOOR_TOKENS {
return;
}
let extra_cost = extra_cost_usd_micros(missed, prompt_tokens, usage, metadata);
self.rebilled.missed_tokens = self.rebilled.missed_tokens.saturating_add(missed);
self.rebilled.miss_count = self.rebilled.miss_count.saturating_add(1);
match extra_cost {
Some(cost) => {
self.rebilled.extra_cost_usd_micros =
self.rebilled.extra_cost_usd_micros.saturating_add(cost);
}
None => {
self.rebilled.unpriced_miss_count =
self.rebilled.unpriced_miss_count.saturating_add(1);
}
}
if is_significant_miss(missed, extra_cost) {
self.turn_notices.push(CacheMissNotice {
missed_tokens: missed,
extra_cost_usd_micros: extra_cost,
cause: miss_cause(model, started_at, previous, tool_list_changed),
});
}
}
}
/// Whether the provider reports prompt-cache accounting at all.
///
/// Field presence, not a positive count: a provider that never populates these
/// fields (local models, plain OpenAI-compatible hosts) reports zero cache
/// reads on every request and must not be billed a miss for it. A provider that
/// does report cache can legitimately send `Some(0)` on a genuine full miss.
fn reports_cache(usage: &ModelUsage) -> bool {
usage.cache_read_tokens.is_some() || usage.cache_write_tokens.is_some()
}
fn miss_cause(
model: &ModelKey,
started_at: Instant,
previous: &CompletedRequest,
tool_list_changed: bool,
) -> CacheMissCause {
if model != &previous.model {
return CacheMissCause::ModelSwitch;
}
if tool_list_changed {
return CacheMissCause::ToolListChanged;
}
let gap = started_at.saturating_duration_since(previous.completed_at);
if gap >= PROVIDER_CACHE_TTL_HINT {
CacheMissCause::Idle(gap)
} else {
CacheMissCause::Unattributed
}
}
fn extra_cost_usd_micros(
missed: u64,
prompt_tokens: u64,
usage: &ModelUsage,
metadata: Option<&ModelMetadata>,
) -> Option<u64> {
let cost = metadata?.cost_for_input_tokens(prompt_tokens)?;
let input = cost.input_micros_per_m?;
let cache_read = cost.cache_read_micros_per_m?;
// A missed prefix that is written back into cache is billed at the write
// rate (1.25x input on Anthropic), not the plain input rate.
let write = cost.cache_write_micros_per_m.unwrap_or(input);
let written = usage.cache_write_tokens.unwrap_or(0).min(missed);
let uncached = missed - written;
let extra = cost_component(written, Some(write.saturating_sub(cache_read))).saturating_add(
cost_component(uncached, Some(input.saturating_sub(cache_read))),
);
Some(extra.min(u64::MAX as u128) as u64)
}
fn is_significant_miss(missed: u64, extra_cost: Option<u64>) -> bool {
missed >= SIGNIFICANT_MISS_TOKENS
|| extra_cost.is_some_and(|cost| cost >= SIGNIFICANT_MISS_EXTRA_COST_USD_MICROS)
}
/// Transcript line for one significant miss. Used by the completed-turn path.
pub(super) fn notice_text(notice: &CacheMissNotice) -> String {
let mut text = match notice.cause {
CacheMissCause::ModelSwitch => "cache miss after model switch".to_string(),
CacheMissCause::ToolListChanged => "cache miss after tool list change".to_string(),
CacheMissCause::Idle(gap) => format!(
"cache miss after {}m idle (cache TTL is about {}m)",
whole_minutes(gap),
whole_minutes(PROVIDER_CACHE_TTL_HINT),
),
CacheMissCause::Unattributed => "cache miss".to_string(),
};
text.push_str(": ");
text.push_str(&format_token_count(notice.missed_tokens));
text.push_str(" tokens re-billed");
if let Some(cost) = notice.extra_cost_usd_micros {
text.push_str(" (~");
text.push_str(&format_usd(cost));
text.push(')');
}
text
}
fn whole_minutes(gap: Duration) -> u64 {
(gap.as_secs().saturating_add(30) / 60).max(1)
}
#[cfg(test)]
#[path = "cache_stats_tests.rs"]
mod tests;