返回 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::tui::app::{App, AppAction, TurnCacheRecord};
8 use codewhale_localization::{Locale, MessageId, tr};
9 use codewhale_models::MessageRequest;
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.as_ref().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 let drift = app.prefix_drift_count;
305 if changes == 0 {
306 out.push_str(&format!(
307 " Stability: {pct}% ({stable_checks}/{checks} checks)\n"
308 ));
309 out.push_str(" Status: stable (no prefix changes this session)\n");
310 if app.prefix_context_updates > 0 {
311 out.push_str(&format!(
312 " Context updates: {} (workspace drift delivered as history, header unchanged)\n",
313 app.prefix_context_updates
314 ));
315 }
316 } else {
317 out.push_str(&format!(
318 " Stability: {pct}% ({stable_checks}/{checks} checks, {changes} change{})\n",
319 if changes == 1 { "" } else { "s" }
320 ));
321 if drift == 0 {
322 out.push_str(
323 " Status: stable (all changes were declared header changes)\n",
324 );
325 } else {
326 out.push_str(&format!(
327 " Status: WARNING — {drift} undeclared drift{}\n",
328 if drift == 1 { "" } else { "s" }
329 ));
330 }
331 if let Some(ref reason) = app.prefix_pin_reason {
332 out.push_str(&format!(" Pin reason: {reason}\n"));
333 }
334 if app.prefix_context_updates > 0 {
335 out.push_str(&format!(
336 " Context updates: {} (workspace drift delivered as history, header unchanged)\n",
337 app.prefix_context_updates
338 ));
339 }
340 if let Some(ref reason) = app.prefix_last_miss_reason {
341 out.push_str(&format!(" Last miss: {reason}\n"));
342 }
343 if let Some(ref desc) = app.last_prefix_change_desc {
344 out.push_str(&format!(" Last change: {desc}\n"));
345 }
346 }
347 }
348 None => {
349 out.push_str(" Stability: unknown (no checks recorded yet)\n");
350 out.push_str(" Run a turn first to collect prefix stability data.\n");
351 }
352 }
353
354 // ── Prefix fingerprint ────────────────────────────────────────────
355 out.push_str("\n── Prefix Fingerprint\n");
356 match &app.last_pinned_prefix_hash {
357 Some(hash) => {
358 out.push_str(&format!(" Pinned hash: {hash}\n"));
359 let short = if hash.len() >= 12 { &hash[..12] } else { hash };
360 out.push_str(&format!(" Short id: {short}\n"));
361 if app.prefix_drift_count > 0 {
362 out.push_str(" Drift: WARNING — undeclared hash change this session\n");
363 out.push_str(&format!(
364 " ({change} change{plural} detected, {drift} undeclared)\n",
365 change = app.prefix_change_count,
366 plural = if app.prefix_change_count == 1 {
367 ""
368 } else {
369 "s"
370 },
371 drift = app.prefix_drift_count,
372 ));
373 } else if app.prefix_change_count > 0 {
374 out.push_str(" Drift: none (all changes were declared)\n");
375 out.push_str(&format!(
376 " ({change} change{plural} detected)\n",
377 change = app.prefix_change_count,
378 plural = if app.prefix_change_count == 1 {
379 ""
380 } else {
381 "s"
382 },
383 ));
384 } else {
385 out.push_str(" Drift: none (hash stable)\n");
386 }
387 }
388 None => {
389 out.push_str(" Pinned hash: unavailable\n");
390 out.push_str(" Run a turn first, or use /cache inspect.\n");
391 }
392 }
393
394 // ── Cache hit-rate summary ────────────────────────────────────────
395 out.push_str("\n── Cache Hit Rate\n");
396 let history = &app.session.turn_cache_history;
397 if history.is_empty() {
398 out.push_str(" No turn telemetry recorded yet.\n");
399 } else {
400 // Aggregate only cache-aware turns; skip turns where the provider
401 // did not report cache telemetry (cache_hit_tokens is None).
402 // When cache_miss_tokens is None, infer it as
403 // input_tokens − cache_hit_tokens (matches /cache table logic).
404 let mut turns = 0u64;
405 let (hit, miss, input) = app.session.turn_cache_history.iter().fold(
406 (0u64, 0u64, 0u64),
407 |(hit, miss, input), rec| {
408 let Some(hit_tokens) = rec.cache_hit_tokens else {
409 return (hit, miss, input);
410 };
411 let h = u64::from(hit_tokens);
412 let m = u64::from(
413 rec.cache_miss_tokens
414 .unwrap_or(rec.input_tokens.saturating_sub(hit_tokens)),
415 );
416 turns += 1;
417 (hit + h, miss + m, input + u64::from(rec.input_tokens))
418 },
419 );
420 let total_cache = hit + miss;
421 let avg_pct = if total_cache > 0 {
422 (hit as f64 / total_cache as f64 * 100.0).clamp(0.0, 100.0)
423 } else {
424 0.0
425 };
426 out.push_str(&format!(" Turns recorded: {turns}\n"));
427 out.push_str(&format!(
428 " Cache hit tokens: {hit} ({avg_pct:.1}% of {total_cache} cache-aware tokens)\n",
429 hit = format_tokens(hit),
430 total_cache = format_tokens(total_cache),
431 ));
432 out.push_str(&format!(
433 " Cache miss tokens: {miss}\n",
434 miss = format_tokens(miss),
435 ));
436 out.push_str(&format!(
437 " Total input tokens: {input}\n",
438 input = format_tokens(input),
439 ));
440 if avg_pct < 80.0 {
441 out.push_str(" NOTE: cache hit rate is low (< 80%). Check prefix stability above or consider /compact.\n");
442 }
443 }
444
445 out
446 }
447
448 /// Render three-zone prefix contract status for `/cache zones` (#2264).
449 ///
450 /// Displays the PinnedPrefix fingerprint, AppendLog size, and TurnScratch
451 /// state. The zones are type scaffolding only (Phase 1) — not yet
452 /// enforcing the full contract at request time.
453 fn format_cache_zones(app: &App) -> String {
454 let mut out = String::new();
455 out.push_str("Cache Zones (#2264 three-zone contract, Phase 1 foundation)\n");
456
457 // ── PinnedPrefix ─────────────────────────────────────────────────
458 out.push_str("\n── PinnedPrefix (system + tools, frozen baseline)\n");
459 match &app.last_pinned_prefix_hash {
460 Some(hash) => {
461 let short = if hash.len() >= 12 { &hash[..12] } else { hash };
462 out.push_str(&format!(" Short id: {short}\n"));
463 if app.prefix_change_count > 0 {
464 out.push_str(&format!(
465 " Status: WARNING — {change} drift{plural} detected\n",
466 change = app.prefix_change_count,
467 plural = if app.prefix_change_count == 1 {
468 ""
469 } else {
470 "s"
471 }
472 ));
473 } else {
474 out.push_str(" Status: stable (no drift this session)\n");
475 }
476 if let Some(pct) = app.prefix_stability_pct {
477 out.push_str(&format!(" Stability: {pct}%\n"));
478 }
479 }
480 None => {
481 out.push_str(" Status: unavailable (not yet frozen)\n");
482 out.push_str(" Run a turn first to freeze the baseline.\n");
483 }
484 }
485
486 // ── AppendLog ────────────────────────────────────────────────────
487 out.push_str("\n── AppendLog (conversation history, append-only)\n");
488 out.push_str(" Status: Phase 1 scaffolding — not yet wired into engine\n");
489 let msg_count = app.api_messages.len();
490 out.push_str(&format!(" Messages: {msg_count}\n"));
491 let history_count = app
492 .api_messages
493 .iter()
494 .filter(|m| m.role != "system")
495 .count();
496 out.push_str(&format!(" History msgs: {history_count}\n"));
497
498 // ── TurnScratch ──────────────────────────────────────────────────
499 out.push_str("\n── TurnScratch (per-turn ephemeral data)\n");
500 out.push_str(" Status: Phase 1 scaffolding — not yet wired into engine\n");
501
502 // ── Zone contract summary ────────────────────────────────────────
503 out.push_str("\n── Contract Status\n");
504 let has_drift = app.prefix_change_count > 0;
505 out.push_str(&format!(
506 " PinnedPrefix: {}\n",
507 if app.last_pinned_prefix_hash.is_some() {
508 if has_drift {
509 "WARNING — drifted"
510 } else {
511 "OK"
512 }
513 } else {
514 "not frozen"
515 }
516 ));
517 out.push_str(" AppendLog: Phase 1 foundation\n");
518 out.push_str(" TurnScratch: Phase 1 foundation\n");
519
520 out
521 }
522
523 /// Formats a u64 token count with a compact suffix: K for thousands,
524 /// M for millions. Never returns scientific notation.
525 pub(crate) fn format_tokens(n: u64) -> String {
526 if n >= 1_000_000 {
527 format!("{:.1}M", n as f64 / 1_000_000.0)
528 } else if n >= 1_000 {
529 format!("{:.1}K", n as f64 / 1_000.0)
530 } else {
531 n.to_string()
532 }
533 }
534
535 fn format_static_prefix_status(
536 previous: Option<&PromptInspection>,
537 current: &PromptInspection,
538 ) -> String {
539 let Some(previous) = previous else {
540 return "Static base prefix stability: no previous request\n".to_string();
541 };
542 if previous.base_static_prefix_hash == current.base_static_prefix_hash {
543 return "Static base prefix stability: OK\n".to_string();
544 }
545
546 let changed = changed_static_layers(previous, current);
547 if changed.is_empty() {
548 "Static base prefix stability: WARNING (base hash changed)\n".to_string()
549 } else {
550 format!(
551 "Static base prefix stability: WARNING changed layers: {}\n",
552 changed.join(", ")
553 )
554 }
555 }
556
557 fn format_first_divergence(
558 previous: Option<&PromptInspection>,
559 current: &PromptInspection,
560 ) -> String {
561 let Some(previous) = previous else {
562 return "First divergence from previous request: unavailable\n".to_string();
563 };
564 let max_len = previous.layers.len().max(current.layers.len());
565 for index in 0..max_len {
566 match (previous.layers.get(index), current.layers.get(index)) {
567 (Some(prev), Some(curr)) if prev.name == curr.name && prev.sha256 == curr.sha256 => {}
568 (Some(prev), Some(curr)) if prev.name == curr.name => {
569 return format!("First divergence from previous request: {}\n", curr.name);
570 }
571 (Some(_), Some(curr)) => {
572 return format!("First divergence from previous request: {}\n", curr.name);
573 }
574 (None, Some(curr)) => {
575 return format!("First divergence from previous request: {}\n", curr.name);
576 }
577 (Some(prev), None) => {
578 return format!(
579 "First divergence from previous request: {} removed\n",
580 prev.name
581 );
582 }
583 (None, None) => break,
584 }
585 }
586 "First divergence from previous request: none\n".to_string()
587 }
588
589 fn changed_static_layers(previous: &PromptInspection, current: &PromptInspection) -> Vec<String> {
590 current
591 .layers
592 .iter()
593 .filter(|layer| layer.stability.label() == "static")
594 .filter(|layer| {
595 previous
596 .layers
597 .iter()
598 .find(|previous_layer| previous_layer.name == layer.name)
599 .is_none_or(|previous_layer| previous_layer.sha256 != layer.sha256)
600 })
601 .map(|layer| layer.name.clone())
602 .collect()
603 }
604
605 /// Column header for the per-turn cache/cost table. The widths here must match
606 /// the row format strings below.
607 const TURN_CACHE_ROW_HEADER: &str = "turn route in out hit miss write replay ratio cost age";
608
609 /// Rule width for the table. Sized to the header above.
610 const TURN_CACHE_TABLE_WIDTH: usize = 106;
611
612 /// Render one turn's cost cell, collecting the reason when it has none.
613 ///
614 /// A turn with no route provenance (legacy or synthetic record) and a turn on a
615 /// route that is not money-metered both render as `—` — neither is a real
616 /// zero-dollar charge.
617 fn turn_cost_cell(
618 rec: &TurnCacheRecord,
619 currency: crate::pricing::CostCurrency,
620 unpriced_reasons: &mut std::collections::BTreeSet<crate::pricing::UnpricedReason>,
621 unpriced_classes: &mut std::collections::BTreeSet<&'static str>,
622 ) -> String {
623 let Some(audit) = rec.cost_audit.as_ref() else {
624 return "—".to_string();
625 };
626 if audit.is_priced_in(currency)
627 && let Some(estimate) = audit.estimate
628 {
629 return crate::pricing::format_cost_amount_precise(estimate.amount(currency), currency);
630 }
631 if let Some(reason) = audit.unpriced_reason {
632 unpriced_reasons.insert(reason);
633 }
634 for class in &audit.unpriced_classes {
635 unpriced_classes.insert(class.label());
636 }
637 "—".to_string()
638 }
639
640 fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
641 let total = app.session.turn_cache_history.len();
642 let start = total.saturating_sub(count);
643 let rows: Vec<&TurnCacheRecord> = app.session.turn_cache_history.iter().skip(start).collect();
644
645 let mut totals_input: u64 = 0;
646 let mut totals_hit: u64 = 0;
647 let mut totals_miss: u64 = 0;
648 let mut totals_write: u64 = 0;
649 let mut totals_reasoning: u64 = 0;
650 let currency = app.cost_display_currency(app.cost_currency);
651 // Non-secret audit trail for turns whose spend is missing from the session
652 // total, so a `—` in the cost column is always explainable.
653 let mut unpriced_reasons: std::collections::BTreeSet<crate::pricing::UnpricedReason> =
654 std::collections::BTreeSet::new();
655 let mut unpriced_classes: std::collections::BTreeSet<&'static str> =
656 std::collections::BTreeSet::new();
657 let mut header = tr(locale, MessageId::CmdCacheHeader)
658 .replace("{count}", &rows.len().to_string())
659 .replace("{total}", &total.to_string())
660 .replace("{model}", &app.model);
661 header.push_str(&"─".repeat(TURN_CACHE_TABLE_WIDTH));
662 header.push('\n');
663 header.push_str(TURN_CACHE_ROW_HEADER);
664 header.push('\n');
665 header.push_str(&"─".repeat(TURN_CACHE_TABLE_WIDTH));
666 header.push('\n');
667
668 let now = Instant::now();
669 let mut body = String::new();
670 let absolute_start = total.saturating_sub(rows.len());
671 for (i, rec) in rows.iter().enumerate() {
672 let turn_index = absolute_start + i + 1;
673 totals_input += u64::from(rec.input_tokens);
674
675 let replay_cell = rec
676 .reasoning_replay_tokens
677 .map_or_else(|| "—".to_string(), |t| t.to_string());
678 let classes = crate::pricing::token_usage_for_pricing(&codewhale_models::Usage {
679 input_tokens: rec.input_tokens,
680 output_tokens: rec.output_tokens,
681 prompt_cache_hit_tokens: rec.cache_hit_tokens,
682 prompt_cache_miss_tokens: rec.cache_miss_tokens,
683 prompt_cache_write_tokens: rec.cache_write_tokens,
684 reasoning_tokens: rec.reasoning_tokens,
685 reasoning_replay_tokens: rec.reasoning_replay_tokens,
686 server_tool_use: None,
687 });
688 let write = u32::try_from(classes.cache_write).unwrap_or(u32::MAX);
689 let write_cell = rec
690 .cache_write_tokens
691 .map_or_else(|| "—".to_string(), |_| write.to_string());
692 totals_write += classes.cache_write;
693 totals_reasoning += u64::from(rec.reasoning_tokens.unwrap_or(0));
694 let cost_cell = turn_cost_cell(rec, currency, &mut unpriced_reasons, &mut unpriced_classes);
695 let route_cell = format_turn_cache_route(rec);
696 let age = humanize_age(now.saturating_duration_since(rec.recorded_at));
697
698 // No cache telemetry → render `—` everywhere and don't pollute totals
699 // with inferred zeros. Some providers (and some routes inside DeepSeek)
700 // skip the cache fields; including a synthesized 0/N for those turns
701 // would make every aggregate ratio look broken.
702 if rec.cache_hit_tokens.is_none()
703 && rec.cache_miss_tokens.is_none()
704 && rec.cache_write_tokens.is_none()
705 {
706 body.push_str(&format!(
707 "{turn:>4} {route:<24} {input:>5} {output:>5} {hit:>5} {miss:>5} {write:>5} {replay:>6} {ratio:>6} {cost:>9} {age}\n",
708 turn = turn_index,
709 route = route_cell,
710 input = rec.input_tokens,
711 output = rec.output_tokens,
712 hit = "—",
713 miss = "—",
714 write = write_cell,
715 replay = replay_cell,
716 ratio = "—",
717 cost = cost_cell,
718 age = age,
719 ));
720 continue;
721 }
722
723 let miss_reported = rec.cache_miss_tokens;
724 let hit = u32::try_from(classes.cache_read).unwrap_or(u32::MAX);
725 let miss = u32::try_from(classes.input).unwrap_or(u32::MAX);
726 // Use the same mutually-exclusive hit/miss/write partition as pricing.
727 // Inferring `input - hit` here and then adding write counted creation
728 // tokens twice in exactly the turns with a write premium.
729 let accounted = u64::from(hit) + u64::from(miss) + u64::from(write);
730 let ratio = if accounted == 0 {
731 " —".to_string()
732 } else {
733 format!("{:>5.1}%", 100.0 * f64::from(hit) / accounted as f64)
734 };
735 totals_hit += u64::from(hit);
736 totals_miss += u64::from(miss);
737
738 let miss_cell = match miss_reported {
739 Some(_) => format!("{miss}"),
740 None => format!("{miss}*"),
741 };
742
743 body.push_str(&format!(
744 "{turn:>4} {route:<24} {input:>5} {output:>5} {hit:>5} {miss:>5} {write:>5} {replay:>6} {ratio} {cost:>9} {age}\n",
745 turn = turn_index,
746 route = route_cell,
747 input = rec.input_tokens,
748 output = rec.output_tokens,
749 hit = hit,
750 miss = miss_cell,
751 write = write_cell,
752 replay = replay_cell,
753 ratio = ratio,
754 cost = cost_cell,
755 age = age,
756 ));
757 }
758
759 // Anthropic-normalized aggregate: hit / (hit + miss + write).
760 let totals_accounted = totals_hit + totals_miss + totals_write;
761 let avg_ratio = if totals_accounted == 0 {
762 "—".to_string()
763 } else {
764 format!(
765 "{:.1}%",
766 100.0 * totals_hit as f64 / totals_accounted as f64
767 )
768 };
769
770 let mut footer = String::new();
771 footer.push_str(&"─".repeat(TURN_CACHE_TABLE_WIDTH));
772 footer.push('\n');
773 // Reasoning is reported separately from `sum_out` on purpose: providers
774 // count it *inside* the completion tokens they bill, so adding the two
775 // would double-count it.
776 footer.push_str(&format!(
777 "sum_write: {totals_write} sum_reasoning: {totals_reasoning} (already inside out)\n"
778 ));
779 footer.push_str(
780 &tr(locale, MessageId::CmdCacheTotals)
781 .replace("{sum_in}", &totals_input.to_string())
782 .replace("{sum_hit}", &totals_hit.to_string())
783 .replace("{sum_miss}", &totals_miss.to_string())
784 .replace("{avg}", &avg_ratio),
785 );
786 footer.push_str(&tr(locale, MessageId::CmdCacheFootnote));
787 if !unpriced_reasons.is_empty() || !unpriced_classes.is_empty() {
788 // Reasons are localized prose; token-class labels are key names and
789 // stay raw, the same split `/cost` uses.
790 let notes = unpriced_reasons
791 .iter()
792 .map(|reason| tr(locale, reason.message_id()).into_owned())
793 .chain(unpriced_classes.iter().map(|class| (*class).to_string()))
794 .collect::<Vec<_>>()
795 .join(", ");
796 footer.push_str(&tr(locale, MessageId::CmdCacheUnpricedNote).replace("{notes}", &notes));
797 }
798 footer.push_str(&tr(locale, MessageId::CmdCacheAdvice));
799
800 format!("{header}{body}{footer}")
801 }
802
803 fn format_turn_cache_route(rec: &TurnCacheRecord) -> String {
804 let Some(model) = rec.model.as_deref().filter(|model| !model.is_empty()) else {
805 return "—".to_string();
806 };
807 let provider = rec
808 .provider_identity
809 .as_deref()
810 .filter(|provider| !provider.trim().is_empty())
811 .or_else(|| rec.provider.map(|provider| provider.as_str()))
812 .unwrap_or("?");
813 let route = if rec.auto_model {
814 format!("auto:{provider}/{model}")
815 } else {
816 format!("{provider}/{model}")
817 };
818 truncate_route_cell(&route, 24)
819 }
820
821 fn truncate_route_cell(route: &str, max_chars: usize) -> String {
822 if route.chars().count() <= max_chars {
823 return route.to_string();
824 }
825 if max_chars <= 3 {
826 return route.chars().take(max_chars).collect();
827 }
828 let mut out: String = route.chars().take(max_chars - 3).collect();
829 out.push_str("...");
830 out
831 }
832
833 fn humanize_age(d: std::time::Duration) -> String {
834 crate::elapsed::format_elapsed_secs(d.as_secs())
835 }
836
837 #[cfg(test)]
838 mod route_tests {
839 use super::*;
840
841 #[test]
842 fn cache_route_keeps_exact_named_custom_identity() {
843 let record = TurnCacheRecord {
844 provider: Some(crate::config::ApiProvider::Custom),
845 provider_identity: Some("lm-studio".to_string()),
846 model: Some("local-code-model".to_string()),
847 auto_model: false,
848 input_tokens: 1,
849 output_tokens: 1,
850 cache_hit_tokens: None,
851 cache_miss_tokens: None,
852 reasoning_replay_tokens: None,
853 cache_write_tokens: None,
854 reasoning_tokens: None,
855 cost_audit: None,
856 recorded_at: Instant::now(),
857 };
858
859 assert_eq!(format_turn_cache_route(&record), "lm-studio/local-code-...");
860 }
861 }
862
862 lines RUST