返回 CodeWhale
cache.rs
根目录 / crates / tui / src / commands / groups / debug / cache.rs
1 //! `/cache` command — per-turn prefix-cache telemetry and inspection.
2
3 use std::time::Instant;
4
5 use super::CommandResult;
6 use crate::client::{CacheWarmupKey, PromptInspection, inspect_prompt_for_request};
7 use crate::localization::{Locale, MessageId, tr};
8 use crate::models::MessageRequest;
9 use crate::tui::app::{App, AppAction, TurnCacheRecord};
10
11 /// Show per-turn DeepSeek prefix-cache telemetry for the last N turns (#263).
12 ///
13 /// `arg` is parsed as a count override (default 10, capped at the ring size).
14 /// Renders a fixed-width table the user can paste into a bug report.
15 pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
16 let arg = arg.map(str::trim).filter(|s| !s.is_empty());
17 if let Some(flags) = arg.and_then(|a| a.strip_prefix("inspect")) {
18 let flags = flags.trim();
19 let verbose = flags.split_whitespace().any(|flag| flag == "--verbose");
20 let json_mode = flags.split_whitespace().any(|flag| flag == "--json");
21 return CommandResult::message(format_cache_inspect(app, verbose, json_mode));
22 }
23 if matches!(arg, Some("warmup")) {
24 return CommandResult::action(AppAction::CacheWarmup);
25 }
26 if matches!(arg, Some("stats")) {
27 return CommandResult::message(format_cache_stats(app));
28 }
29 if matches!(arg, Some("zones")) {
30 return CommandResult::message(format_cache_zones(app));
31 }
32
33 let want = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
34 let cap = app.session.turn_cache_history.len();
35 let count = want
36 .min(cap)
37 .min(crate::tui::app::App::TURN_CACHE_HISTORY_CAP);
38
39 if cap == 0 {
40 return CommandResult::message(tr(app.ui_locale, MessageId::CmdCacheNoData));
41 }
42
43 CommandResult::message(format_cache_history(app, count, app.ui_locale))
44 }
45
46 fn format_cache_inspect(app: &mut App, verbose: bool, json_mode: bool) -> String {
47 if verbose && json_mode {
48 return "cache inspect: --json and --verbose cannot be combined".to_string();
49 }
50
51 let Some(target) = app.cache_replay_target() else {
52 return "cache inspect: Auto has no concrete route yet; send a turn first".to_string();
53 };
54 let Some(replay_base_url) = target.base_url.as_deref() else {
55 return "cache inspect: the restored Auto route has no captured endpoint; send a turn first"
56 .to_string();
57 };
58 let reasoning_effort = app
59 .reasoning_effort_api_value_for_replay(target.provider, replay_base_url, &target.model)
60 .map(str::to_string);
61 let request = MessageRequest {
62 model: target.model.clone(),
63 messages: app.api_messages.clone(),
64 max_tokens: 0,
65 system: app.system_prompt.clone(),
66 tools: app.session.last_tool_catalog.clone(),
67 tool_choice: None,
68 metadata: None,
69 thinking: None,
70 reasoning_effort,
71 stream: Some(true),
72 temperature: None,
73 top_p: None,
74 };
75 let inspection = inspect_prompt_for_request(&request);
76 let previous = app.session.last_cache_inspection.as_ref();
77 let current_warmup_key = CacheWarmupKey::from_inspection(
78 &target.provider_identity,
79 &target.model,
80 replay_base_url,
81 &inspection,
82 );
83 let warmup_status =
84 format_warmup_status(app.session.last_warmup_key.as_ref(), &current_warmup_key);
85 if json_mode {
86 let output = serde_json::to_value(&inspection)
87 .and_then(|mut value| {
88 if let serde_json::Value::Object(ref mut object) = value {
89 object.insert(
90 "current_warmup_key".to_string(),
91 serde_json::to_value(&current_warmup_key)?,
92 );
93 object.insert(
94 "warmup_status".to_string(),
95 serde_json::Value::String(warmup_status.trim_end().to_string()),
96 );
97 }
98 serde_json::to_string_pretty(&value)
99 })
100 .unwrap_or_else(|_| {
101 "{\"error\":\"cache inspection serialization failed\"}".to_string()
102 });
103 app.session.last_cache_inspection = Some(inspection);
104 return output;
105 }
106
107 let mut out = String::new();
108 out.push_str("Cache Inspect\n");
109 out.push_str("Full prompt text is not printed. Hashes are SHA-256 of each rendered layer.\n");
110 out.push_str(&format!(
111 "Base static prefix hash: {}\n",
112 inspection.base_static_prefix_hash
113 ));
114 out.push_str(&format!(
115 "Full request prefix hash: {}\n",
116 inspection.full_request_prefix_hash
117 ));
118 out.push_str(&format!(
119 "Tool catalog hash: {}\n",
120 if inspection.tool_catalog_hash.is_empty() {
121 "(no tools registered)".to_string()
122 } else {
123 inspection.tool_catalog_hash.clone()
124 }
125 ));
126 out.push_str(&format_static_prefix_status(previous, &inspection));
127 out.push_str(&format_first_divergence(previous, &inspection));
128 out.push_str(&warmup_status);
129 let total_tokens: usize = inspection
130 .layers
131 .iter()
132 .map(|layer| layer.token_estimate)
133 .sum();
134 out.push_str(&format!("Estimated reusable tokens: ~{total_tokens}\n"));
135 out.push('\n');
136
137 for layer in &inspection.layers {
138 let mut line = format!(
139 "{}: {}, chars={}, bytes={}, ~{}tok, hash={}\n",
140 layer.name,
141 layer.stability.label(),
142 layer.char_len,
143 layer.byte_len,
144 layer.token_estimate,
145 layer.sha256
146 );
147 if let Some(tool_result) = &layer.tool_result {
148 let trimmed = line.trim_end_matches('\n').to_string();
149 line = format!(
150 "{trimmed}, original_chars={}, sent_chars={}, truncated={}, deduplicated={}\n",
151 tool_result.original_chars,
152 tool_result.sent_chars,
153 tool_result.truncated,
154 tool_result.deduplicated
155 );
156 }
157 if let Some(turn_meta) = &layer.turn_meta {
158 let trimmed = line.trim_end_matches('\n').to_string();
159 line = format!(
160 "{trimmed}, turn_meta_original_chars={}, turn_meta_sent_chars={}, turn_meta_deduplicated={}, turn_meta_sha256={}\n",
161 turn_meta.original_chars,
162 turn_meta.sent_chars,
163 turn_meta.deduplicated,
164 turn_meta.sha256
165 );
166 }
167 out.push_str(&line);
168 }
169 if verbose {
170 out.push_str("\nVerbose diff\n");
171 if let Some(previous) = previous {
172 out.push_str(&format_verbose_diff(previous, &inspection));
173 } else {
174 out.push_str("No previous inspection to compare against.\n");
175 }
176 }
177 app.session.last_cache_inspection = Some(inspection);
178 out
179 }
180
181 pub(crate) fn format_warmup_status(
182 last_warmup: Option<&CacheWarmupKey>,
183 current: &CacheWarmupKey,
184 ) -> String {
185 match last_warmup {
186 None => format!(
187 "Warmup status: no previous warmup (current key: {})\n",
188 current.hash_short()
189 ),
190 Some(previous) if previous == current => {
191 format!(
192 "Warmup status: valid (key {} matches)\n",
193 current.hash_short()
194 )
195 }
196 Some(previous) => {
197 let mut reasons = Vec::new();
198 if previous.provider != current.provider {
199 reasons.push("provider changed");
200 }
201 if previous.model != current.model {
202 reasons.push("model changed");
203 }
204 if previous.base_url != current.base_url {
205 reasons.push("base URL changed");
206 }
207 if previous.static_prefix_hash != current.static_prefix_hash {
208 reasons.push("static prefix changed");
209 }
210 if previous.tool_catalog_hash != current.tool_catalog_hash {
211 reasons.push("tool catalog changed");
212 }
213 if previous.project_pack_hash != current.project_pack_hash {
214 reasons.push("project pack changed");
215 }
216 if previous.skills_hash != current.skills_hash {
217 reasons.push("skills changed");
218 }
219 let reason_text = if reasons.is_empty() {
220 "unknown prefix input changed".to_string()
221 } else {
222 reasons.join(", ")
223 };
224 format!(
225 "Warmup status: invalid ({} -> {}; {})\n",
226 previous.hash_short(),
227 current.hash_short(),
228 reason_text
229 )
230 }
231 }
232 }
233
234 fn format_verbose_diff(previous: &PromptInspection, current: &PromptInspection) -> String {
235 let mut out = String::new();
236 let max_len = previous.layers.len().max(current.layers.len());
237 for index in 0..max_len {
238 match (previous.layers.get(index), current.layers.get(index)) {
239 (Some(prev), Some(curr)) if prev == curr => {
240 out.push_str(&format!(" [{index}] {} unchanged\n", curr.name));
241 }
242 (Some(prev), Some(curr)) => {
243 out.push_str(&format!(" [{index}] {} changed\n", curr.name));
244 if prev.name != curr.name {
245 out.push_str(&format!(" name: {} -> {}\n", prev.name, curr.name));
246 }
247 if prev.stability != curr.stability {
248 out.push_str(&format!(
249 " stability: {} -> {}\n",
250 prev.stability.label(),
251 curr.stability.label()
252 ));
253 }
254 if prev.char_len != curr.char_len {
255 out.push_str(&format!(
256 " chars: {} -> {} ({:+})\n",
257 prev.char_len,
258 curr.char_len,
259 curr.char_len as i64 - prev.char_len as i64
260 ));
261 }
262 if prev.sha256 != curr.sha256 {
263 out.push_str(&format!(
264 " hash: {} -> {}\n",
265 short_hash(&prev.sha256),
266 short_hash(&curr.sha256)
267 ));
268 }
269 }
270 (None, Some(curr)) => {
271 out.push_str(&format!(" [{index}] {} added\n", curr.name));
272 }
273 (Some(prev), None) => {
274 out.push_str(&format!(" [{index}] {} removed\n", prev.name));
275 }
276 (None, None) => unreachable!("index is within max_len"),
277 }
278 }
279 out
280 }
281
282 fn short_hash(hash: &str) -> &str {
283 &hash[..hash.len().min(12)]
284 }
285
286 /// Render a prefix-cache stability and health summary for `/cache stats`.
287 ///
288 /// Surfaces the current prefix fingerprint, stability ratio, change history,
289 /// and an aggregated cache-hit summary from per-turn telemetry. When the
290 /// prefix has changed, a prominent warning is included so users can
291 /// correlate cache misses with prefix drift.
292 fn format_cache_stats(app: &App) -> String {
293 let mut out = String::new();
294 out.push_str("Cache Stats\n");
295
296 // ── Prefix stability ──────────────────────────────────────────────
297 out.push_str("\n── Prefix Stability\n");
298 match app.prefix_stability_pct {
299 Some(pct) => {
300 let checks = app.prefix_checks_total;
301 let changes = app.prefix_change_count;
302 let stable_checks = checks.saturating_sub(changes);
303
304 if changes == 0 {
305 out.push_str(&format!(
306 " Stability: {pct}% ({stable_checks}/{checks} checks)\n"
307 ));
308 out.push_str(" Status: stable (no prefix changes this session)\n");
309 } else {
310 out.push_str(&format!(
311 " Stability: {pct}% ({stable_checks}/{checks} checks, {changes} change{})\n",
312 if changes == 1 { "" } else { "s" }
313 ));
314 out.push_str(" Status: WARNING — prefix has changed\n");
315 if let Some(ref desc) = app.last_prefix_change_desc {
316 out.push_str(&format!(" Last change: {desc}\n"));
317 }
318 }
319 }
320 None => {
321 out.push_str(" Stability: unknown (no checks recorded yet)\n");
322 out.push_str(" Run a turn first to collect prefix stability data.\n");
323 }
324 }
325
326 // ── Prefix fingerprint ────────────────────────────────────────────
327 out.push_str("\n── Prefix Fingerprint\n");
328 match &app.last_pinned_prefix_hash {
329 Some(hash) => {
330 out.push_str(&format!(" Pinned hash: {hash}\n"));
331 let short = if hash.len() >= 12 { &hash[..12] } else { hash };
332 out.push_str(&format!(" Short id: {short}\n"));
333 if app.prefix_change_count > 0 {
334 out.push_str(" Drift: WARNING — hash has changed during this session\n");
335 out.push_str(&format!(
336 " ({change} change{plural} detected)\n",
337 change = app.prefix_change_count,
338 plural = if app.prefix_change_count == 1 {
339 ""
340 } else {
341 "s"
342 }
343 ));
344 } else {
345 out.push_str(" Drift: none (hash stable)\n");
346 }
347 }
348 None => {
349 out.push_str(" Pinned hash: unavailable\n");
350 out.push_str(" Run a turn first, or use /cache inspect.\n");
351 }
352 }
353
354 // ── Cache hit-rate summary ────────────────────────────────────────
355 out.push_str("\n── Cache Hit Rate\n");
356 let history = &app.session.turn_cache_history;
357 if history.is_empty() {
358 out.push_str(" No turn telemetry recorded yet.\n");
359 } else {
360 // Aggregate only cache-aware turns; skip turns where the provider
361 // did not report cache telemetry (cache_hit_tokens is None).
362 // When cache_miss_tokens is None, infer it as
363 // input_tokens − cache_hit_tokens (matches /cache table logic).
364 let mut turns = 0u64;
365 let (hit, miss, input) = app.session.turn_cache_history.iter().fold(
366 (0u64, 0u64, 0u64),
367 |(hit, miss, input), rec| {
368 let Some(hit_tokens) = rec.cache_hit_tokens else {
369 return (hit, miss, input);
370 };
371 let h = u64::from(hit_tokens);
372 let m = u64::from(
373 rec.cache_miss_tokens
374 .unwrap_or(rec.input_tokens.saturating_sub(hit_tokens)),
375 );
376 turns += 1;
377 (hit + h, miss + m, input + u64::from(rec.input_tokens))
378 },
379 );
380 let total_cache = hit + miss;
381 let avg_pct = if total_cache > 0 {
382 (hit as f64 / total_cache as f64 * 100.0).clamp(0.0, 100.0)
383 } else {
384 0.0
385 };
386 out.push_str(&format!(" Turns recorded: {turns}\n"));
387 out.push_str(&format!(
388 " Cache hit tokens: {hit} ({avg_pct:.1}% of {total_cache} cache-aware tokens)\n",
389 hit = format_tokens(hit),
390 total_cache = format_tokens(total_cache),
391 ));
392 out.push_str(&format!(
393 " Cache miss tokens: {miss}\n",
394 miss = format_tokens(miss),
395 ));
396 out.push_str(&format!(
397 " Total input tokens: {input}\n",
398 input = format_tokens(input),
399 ));
400 if avg_pct < 80.0 {
401 out.push_str(" NOTE: cache hit rate is low (< 80%). Check prefix stability above or consider /compact.\n");
402 }
403 }
404
405 out
406 }
407
408 /// Render three-zone prefix contract status for `/cache zones` (#2264).
409 ///
410 /// Displays the PinnedPrefix fingerprint, AppendLog size, and TurnScratch
411 /// state. The zones are type scaffolding only (Phase 1) — not yet
412 /// enforcing the full contract at request time.
413 fn format_cache_zones(app: &App) -> String {
414 let mut out = String::new();
415 out.push_str("Cache Zones (#2264 three-zone contract, Phase 1 foundation)\n");
416
417 // ── PinnedPrefix ─────────────────────────────────────────────────
418 out.push_str("\n── PinnedPrefix (system + tools, frozen baseline)\n");
419 match &app.last_pinned_prefix_hash {
420 Some(hash) => {
421 let short = if hash.len() >= 12 { &hash[..12] } else { hash };
422 out.push_str(&format!(" Short id: {short}\n"));
423 if app.prefix_change_count > 0 {
424 out.push_str(&format!(
425 " Status: WARNING — {change} drift{plural} detected\n",
426 change = app.prefix_change_count,
427 plural = if app.prefix_change_count == 1 {
428 ""
429 } else {
430 "s"
431 }
432 ));
433 } else {
434 out.push_str(" Status: stable (no drift this session)\n");
435 }
436 if let Some(pct) = app.prefix_stability_pct {
437 out.push_str(&format!(" Stability: {pct}%\n"));
438 }
439 }
440 None => {
441 out.push_str(" Status: unavailable (not yet frozen)\n");
442 out.push_str(" Run a turn first to freeze the baseline.\n");
443 }
444 }
445
446 // ── AppendLog ────────────────────────────────────────────────────
447 out.push_str("\n── AppendLog (conversation history, append-only)\n");
448 out.push_str(" Status: Phase 1 scaffolding — not yet wired into engine\n");
449 let msg_count = app.api_messages.len();
450 out.push_str(&format!(" Messages: {msg_count}\n"));
451 let history_count = app
452 .api_messages
453 .iter()
454 .filter(|m| m.role != "system")
455 .count();
456 out.push_str(&format!(" History msgs: {history_count}\n"));
457
458 // ── TurnScratch ──────────────────────────────────────────────────
459 out.push_str("\n── TurnScratch (per-turn ephemeral data)\n");
460 out.push_str(" Status: Phase 1 scaffolding — not yet wired into engine\n");
461
462 // ── Zone contract summary ────────────────────────────────────────
463 out.push_str("\n── Contract Status\n");
464 let has_drift = app.prefix_change_count > 0;
465 out.push_str(&format!(
466 " PinnedPrefix: {}\n",
467 if app.last_pinned_prefix_hash.is_some() {
468 if has_drift {
469 "WARNING — drifted"
470 } else {
471 "OK"
472 }
473 } else {
474 "not frozen"
475 }
476 ));
477 out.push_str(" AppendLog: Phase 1 foundation\n");
478 out.push_str(" TurnScratch: Phase 1 foundation\n");
479
480 out
481 }
482
483 /// Formats a u64 token count with a compact suffix: K for thousands,
484 /// M for millions. Never returns scientific notation.
485 pub(crate) fn format_tokens(n: u64) -> String {
486 if n >= 1_000_000 {
487 format!("{:.1}M", n as f64 / 1_000_000.0)
488 } else if n >= 1_000 {
489 format!("{:.1}K", n as f64 / 1_000.0)
490 } else {
491 n.to_string()
492 }
493 }
494
495 fn format_static_prefix_status(
496 previous: Option<&PromptInspection>,
497 current: &PromptInspection,
498 ) -> String {
499 let Some(previous) = previous else {
500 return "Static base prefix stability: no previous request\n".to_string();
501 };
502 if previous.base_static_prefix_hash == current.base_static_prefix_hash {
503 return "Static base prefix stability: OK\n".to_string();
504 }
505
506 let changed = changed_static_layers(previous, current);
507 if changed.is_empty() {
508 "Static base prefix stability: WARNING (base hash changed)\n".to_string()
509 } else {
510 format!(
511 "Static base prefix stability: WARNING changed layers: {}\n",
512 changed.join(", ")
513 )
514 }
515 }
516
517 fn format_first_divergence(
518 previous: Option<&PromptInspection>,
519 current: &PromptInspection,
520 ) -> String {
521 let Some(previous) = previous else {
522 return "First divergence from previous request: unavailable\n".to_string();
523 };
524 let max_len = previous.layers.len().max(current.layers.len());
525 for index in 0..max_len {
526 match (previous.layers.get(index), current.layers.get(index)) {
527 (Some(prev), Some(curr)) if prev.name == curr.name && prev.sha256 == curr.sha256 => {}
528 (Some(prev), Some(curr)) if prev.name == curr.name => {
529 return format!("First divergence from previous request: {}\n", curr.name);
530 }
531 (Some(_), Some(curr)) => {
532 return format!("First divergence from previous request: {}\n", curr.name);
533 }
534 (None, Some(curr)) => {
535 return format!("First divergence from previous request: {}\n", curr.name);
536 }
537 (Some(prev), None) => {
538 return format!(
539 "First divergence from previous request: {} removed\n",
540 prev.name
541 );
542 }
543 (None, None) => break,
544 }
545 }
546 "First divergence from previous request: none\n".to_string()
547 }
548
549 fn changed_static_layers(previous: &PromptInspection, current: &PromptInspection) -> Vec<String> {
550 current
551 .layers
552 .iter()
553 .filter(|layer| layer.stability.label() == "static")
554 .filter(|layer| {
555 previous
556 .layers
557 .iter()
558 .find(|previous_layer| previous_layer.name == layer.name)
559 .is_none_or(|previous_layer| previous_layer.sha256 != layer.sha256)
560 })
561 .map(|layer| layer.name.clone())
562 .collect()
563 }
564
565 /// Column header for the per-turn cache/cost table. The widths here must match
566 /// the row format strings below.
567 const TURN_CACHE_ROW_HEADER: &str = "turn route in out hit miss write replay ratio cost age";
568
569 /// Rule width for the table. Sized to the header above.
570 const TURN_CACHE_TABLE_WIDTH: usize = 106;
571
572 /// Render one turn's cost cell, collecting the reason when it has none.
573 ///
574 /// A turn with no route provenance (legacy or synthetic record) and a turn on a
575 /// route that is not money-metered both render as `—` — neither is a real
576 /// zero-dollar charge.
577 fn turn_cost_cell(
578 rec: &TurnCacheRecord,
579 currency: crate::pricing::CostCurrency,
580 unpriced_notes: &mut std::collections::BTreeSet<&'static str>,
581 ) -> String {
582 let Some(audit) = rec.cost_audit.as_ref() else {
583 return "—".to_string();
584 };
585 if audit.is_priced_in(currency)
586 && let Some(estimate) = audit.estimate
587 {
588 return crate::pricing::format_cost_amount_precise(estimate.amount(currency), currency);
589 }
590 if let Some(reason) = audit.unpriced_reason {
591 unpriced_notes.insert(reason.label());
592 }
593 for class in &audit.unpriced_classes {
594 unpriced_notes.insert(class.label());
595 }
596 "—".to_string()
597 }
598
599 fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
600 let total = app.session.turn_cache_history.len();
601 let start = total.saturating_sub(count);
602 let rows: Vec<&TurnCacheRecord> = app.session.turn_cache_history.iter().skip(start).collect();
603
604 let mut totals_input: u64 = 0;
605 let mut totals_hit: u64 = 0;
606 let mut totals_miss: u64 = 0;
607 let mut totals_write: u64 = 0;
608 let mut totals_reasoning: u64 = 0;
609 let currency = app.cost_display_currency(app.cost_currency);
610 // Non-secret audit trail for turns whose spend is missing from the session
611 // total, so a `—` in the cost column is always explainable.
612 let mut unpriced_notes: std::collections::BTreeSet<&'static str> =
613 std::collections::BTreeSet::new();
614 let mut header = tr(locale, MessageId::CmdCacheHeader)
615 .replace("{count}", &rows.len().to_string())
616 .replace("{total}", &total.to_string())
617 .replace("{model}", &app.model);
618 header.push_str(&"─".repeat(TURN_CACHE_TABLE_WIDTH));
619 header.push('\n');
620 header.push_str(TURN_CACHE_ROW_HEADER);
621 header.push('\n');
622 header.push_str(&"─".repeat(TURN_CACHE_TABLE_WIDTH));
623 header.push('\n');
624
625 let now = Instant::now();
626 let mut body = String::new();
627 let absolute_start = total.saturating_sub(rows.len());
628 for (i, rec) in rows.iter().enumerate() {
629 let turn_index = absolute_start + i + 1;
630 totals_input += u64::from(rec.input_tokens);
631
632 let replay_cell = rec
633 .reasoning_replay_tokens
634 .map_or_else(|| "—".to_string(), |t| t.to_string());
635 let classes = crate::pricing::token_usage_for_pricing(&crate::models::Usage {
636 input_tokens: rec.input_tokens,
637 output_tokens: rec.output_tokens,
638 prompt_cache_hit_tokens: rec.cache_hit_tokens,
639 prompt_cache_miss_tokens: rec.cache_miss_tokens,
640 prompt_cache_write_tokens: rec.cache_write_tokens,
641 reasoning_tokens: rec.reasoning_tokens,
642 reasoning_replay_tokens: rec.reasoning_replay_tokens,
643 server_tool_use: None,
644 });
645 let write = u32::try_from(classes.cache_write).unwrap_or(u32::MAX);
646 let write_cell = rec
647 .cache_write_tokens
648 .map_or_else(|| "—".to_string(), |_| write.to_string());
649 totals_write += classes.cache_write;
650 totals_reasoning += u64::from(rec.reasoning_tokens.unwrap_or(0));
651 let cost_cell = turn_cost_cell(rec, currency, &mut unpriced_notes);
652 let route_cell = format_turn_cache_route(rec);
653 let age = humanize_age(now.saturating_duration_since(rec.recorded_at));
654
655 // No cache telemetry → render `—` everywhere and don't pollute totals
656 // with inferred zeros. Some providers (and some routes inside DeepSeek)
657 // skip the cache fields; including a synthesized 0/N for those turns
658 // would make every aggregate ratio look broken.
659 if rec.cache_hit_tokens.is_none()
660 && rec.cache_miss_tokens.is_none()
661 && rec.cache_write_tokens.is_none()
662 {
663 body.push_str(&format!(
664 "{turn:>4} {route:<24} {input:>5} {output:>5} {hit:>5} {miss:>5} {write:>5} {replay:>6} {ratio:>6} {cost:>9} {age}\n",
665 turn = turn_index,
666 route = route_cell,
667 input = rec.input_tokens,
668 output = rec.output_tokens,
669 hit = "—",
670 miss = "—",
671 write = write_cell,
672 replay = replay_cell,
673 ratio = "—",
674 cost = cost_cell,
675 age = age,
676 ));
677 continue;
678 }
679
680 let miss_reported = rec.cache_miss_tokens;
681 let hit = u32::try_from(classes.cache_read).unwrap_or(u32::MAX);
682 let miss = u32::try_from(classes.input).unwrap_or(u32::MAX);
683 // Use the same mutually-exclusive hit/miss/write partition as pricing.
684 // Inferring `input - hit` here and then adding write counted creation
685 // tokens twice in exactly the turns with a write premium.
686 let accounted = u64::from(hit) + u64::from(miss) + u64::from(write);
687 let ratio = if accounted == 0 {
688 " —".to_string()
689 } else {
690 format!("{:>5.1}%", 100.0 * f64::from(hit) / accounted as f64)
691 };
692 totals_hit += u64::from(hit);
693 totals_miss += u64::from(miss);
694
695 let miss_cell = match miss_reported {
696 Some(_) => format!("{miss}"),
697 None => format!("{miss}*"),
698 };
699
700 body.push_str(&format!(
701 "{turn:>4} {route:<24} {input:>5} {output:>5} {hit:>5} {miss:>5} {write:>5} {replay:>6} {ratio} {cost:>9} {age}\n",
702 turn = turn_index,
703 route = route_cell,
704 input = rec.input_tokens,
705 output = rec.output_tokens,
706 hit = hit,
707 miss = miss_cell,
708 write = write_cell,
709 replay = replay_cell,
710 ratio = ratio,
711 cost = cost_cell,
712 age = age,
713 ));
714 }
715
716 // Anthropic-normalized aggregate: hit / (hit + miss + write).
717 let totals_accounted = totals_hit + totals_miss + totals_write;
718 let avg_ratio = if totals_accounted == 0 {
719 "—".to_string()
720 } else {
721 format!(
722 "{:.1}%",
723 100.0 * totals_hit as f64 / totals_accounted as f64
724 )
725 };
726
727 let mut footer = String::new();
728 footer.push_str(&"─".repeat(TURN_CACHE_TABLE_WIDTH));
729 footer.push('\n');
730 // Reasoning is reported separately from `sum_out` on purpose: providers
731 // count it *inside* the completion tokens they bill, so adding the two
732 // would double-count it.
733 footer.push_str(&format!(
734 "sum_write: {totals_write} sum_reasoning: {totals_reasoning} (already inside out)\n"
735 ));
736 footer.push_str(
737 &tr(locale, MessageId::CmdCacheTotals)
738 .replace("{sum_in}", &totals_input.to_string())
739 .replace("{sum_hit}", &totals_hit.to_string())
740 .replace("{sum_miss}", &totals_miss.to_string())
741 .replace("{avg}", &avg_ratio),
742 );
743 footer.push_str(&tr(locale, MessageId::CmdCacheFootnote));
744 if !unpriced_notes.is_empty() {
745 footer.push_str(&format!(
746 "cost — = no authoritative price for that turn; it is missing from the session estimate ({}).\n",
747 unpriced_notes.into_iter().collect::<Vec<_>>().join(", ")
748 ));
749 }
750 footer.push_str(&tr(locale, MessageId::CmdCacheAdvice));
751
752 format!("{header}{body}{footer}")
753 }
754
755 fn format_turn_cache_route(rec: &TurnCacheRecord) -> String {
756 let Some(model) = rec.model.as_deref().filter(|model| !model.is_empty()) else {
757 return "—".to_string();
758 };
759 let provider = rec
760 .provider_identity
761 .as_deref()
762 .filter(|provider| !provider.trim().is_empty())
763 .or_else(|| rec.provider.map(|provider| provider.as_str()))
764 .unwrap_or("?");
765 let route = if rec.auto_model {
766 format!("auto:{provider}/{model}")
767 } else {
768 format!("{provider}/{model}")
769 };
770 truncate_route_cell(&route, 24)
771 }
772
773 fn truncate_route_cell(route: &str, max_chars: usize) -> String {
774 if route.chars().count() <= max_chars {
775 return route.to_string();
776 }
777 if max_chars <= 3 {
778 return route.chars().take(max_chars).collect();
779 }
780 let mut out: String = route.chars().take(max_chars - 3).collect();
781 out.push_str("...");
782 out
783 }
784
785 fn humanize_age(d: std::time::Duration) -> String {
786 crate::elapsed::format_elapsed_secs(d.as_secs())
787 }
788
789 #[cfg(test)]
790 mod route_tests {
791 use super::*;
792
793 #[test]
794 fn cache_route_keeps_exact_named_custom_identity() {
795 let record = TurnCacheRecord {
796 provider: Some(crate::config::ApiProvider::Custom),
797 provider_identity: Some("lm-studio".to_string()),
798 model: Some("local-code-model".to_string()),
799 auto_model: false,
800 input_tokens: 1,
801 output_tokens: 1,
802 cache_hit_tokens: None,
803 cache_miss_tokens: None,
804 reasoning_replay_tokens: None,
805 cache_write_tokens: None,
806 reasoning_tokens: None,
807 cost_audit: None,
808 recorded_at: Instant::now(),
809 };
810
811 assert_eq!(format_turn_cache_route(&record), "lm-studio/local-code-...");
812 }
813 }
814
814 lines RUST