1use anyhow::{Result, bail};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::{BTreeMap, BTreeSet};
5
6use tsift_quality::runtime_churn::{RestartChurnState, RestartChurnSummary};
7
8const MAX_LARGEST_TURNS: usize = 5;
9const MAX_RUNTIME_EVENTS: usize = 8;
10const MAX_GUARDRAILS: usize = 8;
11const MAX_LOOP_CLUSTERS: usize = 8;
12const MAX_FILE_READ_DIAGNOSTICS: usize = 8;
13const MAX_PROMPT_CACHE_TIMELINE: usize = 8;
14const MAX_PROMPT_CACHE_DIAGNOSTICS: usize = 6;
15const MAX_PROMPT_CACHE_PREFIX_DRIFT: usize = 6;
16const MAX_PROMPT_CACHE_SCORECARD: usize = 6;
17const MAX_PROMPT_CACHE_BREAKPOINTS: usize = 8;
18const MAX_COMMANDS_PER_BUNDLE: usize = 6;
19const PROMPT_CACHE_SCORECARD_DEFAULT_NEXT_COMMAND: &str =
20 "tsift session-cost --input <session.jsonl> --json";
21const PROMPT_BUDGET_WARN_TOKENS: u64 = 100_000;
22const CACHED_RATIO_WARN_PERCENT: f64 = 90.0;
23const CACHED_RATIO_WARN_PROMPT_TOKENS: u64 = 50_000;
24const PROMPT_CACHE_CANDIDATE_TOKENS: u64 = 16_000;
25const PROMPT_CACHE_GOOD_HIT_PERCENT: f64 = 75.0;
26const PROMPT_CACHE_TREND_DELTA_PERCENT: f64 = 5.0;
27const PROMPT_CACHE_RATIO_DROP_WARN_PERCENT: f64 = 20.0;
28const PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT: f64 = 20.0;
29pub(crate) const PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO: f64 = 2.0;
30const RESTART_LOOP_WARN_OCCURRENCES: usize = 3;
31const NOOP_CLOSEOUT_WARN_OCCURRENCES: usize = 3;
32const DEFAULT_FULL_FILE_READ_TOKENS: u64 = 4_000;
33const ESTIMATED_TOKENS_PER_SOURCE_LINE: u64 = 18;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum SessionCostSource {
38 ClaudeJsonl,
39 CodexJsonl,
40 AgentDocLog,
41}
42
43impl SessionCostSource {
44 pub fn parse(raw: &str) -> Result<Self> {
45 match raw.trim().to_ascii_lowercase().as_str() {
46 "claude" | "claude-jsonl" => Ok(Self::ClaudeJsonl),
47 "codex" | "codex-jsonl" => Ok(Self::CodexJsonl),
48 "agent-doc-log" | "agent_doc_log" | "log" => Ok(Self::AgentDocLog),
49 other => bail!(
50 "unsupported session-cost source `{other}`; expected claude-jsonl, codex-jsonl, or agent-doc-log"
51 ),
52 }
53 }
54
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::ClaudeJsonl => "claude_jsonl",
58 Self::CodexJsonl => "codex_jsonl",
59 Self::AgentDocLog => "agent_doc_log",
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct SessionCostPromptCacheMetadata {
66 pub provider: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub cache_key: Option<String>,
69 pub stable_prefix_fingerprint: String,
70 #[serde(skip)]
78 pub stable_prefix_fingerprint_explicit: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
84 pub stable_prefix: Option<String>,
85 #[serde(skip_serializing_if = "Vec::is_empty", default)]
86 pub breakpoints: Vec<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub routing_affinity: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct SessionCostTurn {
93 pub label: String,
94 pub prompt_tokens: u64,
95 pub cached_input_tokens: u64,
96 pub cache_creation_input_tokens: u64,
97 pub output_tokens: u64,
98 pub reasoning_output_tokens: u64,
99 pub total_tokens: u64,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct SessionCostRuntimeEvent {
106 pub event: String,
107 pub occurrences: usize,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct SessionCostGuardrail {
112 pub kind: String,
113 pub severity: String,
114 pub message: String,
115 pub guidance: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119pub struct SessionCostPromptCachePlan {
120 pub status: String,
121 pub feasible: bool,
122 pub observed_cached_input_tokens: u64,
123 pub observed_cache_creation_tokens: u64,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub observed_cached_input_ratio: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub analytics: Option<SessionCostPromptCacheAnalytics>,
128 #[serde(skip_serializing_if = "Vec::is_empty", default)]
129 pub scorecard: Vec<SessionCostPromptCacheRoiScorecard>,
130 pub invariants: Vec<String>,
131 pub provider_adapters: Vec<SessionCostPromptCacheProvider>,
132 pub actions: Vec<SessionCostPromptCacheAction>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136pub struct SessionCostPromptCacheProvider {
137 pub provider: String,
138 pub status: String,
139 pub requirements: Vec<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct SessionCostPromptCacheAction {
144 pub kind: String,
145 pub severity: String,
146 pub message: String,
147 pub guidance: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151pub struct SessionCostPromptCacheRoiScorecard {
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub session_source: Option<String>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub session_path: Option<String>,
156 pub provider: String,
157 pub sample_count: usize,
158 pub net_cached_read_tokens: i64,
159 pub read_create_ratio: String,
160 pub trend: String,
161 pub suspected_invalidation_cause: String,
162 pub next_command: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166pub struct SessionCostPromptCacheAnalytics {
167 pub sample_count: usize,
168 pub effective: bool,
169 pub trend: String,
170 pub total_prompt_tokens: u64,
171 pub total_cached_input_tokens: u64,
172 pub total_cache_creation_tokens: u64,
173 pub net_cached_input_tokens: i64,
174 pub timeline_truncated: bool,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub average_cached_input_ratio: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub first_cached_input_ratio: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub last_cached_input_ratio: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub cached_input_ratio_delta: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub cache_read_to_creation_ratio: Option<String>,
185 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 pub diagnostics: Vec<SessionCostPromptCacheDiagnostic>,
187 pub prefix_drift_truncated: bool,
188 #[serde(skip_serializing_if = "Vec::is_empty", default)]
189 pub prefix_drift: Vec<SessionCostPromptCachePrefixDrift>,
190 pub timeline: Vec<SessionCostPromptCacheTimelineEntry>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct SessionCostPromptCacheDiagnostic {
195 pub kind: String,
196 pub severity: String,
197 pub label: String,
198 pub message: String,
199 pub likely_causes: Vec<String>,
200 pub guidance: String,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204pub struct SessionCostPromptCachePrefixDrift {
205 pub previous_label: String,
206 pub current_label: String,
207 pub trigger: String,
208 pub severity: String,
209 pub first_changed_field: String,
210 pub remediation: String,
215 pub field_changes: Vec<SessionCostPromptCacheFieldChange>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub cached_input_ratio_before: Option<String>,
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub cached_input_ratio_after: Option<String>,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub cache_creation_ratio: Option<String>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct SessionCostPromptCacheFieldChange {
226 pub field: String,
227 pub previous: String,
228 pub current: String,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
232pub struct SessionCostPromptCacheTimelineEntry {
233 pub label: String,
234 pub prompt_tokens: u64,
235 pub cached_input_tokens: u64,
236 pub cache_creation_input_tokens: u64,
237 #[serde(skip_serializing_if = "Option::is_none")]
238 pub cached_input_ratio: Option<String>,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub cache_creation_ratio: Option<String>,
241 #[serde(skip_serializing_if = "Option::is_none")]
242 pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
246pub struct SessionCostLoopCluster {
247 pub kind: String,
248 pub label: String,
249 pub occurrences: usize,
250 pub max_consecutive: usize,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
254pub struct SessionCostFileReadDiagnostic {
255 pub path: String,
256 pub range: String,
257 pub occurrences: usize,
258 pub estimated_tokens: u64,
259 pub duplicate_estimated_tokens: u64,
260 pub follow_up_commands: Vec<String>,
261}
262
263#[derive(Debug, Clone, Default)]
264pub struct SessionCostGuardrailInput {
265 pub largest_prompt_turn_tokens: u64,
266 pub largest_prompt_turn_label: Option<String>,
267 pub prompt_tokens: u64,
268 pub cached_input_ratio: Option<f64>,
269 pub fresh_restart_occurrences: usize,
270 pub auto_trigger_timeout_occurrences: usize,
271 pub ctrl_d_restart_loop_occurrences: usize,
272 pub noop_closeout_occurrences: usize,
273 pub max_restart_count: Option<usize>,
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize)]
277pub struct SessionCostReport {
278 pub source: String,
279 pub record_count: usize,
280 pub usage_samples: usize,
281 pub prompt_tokens: u64,
282 pub cached_input_tokens: u64,
283 pub cache_creation_input_tokens: u64,
284 pub output_tokens: u64,
285 pub reasoning_output_tokens: u64,
286 pub total_tokens: u64,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub cached_input_ratio: Option<f64>,
289 pub largest_turn_total_tokens: u64,
290 pub runtime_event_groups: usize,
291 pub total_runtime_events: usize,
292 pub restart_churn_groups: usize,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub max_restart_count: Option<usize>,
295 pub largest_turns: Vec<SessionCostTurn>,
296 pub runtime_events: Vec<SessionCostRuntimeEvent>,
297 #[serde(skip_serializing_if = "Vec::is_empty", default)]
298 pub loop_clusters: Vec<SessionCostLoopCluster>,
299 #[serde(skip_serializing_if = "Vec::is_empty", default)]
300 pub file_read_diagnostics: Vec<SessionCostFileReadDiagnostic>,
301 #[serde(skip_serializing_if = "Vec::is_empty", default)]
302 pub restart_churn: Vec<RestartChurnSummary>,
303 #[serde(skip_serializing_if = "Vec::is_empty", default)]
304 pub guardrails: Vec<SessionCostGuardrail>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub prompt_cache_plan: Option<SessionCostPromptCachePlan>,
307 #[serde(skip_serializing_if = "Vec::is_empty", default)]
308 pub warnings: Vec<String>,
309}
310
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct SessionCostPromptCacheEffectivenessFixture {
313 pub schema_version: u64,
314 #[serde(default)]
315 pub description: String,
316 #[serde(skip_serializing_if = "Vec::is_empty", default)]
317 pub required_regression_scenarios: Vec<String>,
318 pub cases: Vec<SessionCostPromptCacheEffectivenessCase>,
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
322pub struct SessionCostPromptCacheEffectivenessCase {
323 pub name: String,
324 pub source: String,
325 pub input_lines: Vec<String>,
326 pub minimum_cached_input_ratio: f64,
327 pub minimum_net_cached_input_tokens: i64,
328 pub maximum_read_create_regressions: usize,
329 #[serde(skip_serializing_if = "Vec::is_empty", default)]
330 pub regression_scenarios: Vec<String>,
331 #[serde(skip_serializing_if = "Vec::is_empty", default)]
332 pub required_prefix_drift_fields: Vec<String>,
333 #[serde(skip_serializing_if = "Vec::is_empty", default)]
334 pub required_diagnostics: Vec<String>,
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize)]
338pub struct SessionCostPromptCacheEffectivenessReport {
339 pub schema_version: u64,
340 pub pass: bool,
341 pub totals: SessionCostPromptCacheEffectivenessTotals,
342 pub required_regression_scenarios: Vec<String>,
343 pub covered_regression_scenarios: Vec<String>,
344 pub missing_regression_scenarios: Vec<String>,
345 pub cases: Vec<SessionCostPromptCacheEffectivenessCaseReport>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
349pub struct SessionCostPromptCacheEffectivenessTotals {
350 pub cases: usize,
351 pub passed: usize,
352 pub failed: usize,
353 pub prompt_tokens: u64,
354 pub cached_input_tokens: u64,
355 pub cache_creation_input_tokens: u64,
356 pub net_cached_input_tokens: i64,
357 pub read_create_regressions: usize,
358}
359
360#[derive(Debug, Clone, PartialEq, Serialize)]
361pub struct SessionCostPromptCacheEffectivenessCaseReport {
362 pub name: String,
363 pub source: String,
364 pub status: String,
365 pub prompt_tokens: u64,
366 pub cached_input_tokens: u64,
367 pub cache_creation_input_tokens: u64,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub cached_input_ratio: Option<f64>,
370 pub minimum_cached_input_ratio: f64,
371 pub net_cached_input_tokens: i64,
372 pub minimum_net_cached_input_tokens: i64,
373 pub read_create_regressions: usize,
374 pub maximum_read_create_regressions: usize,
375 #[serde(skip_serializing_if = "Vec::is_empty", default)]
376 pub regression_scenarios: Vec<String>,
377 #[serde(skip_serializing_if = "Vec::is_empty", default)]
378 pub required_prefix_drift_fields: Vec<String>,
379 #[serde(skip_serializing_if = "Vec::is_empty", default)]
380 pub required_diagnostics: Vec<String>,
381 #[serde(skip_serializing_if = "Vec::is_empty", default)]
382 pub failures: Vec<String>,
383}
384
385#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
386struct UsageTotals {
387 prompt_tokens: u64,
388 cached_input_tokens: u64,
389 cache_creation_input_tokens: u64,
390 output_tokens: u64,
391 reasoning_output_tokens: u64,
392 total_tokens: u64,
393}
394
395impl UsageTotals {
396 fn delta_from(self, previous: Self) -> Self {
397 Self {
398 prompt_tokens: self.prompt_tokens.saturating_sub(previous.prompt_tokens),
399 cached_input_tokens: self
400 .cached_input_tokens
401 .saturating_sub(previous.cached_input_tokens),
402 cache_creation_input_tokens: self
403 .cache_creation_input_tokens
404 .saturating_sub(previous.cache_creation_input_tokens),
405 output_tokens: self.output_tokens.saturating_sub(previous.output_tokens),
406 reasoning_output_tokens: self
407 .reasoning_output_tokens
408 .saturating_sub(previous.reasoning_output_tokens),
409 total_tokens: self.total_tokens.saturating_sub(previous.total_tokens),
410 }
411 }
412
413 fn is_zero(self) -> bool {
414 self.prompt_tokens == 0
415 && self.cached_input_tokens == 0
416 && self.cache_creation_input_tokens == 0
417 && self.output_tokens == 0
418 && self.reasoning_output_tokens == 0
419 && self.total_tokens == 0
420 }
421}
422
423#[derive(Debug, Default)]
424struct CostState {
425 warnings: Vec<String>,
426 usage_turns: Vec<SessionCostTurn>,
427 runtime_events: BTreeMap<String, usize>,
428 seen_document_cycle_events: BTreeSet<(String, String)>,
429 total_runtime_events: usize,
430 max_restart_count: Option<usize>,
431 restart_churn: RestartChurnState,
432 pending_commands: Vec<String>,
433 loop_signals: Vec<LoopSignal>,
434 file_read_signals: Vec<FileReadSignal>,
435}
436
437#[derive(Debug, Default)]
438struct PromptCacheAdapterEvidence {
439 anthropic_samples: usize,
440 anthropic_cache_control_samples: usize,
441 openai_samples: usize,
442 openai_prompt_cache_key_samples: usize,
443 openai_prompt_cache_keys: BTreeSet<String>,
444 routed_provider_samples: usize,
445 routing_affinity_samples: usize,
446 routing_affinity_values: BTreeSet<String>,
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
450struct LoopSignal {
451 kind: LoopClusterKind,
452 label: String,
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
456struct FileReadSignal {
457 path: String,
458 range: String,
459 start: Option<usize>,
460 lines: Option<usize>,
461 estimated_tokens: u64,
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
465enum LoopClusterKind {
466 PromptRepeat,
467 CommandBundle,
468 CloseoutChurn,
469}
470
471impl LoopClusterKind {
472 fn as_str(self) -> &'static str {
473 match self {
474 Self::PromptRepeat => "prompt_repeat",
475 Self::CommandBundle => "command_bundle",
476 Self::CloseoutChurn => "closeout_churn",
477 }
478 }
479}
480
481#[derive(Debug, Clone)]
482enum TranscriptBlock {
483 Text { role: Option<String>, text: String },
484 ToolUse { name: String, input: Value },
485}
486
487pub fn compute(input: &str, source_hint: Option<&str>) -> Result<SessionCostReport> {
488 if input.trim().is_empty() {
489 bail!(
490 "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
491 );
492 }
493
494 let source = resolve_source(input, source_hint)?;
495 let mut state = CostState::default();
496 let record_count = input.lines().filter(|line| !line.trim().is_empty()).count();
497
498 match source {
499 SessionCostSource::ClaudeJsonl => ingest_claude_jsonl(input, &mut state)?,
500 SessionCostSource::CodexJsonl => ingest_codex_jsonl(input, &mut state)?,
501 SessionCostSource::AgentDocLog => ingest_agent_doc_log(input, &mut state),
502 }
503
504 let usage_samples = state.usage_turns.len();
505 let mut prompt_tokens = 0_u64;
506 let mut cached_input_tokens = 0_u64;
507 let mut cache_creation_input_tokens = 0_u64;
508 let mut output_tokens = 0_u64;
509 let mut reasoning_output_tokens = 0_u64;
510 let mut total_tokens = 0_u64;
511 let mut largest_turn_total_tokens = 0_u64;
512 for turn in &state.usage_turns {
513 prompt_tokens += turn.prompt_tokens;
514 cached_input_tokens += turn.cached_input_tokens;
515 cache_creation_input_tokens += turn.cache_creation_input_tokens;
516 output_tokens += turn.output_tokens;
517 reasoning_output_tokens += turn.reasoning_output_tokens;
518 total_tokens += turn.total_tokens;
519 largest_turn_total_tokens = largest_turn_total_tokens.max(turn.total_tokens);
520 }
521
522 let cached_input_ratio = (prompt_tokens > 0).then_some(
523 ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
524 );
525 let largest_prompt_turn = state
526 .usage_turns
527 .iter()
528 .max_by(|left, right| {
529 left.prompt_tokens
530 .cmp(&right.prompt_tokens)
531 .then(left.label.cmp(&right.label))
532 })
533 .map(|turn| (turn.prompt_tokens, turn.label.clone()));
534 let noop_closeout_occurrences = state
535 .runtime_events
536 .get("commit_already_current")
537 .copied()
538 .unwrap_or(0);
539 flush_pending_commands(&mut state);
540 let loop_clusters = collect_loop_clusters(&state.loop_signals);
541 let file_read_diagnostics = collect_file_read_diagnostics(&state.file_read_signals);
542 let prompt_cache_plan = derive_prompt_cache_plan(
543 source,
544 prompt_tokens,
545 cached_input_tokens,
546 cache_creation_input_tokens,
547 cached_input_ratio,
548 &state.usage_turns,
549 );
550
551 let mut largest_turns = state.usage_turns;
552 largest_turns.sort_by(|left, right| {
553 right
554 .total_tokens
555 .cmp(&left.total_tokens)
556 .then(right.prompt_tokens.cmp(&left.prompt_tokens))
557 .then(left.label.cmp(&right.label))
558 });
559 largest_turns.truncate(MAX_LARGEST_TURNS);
560
561 let mut runtime_events = state
562 .runtime_events
563 .into_iter()
564 .map(|(event, occurrences)| SessionCostRuntimeEvent { event, occurrences })
565 .collect::<Vec<_>>();
566 runtime_events.sort_by(|left, right| {
567 right
568 .occurrences
569 .cmp(&left.occurrences)
570 .then(left.event.cmp(&right.event))
571 });
572 let runtime_event_groups = runtime_events.len();
573 runtime_events.truncate(MAX_RUNTIME_EVENTS);
574 let restart_churn_groups = state.restart_churn.groups();
575 let restart_churn = state.restart_churn.summaries();
576 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
577 largest_prompt_turn_tokens: largest_prompt_turn.as_ref().map_or(0, |turn| turn.0),
578 largest_prompt_turn_label: largest_prompt_turn.as_ref().map(|turn| turn.1.clone()),
579 prompt_tokens,
580 cached_input_ratio,
581 fresh_restart_occurrences: count_restart_family(&restart_churn, "fresh_restart"),
582 auto_trigger_timeout_occurrences: count_restart_family(
583 &restart_churn,
584 "auto_trigger_timeout",
585 ),
586 ctrl_d_restart_loop_occurrences: count_restart_family(
587 &restart_churn,
588 "ctrl_d_restart_loop",
589 ),
590 noop_closeout_occurrences,
591 max_restart_count: state.max_restart_count,
592 });
593
594 if usage_samples == 0 && runtime_event_groups == 0 {
595 state
596 .warnings
597 .push("no cost or runtime signals were detected in the provided input".to_string());
598 }
599
600 Ok(SessionCostReport {
601 source: source.as_str().to_string(),
602 record_count,
603 usage_samples,
604 prompt_tokens,
605 cached_input_tokens,
606 cache_creation_input_tokens,
607 output_tokens,
608 reasoning_output_tokens,
609 total_tokens,
610 cached_input_ratio,
611 largest_turn_total_tokens,
612 runtime_event_groups,
613 total_runtime_events: state.total_runtime_events,
614 restart_churn_groups,
615 max_restart_count: state.max_restart_count,
616 largest_turns,
617 runtime_events,
618 loop_clusters,
619 file_read_diagnostics,
620 restart_churn,
621 guardrails,
622 prompt_cache_plan,
623 warnings: state.warnings,
624 })
625}
626
627pub fn set_prompt_cache_scorecard_next_command(report: &mut SessionCostReport, next_command: &str) {
628 if let Some(plan) = &mut report.prompt_cache_plan {
629 for row in &mut plan.scorecard {
630 row.next_command = next_command.to_string();
631 }
632 }
633}
634
635pub fn prompt_cache_scorecard_for_session(
636 report: &SessionCostReport,
637 session_source: &str,
638 session_path: &str,
639 next_command: &str,
640) -> Vec<SessionCostPromptCacheRoiScorecard> {
641 report
642 .prompt_cache_plan
643 .as_ref()
644 .map(|plan| {
645 plan.scorecard
646 .iter()
647 .cloned()
648 .map(|mut row| {
649 row.session_source = Some(session_source.to_string());
650 row.session_path = Some(session_path.to_string());
651 row.next_command = next_command.to_string();
652 row
653 })
654 .collect()
655 })
656 .unwrap_or_default()
657}
658
659pub fn build_prompt_cache_effectiveness_report(
660 fixture: &SessionCostPromptCacheEffectivenessFixture,
661) -> Result<SessionCostPromptCacheEffectivenessReport> {
662 if fixture.cases.is_empty() {
663 bail!("prompt-cache effectiveness fixture has no cases");
664 }
665
666 let mut cases = Vec::new();
667 let required_regression_scenarios =
668 normalized_prompt_cache_scenarios(&fixture.required_regression_scenarios);
669 let mut covered_regression_scenarios = BTreeSet::new();
670 let mut totals = SessionCostPromptCacheEffectivenessTotals {
671 cases: 0,
672 passed: 0,
673 failed: 0,
674 prompt_tokens: 0,
675 cached_input_tokens: 0,
676 cache_creation_input_tokens: 0,
677 net_cached_input_tokens: 0,
678 read_create_regressions: 0,
679 };
680
681 for case in &fixture.cases {
682 if case.input_lines.is_empty() {
683 bail!(
684 "prompt-cache fixture case `{}` has no input_lines",
685 case.name
686 );
687 }
688 let input = format!("{}\n", case.input_lines.join("\n"));
689 let report = compute(&input, Some(&case.source))
690 .map_err(|err| err.context(format!("evaluating prompt-cache fixture {}", case.name)))?;
691 let analytics = report
692 .prompt_cache_plan
693 .as_ref()
694 .and_then(|plan| plan.analytics.as_ref());
695 let net_cached_input_tokens = analytics.map_or(
696 signed_token_delta(
697 report.cached_input_tokens,
698 report.cache_creation_input_tokens,
699 ),
700 |analytics| analytics.net_cached_input_tokens,
701 );
702 let read_create_regressions = usize::from(
707 prompt_cache_read_create_regression(
708 report.cached_input_tokens,
709 report.cache_creation_input_tokens,
710 )
711 .is_some(),
712 );
713 let regression_scenarios = normalized_prompt_cache_scenarios(&case.regression_scenarios);
714 covered_regression_scenarios.extend(regression_scenarios.iter().cloned());
715
716 let mut failures = Vec::new();
717 if report.prompt_cache_plan.is_none() {
718 failures.push("missing prompt_cache_plan".to_string());
719 }
720 if analytics.is_none() {
721 failures.push("missing prompt_cache_plan.analytics".to_string());
722 }
723 match report.cached_input_ratio {
724 Some(ratio) if ratio >= case.minimum_cached_input_ratio => {}
725 Some(ratio) => failures.push(format!(
726 "cached_input_ratio {:.2}% below required {:.2}%",
727 ratio, case.minimum_cached_input_ratio
728 )),
729 None => failures.push(format!(
730 "cached_input_ratio missing; required {:.2}%",
731 case.minimum_cached_input_ratio
732 )),
733 }
734 if net_cached_input_tokens < case.minimum_net_cached_input_tokens {
735 failures.push(format!(
736 "net_cached_input_tokens {} below required {}",
737 net_cached_input_tokens, case.minimum_net_cached_input_tokens
738 ));
739 }
740 if read_create_regressions > case.maximum_read_create_regressions {
741 failures.push(format!(
742 "read_create_regressions {} exceeded allowed {}",
743 read_create_regressions, case.maximum_read_create_regressions
744 ));
745 }
746 failures.extend(prompt_cache_provider_adapter_failures(
747 case,
748 report.prompt_cache_plan.as_ref(),
749 ));
750 failures.extend(prompt_cache_required_prefix_drift_failures(
751 analytics,
752 &case.required_prefix_drift_fields,
753 ));
754 failures.extend(prompt_cache_required_diagnostic_failures(
755 analytics,
756 &case.required_diagnostics,
757 ));
758
759 let status = if failures.is_empty() {
760 "pass".to_string()
761 } else {
762 "fail".to_string()
763 };
764 totals.cases += 1;
765 if status == "pass" {
766 totals.passed += 1;
767 } else {
768 totals.failed += 1;
769 }
770 totals.prompt_tokens += report.prompt_tokens;
771 totals.cached_input_tokens += report.cached_input_tokens;
772 totals.cache_creation_input_tokens += report.cache_creation_input_tokens;
773 totals.net_cached_input_tokens += net_cached_input_tokens;
774 totals.read_create_regressions += read_create_regressions;
775
776 cases.push(SessionCostPromptCacheEffectivenessCaseReport {
777 name: case.name.clone(),
778 source: report.source,
779 status,
780 prompt_tokens: report.prompt_tokens,
781 cached_input_tokens: report.cached_input_tokens,
782 cache_creation_input_tokens: report.cache_creation_input_tokens,
783 cached_input_ratio: report.cached_input_ratio,
784 minimum_cached_input_ratio: case.minimum_cached_input_ratio,
785 net_cached_input_tokens,
786 minimum_net_cached_input_tokens: case.minimum_net_cached_input_tokens,
787 read_create_regressions,
788 maximum_read_create_regressions: case.maximum_read_create_regressions,
789 regression_scenarios,
790 required_prefix_drift_fields: normalized_prompt_cache_scenarios(
791 &case.required_prefix_drift_fields,
792 ),
793 required_diagnostics: normalized_prompt_cache_scenarios(&case.required_diagnostics),
794 failures,
795 });
796 }
797
798 let covered_regression_scenarios = covered_regression_scenarios.into_iter().collect::<Vec<_>>();
799 let covered_set = covered_regression_scenarios
800 .iter()
801 .cloned()
802 .collect::<BTreeSet<_>>();
803 let missing_regression_scenarios = required_regression_scenarios
804 .iter()
805 .filter(|scenario| !covered_set.contains(*scenario))
806 .cloned()
807 .collect::<Vec<_>>();
808
809 Ok(SessionCostPromptCacheEffectivenessReport {
810 schema_version: fixture.schema_version,
811 pass: totals.failed == 0 && missing_regression_scenarios.is_empty(),
812 totals,
813 required_regression_scenarios,
814 covered_regression_scenarios,
815 missing_regression_scenarios,
816 cases,
817 })
818}
819
820fn normalized_prompt_cache_scenarios(values: &[String]) -> Vec<String> {
821 values
822 .iter()
823 .map(|value| value.trim())
824 .filter(|value| !value.is_empty())
825 .map(str::to_string)
826 .collect::<BTreeSet<_>>()
827 .into_iter()
828 .collect()
829}
830
831fn prompt_cache_required_prefix_drift_failures(
832 analytics: Option<&SessionCostPromptCacheAnalytics>,
833 required_fields: &[String],
834) -> Vec<String> {
835 let required_fields = normalized_prompt_cache_scenarios(required_fields);
836 if required_fields.is_empty() {
837 return Vec::new();
838 }
839 let observed_fields = analytics
840 .map(|analytics| {
841 analytics
842 .prefix_drift
843 .iter()
844 .flat_map(|drift| {
845 drift
846 .field_changes
847 .iter()
848 .map(|change| change.field.clone())
849 })
850 .collect::<BTreeSet<_>>()
851 })
852 .unwrap_or_default();
853 required_fields
854 .into_iter()
855 .filter(|field| !observed_fields.contains(field))
856 .map(|field| format!("missing required prompt-cache prefix drift field `{field}`"))
857 .collect()
858}
859
860fn prompt_cache_required_diagnostic_failures(
861 analytics: Option<&SessionCostPromptCacheAnalytics>,
862 required_kinds: &[String],
863) -> Vec<String> {
864 let required_kinds = normalized_prompt_cache_scenarios(required_kinds);
865 if required_kinds.is_empty() {
866 return Vec::new();
867 }
868 let observed_kinds = analytics
869 .map(|analytics| {
870 analytics
871 .diagnostics
872 .iter()
873 .map(|diagnostic| diagnostic.kind.clone())
874 .collect::<BTreeSet<_>>()
875 })
876 .unwrap_or_default();
877 required_kinds
878 .into_iter()
879 .filter(|kind| !observed_kinds.contains(kind))
880 .map(|kind| format!("missing required prompt-cache diagnostic `{kind}`"))
881 .collect()
882}
883
884fn prompt_cache_provider_adapter_failures(
885 case: &SessionCostPromptCacheEffectivenessCase,
886 plan: Option<&SessionCostPromptCachePlan>,
887) -> Vec<String> {
888 let mut failures = Vec::new();
889 let Some(plan) = plan else {
890 return failures;
891 };
892 let Ok(source) = SessionCostSource::parse(&case.source) else {
893 return failures;
894 };
895
896 match source {
897 SessionCostSource::ClaudeJsonl => require_prompt_cache_provider_adapter(
898 plan,
899 "anthropic",
900 &["cache_control"],
901 "Anthropic cache_control",
902 &mut failures,
903 ),
904 SessionCostSource::CodexJsonl => require_prompt_cache_provider_adapter(
905 plan,
906 "openai",
907 if case_has_regression_scenario(case, "openai_prompt_cache_key_churn") {
908 &["prompt_cache_key", "prompt_cache_key_churn"]
909 } else {
910 &["prompt_cache_key"]
911 },
912 "OpenAI prompt_cache_key",
913 &mut failures,
914 ),
915 SessionCostSource::AgentDocLog => {}
916 }
917 if matches!(
918 source,
919 SessionCostSource::ClaudeJsonl | SessionCostSource::CodexJsonl
920 ) {
921 require_prompt_cache_provider_adapter(
922 plan,
923 "replica_local",
924 if case_has_regression_scenario(case, "replica_routing_churn") {
925 &["routing_affinity", "routing_affinity_churn"]
926 } else {
927 &["routing_affinity"]
928 },
929 "replica-local routing_affinity",
930 &mut failures,
931 );
932 }
933
934 failures
935}
936
937fn require_prompt_cache_provider_adapter(
938 plan: &SessionCostPromptCachePlan,
939 provider: &str,
940 expected_statuses: &[&str],
941 label: &str,
942 failures: &mut Vec<String>,
943) {
944 match plan
945 .provider_adapters
946 .iter()
947 .find(|adapter| adapter.provider == provider)
948 {
949 Some(adapter) if expected_statuses.contains(&adapter.status.as_str()) => {}
950 Some(adapter) => failures.push(format!(
951 "{label} adapter status `{}`; expected one of {}",
952 adapter.status,
953 expected_statuses.join(", ")
954 )),
955 None => failures.push(format!("missing {label} adapter")),
956 }
957}
958
959fn case_has_regression_scenario(
960 case: &SessionCostPromptCacheEffectivenessCase,
961 scenario: &str,
962) -> bool {
963 case.regression_scenarios
964 .iter()
965 .any(|value| value.trim() == scenario)
966}
967
968pub fn derive_guardrails(input: &SessionCostGuardrailInput) -> Vec<SessionCostGuardrail> {
969 let mut guardrails = Vec::new();
970
971 if input.largest_prompt_turn_tokens >= PROMPT_BUDGET_WARN_TOKENS {
972 let label = input
973 .largest_prompt_turn_label
974 .as_deref()
975 .map(|label| format!(" at {label}"))
976 .unwrap_or_default();
977 guardrails.push(SessionCostGuardrail {
978 kind: "prompt_budget".to_string(),
979 severity: "warn".to_string(),
980 message: format!(
981 "largest prompt turn reached {} tokens{label}",
982 input.largest_prompt_turn_tokens
983 ),
984 guidance:
985 "compact the session or split the task before another large turn resends the same context"
986 .to_string(),
987 });
988 }
989
990 if input.prompt_tokens >= CACHED_RATIO_WARN_PROMPT_TOKENS
991 && input
992 .cached_input_ratio
993 .is_some_and(|ratio| ratio >= CACHED_RATIO_WARN_PERCENT)
994 {
995 guardrails.push(SessionCostGuardrail {
996 kind: "cache_resend".to_string(),
997 severity: "warn".to_string(),
998 message: format!(
999 "cached input ratio was {:.2}% across {} prompt tokens",
1000 input.cached_input_ratio.unwrap_or_default(),
1001 input.prompt_tokens
1002 ),
1003 guidance:
1004 "compact or restart the session when most prompt spend is cached context instead of new work"
1005 .to_string(),
1006 });
1007 }
1008
1009 let restart_signal_count = input.fresh_restart_occurrences
1010 + input.auto_trigger_timeout_occurrences
1011 + input.ctrl_d_restart_loop_occurrences;
1012 if restart_signal_count >= RESTART_LOOP_WARN_OCCURRENCES
1013 || input.ctrl_d_restart_loop_occurrences > 0
1014 || input.auto_trigger_timeout_occurrences > 0
1015 {
1016 let max_restart = input
1017 .max_restart_count
1018 .map(|count| format!(" max_restart={count}."))
1019 .unwrap_or_default();
1020 guardrails.push(SessionCostGuardrail {
1021 kind: "restart_loop".to_string(),
1022 severity: "warn".to_string(),
1023 message: format!(
1024 "restart churn detected: fresh_restart={} auto_trigger_timeout={} ctrl_d_restart_loop={}.{}",
1025 input.fresh_restart_occurrences,
1026 input.auto_trigger_timeout_occurrences,
1027 input.ctrl_d_restart_loop_occurrences,
1028 max_restart
1029 )
1030 .trim()
1031 .to_string(),
1032 guidance:
1033 "fix the startup/retry issue before another restart, or compact and reopen cleanly instead of looping"
1034 .to_string(),
1035 });
1036 }
1037
1038 if input.noop_closeout_occurrences >= NOOP_CLOSEOUT_WARN_OCCURRENCES {
1039 guardrails.push(SessionCostGuardrail {
1040 kind: "noop_closeout".to_string(),
1041 severity: "warn".to_string(),
1042 message: format!(
1043 "commit_already_current appeared {} times",
1044 input.noop_closeout_occurrences
1045 ),
1046 guidance:
1047 "compact the document or avoid reopening it without new edits when closeouts are mostly no-ops"
1048 .to_string(),
1049 });
1050 }
1051
1052 guardrails.truncate(MAX_GUARDRAILS);
1053 guardrails
1054}
1055
1056fn derive_prompt_cache_plan(
1057 source: SessionCostSource,
1058 prompt_tokens: u64,
1059 cached_input_tokens: u64,
1060 cache_creation_input_tokens: u64,
1061 cached_input_ratio: Option<f64>,
1062 usage_turns: &[SessionCostTurn],
1063) -> Option<SessionCostPromptCachePlan> {
1064 let usage_samples = usage_turns.len();
1065 if usage_samples == 0 {
1066 return None;
1067 }
1068
1069 let observed = cached_input_tokens > 0 || cache_creation_input_tokens > 0;
1070 let candidate = prompt_tokens >= PROMPT_CACHE_CANDIDATE_TOKENS;
1071 if !observed && !candidate {
1072 return None;
1073 }
1074
1075 let adapter_evidence = prompt_cache_adapter_evidence(usage_turns);
1076 let mut actions = Vec::new();
1077 if !observed {
1078 actions.push(SessionCostPromptCacheAction {
1079 kind: "enable_provider_cache".to_string(),
1080 severity: "recommend".to_string(),
1081 message: format!(
1082 "prompt volume reached {prompt_tokens} tokens without observed cache reads"
1083 ),
1084 guidance: "add a provider adapter that keeps stable context byte-identical and passes the provider cache hint on each turn"
1085 .to_string(),
1086 });
1087 } else if cached_input_ratio.is_some_and(|ratio| ratio < PROMPT_CACHE_GOOD_HIT_PERCENT) {
1088 actions.push(SessionCostPromptCacheAction {
1089 kind: "improve_cache_hit_rate".to_string(),
1090 severity: "recommend".to_string(),
1091 message: format!(
1092 "cached input ratio was {:.2}% across {prompt_tokens} prompt tokens",
1093 cached_input_ratio.unwrap_or_default()
1094 ),
1095 guidance:
1096 "move volatile timestamps, generated headers, and one-off compaction prompts after the cached prefix"
1097 .to_string(),
1098 });
1099 } else {
1100 actions.push(SessionCostPromptCacheAction {
1101 kind: "preserve_cache_shape".to_string(),
1102 severity: "info".to_string(),
1103 message: format!(
1104 "cache reads were observed across {cached_input_tokens} input tokens"
1105 ),
1106 guidance:
1107 "keep the stable prefix and append-only transcript shape intact while adding new tools or context"
1108 .to_string(),
1109 });
1110 }
1111
1112 if cache_creation_input_tokens > cached_input_tokens && cached_input_tokens > 0 {
1113 actions.push(SessionCostPromptCacheAction {
1114 kind: "reduce_cache_rewrites".to_string(),
1115 severity: "recommend".to_string(),
1116 message: format!(
1117 "cache creation tokens ({cache_creation_input_tokens}) exceeded cache read tokens ({cached_input_tokens})"
1118 ),
1119 guidance:
1120 "check for prefix churn before each model call; repeated writes can erase the economics of prompt caching"
1121 .to_string(),
1122 });
1123 }
1124 push_prompt_cache_adapter_actions(&adapter_evidence, &mut actions);
1125
1126 Some(SessionCostPromptCachePlan {
1127 status: if observed { "observed" } else { "candidate" }.to_string(),
1128 feasible: true,
1129 observed_cached_input_tokens: cached_input_tokens,
1130 observed_cache_creation_tokens: cache_creation_input_tokens,
1131 observed_cached_input_ratio: cached_input_ratio.map(|ratio| format!("{ratio:.2}%")),
1132 analytics: derive_prompt_cache_analytics(
1133 usage_turns,
1134 prompt_tokens,
1135 cached_input_tokens,
1136 cache_creation_input_tokens,
1137 cached_input_ratio,
1138 ),
1139 scorecard: derive_prompt_cache_roi_scorecard(
1140 usage_turns,
1141 default_prompt_cache_provider(source),
1142 PROMPT_CACHE_SCORECARD_DEFAULT_NEXT_COMMAND,
1143 ),
1144 invariants: vec![
1145 "place stable system/developer context before per-turn content".to_string(),
1146 "treat conversation history as append-only until an intentional compaction boundary"
1147 .to_string(),
1148 "exclude volatile timestamps, random ids, and transient instructions from the cached prefix"
1149 .to_string(),
1150 "run compaction against the same live prefix whenever the provider cache is still warm"
1151 .to_string(),
1152 ],
1153 provider_adapters: derive_prompt_cache_provider_adapters(&adapter_evidence),
1154 actions,
1155 })
1156}
1157
1158fn derive_prompt_cache_provider_adapters(
1159 evidence: &PromptCacheAdapterEvidence,
1160) -> Vec<SessionCostPromptCacheProvider> {
1161 vec![
1162 SessionCostPromptCacheProvider {
1163 provider: "anthropic".to_string(),
1164 status: anthropic_cache_control_status(evidence).to_string(),
1165 requirements: vec![
1166 "attach cache_control to the stable system block".to_string(),
1167 "attach cache_control to the final tool definition when tools are sent".to_string(),
1168 "attach cache_control to the last two user-role messages; skip one-off compaction instructions"
1169 .to_string(),
1170 ],
1171 },
1172 SessionCostPromptCacheProvider {
1173 provider: "openai".to_string(),
1174 status: openai_prompt_cache_key_status(evidence).to_string(),
1175 requirements: vec![
1176 "derive prompt_cache_key from the stable thread/session id".to_string(),
1177 "keep prefixes byte-identical across consecutive calls for the same key".to_string(),
1178 ],
1179 },
1180 SessionCostPromptCacheProvider {
1181 provider: "replica_local".to_string(),
1182 status: replica_local_routing_affinity_status(evidence).to_string(),
1183 requirements: vec![
1184 "route consecutive calls for the same cache key to the same replica when the provider cache is replica-local"
1185 .to_string(),
1186 ],
1187 },
1188 ]
1189}
1190
1191fn prompt_cache_adapter_evidence(usage_turns: &[SessionCostTurn]) -> PromptCacheAdapterEvidence {
1192 let mut evidence = PromptCacheAdapterEvidence::default();
1193 for metadata in usage_turns
1194 .iter()
1195 .filter_map(|turn| turn.prompt_cache_metadata.as_ref())
1196 {
1197 let anthropic = is_anthropic_provider(&metadata.provider);
1198 let openai = is_openai_provider(&metadata.provider);
1199 if anthropic {
1200 evidence.anthropic_samples += 1;
1201 if metadata_has_cache_control_breakpoint(metadata) {
1202 evidence.anthropic_cache_control_samples += 1;
1203 }
1204 }
1205 if openai {
1206 evidence.openai_samples += 1;
1207 if let Some(cache_key) = metadata.cache_key.as_ref() {
1208 evidence.openai_prompt_cache_key_samples += 1;
1209 evidence.openai_prompt_cache_keys.insert(cache_key.clone());
1210 }
1211 }
1212 if anthropic || openai {
1213 evidence.routed_provider_samples += 1;
1214 if let Some(routing_affinity) = metadata.routing_affinity.as_ref() {
1215 evidence.routing_affinity_samples += 1;
1216 evidence
1217 .routing_affinity_values
1218 .insert(routing_affinity.clone());
1219 }
1220 }
1221 }
1222 evidence
1223}
1224
1225fn anthropic_cache_control_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1226 if evidence.anthropic_samples == 0 {
1227 "not_observed"
1228 } else if evidence.anthropic_cache_control_samples == evidence.anthropic_samples {
1229 "cache_control"
1230 } else if evidence.anthropic_cache_control_samples > 0 {
1231 "partial_cache_control"
1232 } else {
1233 "missing_cache_control"
1234 }
1235}
1236
1237fn openai_prompt_cache_key_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1238 if evidence.openai_samples == 0 {
1239 "not_observed"
1240 } else if evidence.openai_prompt_cache_key_samples < evidence.openai_samples {
1241 if evidence.openai_prompt_cache_key_samples == 0 {
1242 "missing_prompt_cache_key"
1243 } else {
1244 "partial_prompt_cache_key"
1245 }
1246 } else if evidence.openai_prompt_cache_keys.len() > 1 {
1247 "prompt_cache_key_churn"
1248 } else {
1249 "prompt_cache_key"
1250 }
1251}
1252
1253fn replica_local_routing_affinity_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1254 if evidence.routed_provider_samples == 0 {
1255 "not_observed"
1256 } else if evidence.routing_affinity_samples < evidence.routed_provider_samples {
1257 if evidence.routing_affinity_samples == 0 {
1258 "missing_routing_affinity"
1259 } else {
1260 "partial_routing_affinity"
1261 }
1262 } else if evidence.routing_affinity_values.len() > 1 {
1263 "routing_affinity_churn"
1264 } else {
1265 "routing_affinity"
1266 }
1267}
1268
1269fn push_prompt_cache_adapter_actions(
1270 evidence: &PromptCacheAdapterEvidence,
1271 actions: &mut Vec<SessionCostPromptCacheAction>,
1272) {
1273 match anthropic_cache_control_status(evidence) {
1274 "missing_cache_control" | "partial_cache_control" => {
1275 actions.push(SessionCostPromptCacheAction {
1276 kind: "fix_anthropic_cache_control".to_string(),
1277 severity: "recommend".to_string(),
1278 message: "Anthropic prompt-cache calls are missing cache_control breakpoints"
1279 .to_string(),
1280 guidance: "attach cache_control to the stable Anthropic system/tool/user blocks that should be cached"
1281 .to_string(),
1282 });
1283 }
1284 _ => {}
1285 }
1286 match openai_prompt_cache_key_status(evidence) {
1287 "missing_prompt_cache_key" | "partial_prompt_cache_key" | "prompt_cache_key_churn" => {
1288 actions.push(SessionCostPromptCacheAction {
1289 kind: "fix_openai_prompt_cache_key".to_string(),
1290 severity: "recommend".to_string(),
1291 message: "OpenAI prompt-cache calls need a stable prompt_cache_key".to_string(),
1292 guidance: "derive prompt_cache_key from the stable session/thread id and keep it unchanged across warm-prefix calls"
1293 .to_string(),
1294 });
1295 }
1296 _ => {}
1297 }
1298 match replica_local_routing_affinity_status(evidence) {
1299 "missing_routing_affinity" | "partial_routing_affinity" | "routing_affinity_churn" => {
1300 actions.push(SessionCostPromptCacheAction {
1301 kind: "fix_replica_routing_affinity".to_string(),
1302 severity: "recommend".to_string(),
1303 message: "prompt-cache calls need stable replica-local routing affinity"
1304 .to_string(),
1305 guidance: "route consecutive calls for the same cache key to the same provider replica or deployment"
1306 .to_string(),
1307 });
1308 }
1309 _ => {}
1310 }
1311}
1312
1313fn derive_prompt_cache_analytics(
1314 usage_turns: &[SessionCostTurn],
1315 prompt_tokens: u64,
1316 cached_input_tokens: u64,
1317 cache_creation_input_tokens: u64,
1318 cached_input_ratio: Option<f64>,
1319) -> Option<SessionCostPromptCacheAnalytics> {
1320 if usage_turns.is_empty() {
1321 return None;
1322 }
1323
1324 let first_ratio = usage_turns
1325 .first()
1326 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1327 let last_ratio = usage_turns
1328 .last()
1329 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1330 let ratio_delta = first_ratio
1331 .zip(last_ratio)
1332 .map(|(first, last)| last - first);
1333 let trend = prompt_cache_trend(usage_turns.len(), ratio_delta).to_string();
1334 let effective = cached_input_ratio.is_some_and(|ratio| ratio >= PROMPT_CACHE_GOOD_HIT_PERCENT)
1335 && cached_input_tokens >= cache_creation_input_tokens;
1336 let cache_read_to_creation_ratio = (cache_creation_input_tokens > 0).then(|| {
1337 format!(
1338 "{:.2}x",
1339 (cached_input_tokens as f64) / (cache_creation_input_tokens as f64)
1340 )
1341 });
1342 let timeline = prompt_cache_timeline(usage_turns);
1343 let (prefix_drift, prefix_drift_truncated) = derive_prompt_cache_prefix_drift(usage_turns);
1344 let diagnostics = derive_prompt_cache_diagnostics(
1345 usage_turns,
1346 cached_input_tokens,
1347 cache_creation_input_tokens,
1348 );
1349
1350 Some(SessionCostPromptCacheAnalytics {
1351 sample_count: usage_turns.len(),
1352 effective,
1353 trend,
1354 total_prompt_tokens: prompt_tokens,
1355 total_cached_input_tokens: cached_input_tokens,
1356 total_cache_creation_tokens: cache_creation_input_tokens,
1357 net_cached_input_tokens: signed_token_delta(
1358 cached_input_tokens,
1359 cache_creation_input_tokens,
1360 ),
1361 timeline_truncated: usage_turns.len() > MAX_PROMPT_CACHE_TIMELINE,
1362 average_cached_input_ratio: cached_input_ratio.map(format_percent),
1363 first_cached_input_ratio: first_ratio.map(format_percent),
1364 last_cached_input_ratio: last_ratio.map(format_percent),
1365 cached_input_ratio_delta: ratio_delta.map(format_signed_percent),
1366 cache_read_to_creation_ratio,
1367 diagnostics,
1368 prefix_drift_truncated,
1369 prefix_drift,
1370 timeline,
1371 })
1372}
1373
1374fn derive_prompt_cache_roi_scorecard(
1375 usage_turns: &[SessionCostTurn],
1376 fallback_provider: &str,
1377 next_command: &str,
1378) -> Vec<SessionCostPromptCacheRoiScorecard> {
1379 let mut by_provider = BTreeMap::<String, Vec<SessionCostTurn>>::new();
1380 for turn in usage_turns {
1381 let provider = turn
1382 .prompt_cache_metadata
1383 .as_ref()
1384 .map(|metadata| metadata.provider.trim())
1385 .filter(|provider| !provider.is_empty())
1386 .unwrap_or(fallback_provider)
1387 .to_ascii_lowercase();
1388 by_provider.entry(provider).or_default().push(turn.clone());
1389 }
1390
1391 let mut rows = by_provider
1392 .into_iter()
1393 .map(|(provider, turns)| prompt_cache_roi_scorecard_row(provider, &turns, next_command))
1394 .collect::<Vec<_>>();
1395 rows.sort_by(|left, right| {
1396 right
1397 .net_cached_read_tokens
1398 .cmp(&left.net_cached_read_tokens)
1399 .then(left.provider.cmp(&right.provider))
1400 });
1401 rows.truncate(MAX_PROMPT_CACHE_SCORECARD);
1402 rows
1403}
1404
1405fn prompt_cache_roi_scorecard_row(
1406 provider: String,
1407 turns: &[SessionCostTurn],
1408 next_command: &str,
1409) -> SessionCostPromptCacheRoiScorecard {
1410 let prompt_tokens = turns.iter().map(|turn| turn.prompt_tokens).sum::<u64>();
1411 let cached_input_tokens = turns
1412 .iter()
1413 .map(|turn| turn.cached_input_tokens)
1414 .sum::<u64>();
1415 let cache_creation_input_tokens = turns
1416 .iter()
1417 .map(|turn| turn.cache_creation_input_tokens)
1418 .sum::<u64>();
1419 let first_ratio = turns
1420 .first()
1421 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1422 let last_ratio = turns
1423 .last()
1424 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1425 let ratio_delta = first_ratio
1426 .zip(last_ratio)
1427 .map(|(first, last)| last - first);
1428 let diagnostics =
1429 derive_prompt_cache_diagnostics(turns, cached_input_tokens, cache_creation_input_tokens);
1430 let (prefix_drift, _) = derive_prompt_cache_prefix_drift(turns);
1431 let adapter_evidence = prompt_cache_adapter_evidence(turns);
1432
1433 SessionCostPromptCacheRoiScorecard {
1434 session_source: None,
1435 session_path: None,
1436 provider: provider.clone(),
1437 sample_count: turns.len(),
1438 net_cached_read_tokens: signed_token_delta(
1439 cached_input_tokens,
1440 cache_creation_input_tokens,
1441 ),
1442 read_create_ratio: prompt_cache_read_create_ratio(
1443 cached_input_tokens,
1444 cache_creation_input_tokens,
1445 ),
1446 trend: prompt_cache_trend(turns.len(), ratio_delta).to_string(),
1447 suspected_invalidation_cause: prompt_cache_scorecard_cause(
1448 &provider,
1449 &diagnostics,
1450 &prefix_drift,
1451 &adapter_evidence,
1452 prompt_tokens,
1453 cached_input_tokens,
1454 cache_creation_input_tokens,
1455 ),
1456 next_command: next_command.to_string(),
1457 }
1458}
1459
1460fn prompt_cache_read_create_ratio(
1461 cached_input_tokens: u64,
1462 cache_creation_input_tokens: u64,
1463) -> String {
1464 if cache_creation_input_tokens > 0 {
1465 format!(
1466 "{:.2}x",
1467 (cached_input_tokens as f64) / (cache_creation_input_tokens as f64)
1468 )
1469 } else if cached_input_tokens > 0 {
1470 "read_only".to_string()
1471 } else {
1472 "-".to_string()
1473 }
1474}
1475
1476fn prompt_cache_scorecard_cause(
1477 provider: &str,
1478 diagnostics: &[SessionCostPromptCacheDiagnostic],
1479 prefix_drift: &[SessionCostPromptCachePrefixDrift],
1480 adapter_evidence: &PromptCacheAdapterEvidence,
1481 prompt_tokens: u64,
1482 cached_input_tokens: u64,
1483 cache_creation_input_tokens: u64,
1484) -> String {
1485 if let Some(diagnostic) = diagnostics.first() {
1486 return diagnostic
1487 .likely_causes
1488 .first()
1489 .cloned()
1490 .unwrap_or_else(|| diagnostic.kind.clone());
1491 }
1492 if let Some(drift) = prefix_drift
1493 .iter()
1494 .find(|drift| drift.severity == "warn")
1495 .or_else(|| prefix_drift.first())
1496 {
1497 return format!("{} changed ({})", drift.first_changed_field, drift.trigger);
1498 }
1499 if let Some(adapter_cause) = prompt_cache_adapter_scorecard_cause(provider, adapter_evidence) {
1500 return adapter_cause;
1501 }
1502 if cached_input_tokens == 0 && prompt_tokens >= PROMPT_CACHE_CANDIDATE_TOKENS {
1503 return "no provider cache reads observed".to_string();
1504 }
1505 if cache_creation_input_tokens > cached_input_tokens {
1506 return "cache creation exceeded cache reads".to_string();
1507 }
1508 "none observed".to_string()
1509}
1510
1511fn prompt_cache_adapter_scorecard_cause(
1512 provider: &str,
1513 evidence: &PromptCacheAdapterEvidence,
1514) -> Option<String> {
1515 if is_anthropic_provider(provider) {
1516 match anthropic_cache_control_status(evidence) {
1517 "missing_cache_control" => {
1518 return Some("missing Anthropic cache_control breakpoints".to_string());
1519 }
1520 "partial_cache_control" => {
1521 return Some("partial Anthropic cache_control breakpoint coverage".to_string());
1522 }
1523 _ => {}
1524 }
1525 }
1526 if is_openai_provider(provider) {
1527 match openai_prompt_cache_key_status(evidence) {
1528 "missing_prompt_cache_key" => {
1529 return Some("missing OpenAI prompt_cache_key".to_string());
1530 }
1531 "partial_prompt_cache_key" => {
1532 return Some("partial OpenAI prompt_cache_key coverage".to_string());
1533 }
1534 "prompt_cache_key_churn" => {
1535 return Some("OpenAI prompt_cache_key changed between calls".to_string());
1536 }
1537 _ => {}
1538 }
1539 }
1540 if is_anthropic_provider(provider) || is_openai_provider(provider) {
1541 match replica_local_routing_affinity_status(evidence) {
1542 "missing_routing_affinity" => {
1543 return Some("missing replica-local routing affinity".to_string());
1544 }
1545 "partial_routing_affinity" => {
1546 return Some("partial replica-local routing affinity coverage".to_string());
1547 }
1548 "routing_affinity_churn" => {
1549 return Some("replica-local routing affinity changed between calls".to_string());
1550 }
1551 _ => {}
1552 }
1553 }
1554 None
1555}
1556
1557fn derive_prompt_cache_diagnostics(
1558 usage_turns: &[SessionCostTurn],
1559 cached_input_tokens: u64,
1560 cache_creation_input_tokens: u64,
1561) -> Vec<SessionCostPromptCacheDiagnostic> {
1562 let mut diagnostics = Vec::new();
1563
1564 for pair in usage_turns.windows(2) {
1565 let previous = &pair[0];
1566 let current = &pair[1];
1567 let Some(previous_ratio) =
1568 percent_ratio(previous.cached_input_tokens, previous.prompt_tokens)
1569 else {
1570 continue;
1571 };
1572 let Some(current_ratio) = percent_ratio(current.cached_input_tokens, current.prompt_tokens)
1573 else {
1574 continue;
1575 };
1576 let drop = previous_ratio - current_ratio;
1577 if drop >= PROMPT_CACHE_RATIO_DROP_WARN_PERCENT {
1578 let first_changed_field = prompt_cache_first_changed_field(previous, current);
1579 let drift_suffix = first_changed_field
1580 .as_ref()
1581 .map(|change| format!("; first changed prompt-cache field: {}", change.field))
1582 .unwrap_or_else(|| {
1583 "; no prompt-cache metadata field changed between adjacent turns".to_string()
1584 });
1585 let mut likely_causes = vec![
1586 "stable prefix bytes changed before the cache boundary".to_string(),
1587 "prompt_cache_key or thread/session id changed".to_string(),
1588 "replica-local cache affinity was lost".to_string(),
1589 ];
1590 if let Some(change) = first_changed_field {
1591 likely_causes.insert(
1592 0,
1593 format!(
1594 "first changed prompt-cache field: {} ({} -> {})",
1595 change.field, change.previous, change.current
1596 ),
1597 );
1598 }
1599 diagnostics.push(SessionCostPromptCacheDiagnostic {
1600 kind: "cached_ratio_drop".to_string(),
1601 severity: "warn".to_string(),
1602 label: current.label.clone(),
1603 message: format!(
1604 "cached input ratio dropped from {} to {} at {}{}",
1605 format_percent(previous_ratio),
1606 format_percent(current_ratio),
1607 current.label,
1608 drift_suffix
1609 ),
1610 likely_causes,
1611 guidance:
1612 "compare the prefix, tool set, cache key, compaction boundary, and routing between the previous turn and this turn"
1613 .to_string(),
1614 });
1615 }
1616 }
1617
1618 for (index, turn) in usage_turns.iter().enumerate() {
1619 let Some(creation_ratio) =
1620 percent_ratio(turn.cache_creation_input_tokens, turn.prompt_tokens)
1621 else {
1622 continue;
1623 };
1624 if turn.cache_creation_input_tokens > 0
1625 && creation_ratio >= PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT
1626 {
1627 let first_changed_field = index
1628 .checked_sub(1)
1629 .and_then(|previous_index| usage_turns.get(previous_index))
1630 .and_then(|previous| prompt_cache_first_changed_field(previous, turn));
1631 let drift_suffix = first_changed_field
1632 .as_ref()
1633 .map(|change| format!("; first changed prompt-cache field: {}", change.field))
1634 .unwrap_or_else(|| {
1635 "; no adjacent prompt-cache metadata drift was detected".to_string()
1636 });
1637 let mut likely_causes = vec![
1638 "provider created a fresh cached prefix instead of reusing the warm prefix"
1639 .to_string(),
1640 "system, developer, or tool block changed before the cache boundary".to_string(),
1641 "compaction or transient instructions entered the cached prefix".to_string(),
1642 ];
1643 if let Some(change) = first_changed_field {
1644 likely_causes.insert(
1645 0,
1646 format!(
1647 "first changed prompt-cache field: {} ({} -> {})",
1648 change.field, change.previous, change.current
1649 ),
1650 );
1651 }
1652 diagnostics.push(SessionCostPromptCacheDiagnostic {
1653 kind: "cache_creation_spike".to_string(),
1654 severity: "warn".to_string(),
1655 label: turn.label.clone(),
1656 message: format!(
1657 "cache creation was {} of prompt tokens at {}{}",
1658 format_percent(creation_ratio),
1659 turn.label,
1660 drift_suffix
1661 ),
1662 likely_causes,
1663 guidance:
1664 "inspect the cached prefix and provider breakpoint placement for this turn before treating the cache as effective"
1665 .to_string(),
1666 });
1667 }
1668 }
1669
1670 let read_create_regression = prompt_cache_read_create_regression(
1672 cached_input_tokens,
1673 cache_creation_input_tokens,
1674 )
1675 .map(|read_to_creation| SessionCostPromptCacheDiagnostic {
1676 kind: "read_create_regression".to_string(),
1677 severity: "recommend".to_string(),
1678 label: "session".to_string(),
1679 message: format!(
1680 "cache read/create ratio was {read_to_creation:.2}x ({cached_input_tokens} read tokens, {cache_creation_input_tokens} creation tokens)"
1681 ),
1682 likely_causes: vec![
1683 "cached prefix is being rewritten too often for warm reuse".to_string(),
1684 "volatile values are inside the cached prefix".to_string(),
1685 "cache key or replica routing is changing between turns".to_string(),
1686 ],
1687 guidance:
1688 "stabilize the prefix/key/routing path until cache reads clearly exceed creation work"
1689 .to_string(),
1690 });
1691
1692 let per_turn_cap = if read_create_regression.is_some() {
1697 MAX_PROMPT_CACHE_DIAGNOSTICS.saturating_sub(1)
1698 } else {
1699 MAX_PROMPT_CACHE_DIAGNOSTICS
1700 };
1701 diagnostics.truncate(per_turn_cap);
1702 if let Some(regression) = read_create_regression {
1703 diagnostics.push(regression);
1704 }
1705 diagnostics
1706}
1707
1708pub(crate) fn prompt_cache_read_create_regression(
1713 cached_input_tokens: u64,
1714 cache_creation_input_tokens: u64,
1715) -> Option<f64> {
1716 if cache_creation_input_tokens == 0 {
1717 return None;
1718 }
1719 let read_to_creation = (cached_input_tokens as f64) / (cache_creation_input_tokens as f64);
1720 (read_to_creation < PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO).then_some(read_to_creation)
1721}
1722
1723fn derive_prompt_cache_prefix_drift(
1724 usage_turns: &[SessionCostTurn],
1725) -> (Vec<SessionCostPromptCachePrefixDrift>, bool) {
1726 let mut drift = Vec::new();
1727
1728 for pair in usage_turns.windows(2) {
1729 let previous = &pair[0];
1730 let current = &pair[1];
1731 let field_changes = prompt_cache_field_changes(previous, current);
1732 let Some(first_changed_field) = field_changes.first().map(|change| change.field.clone())
1733 else {
1734 continue;
1735 };
1736
1737 let ratio_drop = prompt_cache_ratio_drop_triggered(previous, current);
1738 let creation_spike = prompt_cache_creation_spike_triggered(current);
1739 let trigger = match (ratio_drop, creation_spike) {
1740 (true, true) => "cached_ratio_drop_and_cache_creation_spike",
1741 (true, false) => "cached_ratio_drop",
1742 (false, true) => "cache_creation_spike",
1743 (false, false) => "metadata_drift",
1744 };
1745
1746 drift.push(SessionCostPromptCachePrefixDrift {
1747 previous_label: previous.label.clone(),
1748 current_label: current.label.clone(),
1749 trigger: trigger.to_string(),
1750 severity: if ratio_drop || creation_spike {
1751 "warn".to_string()
1752 } else {
1753 "info".to_string()
1754 },
1755 remediation: prompt_cache_prefix_drift_remediation(&first_changed_field),
1756 first_changed_field,
1757 field_changes,
1758 cached_input_ratio_before: percent_ratio(
1759 previous.cached_input_tokens,
1760 previous.prompt_tokens,
1761 )
1762 .map(format_percent),
1763 cached_input_ratio_after: percent_ratio(
1764 current.cached_input_tokens,
1765 current.prompt_tokens,
1766 )
1767 .map(format_percent),
1768 cache_creation_ratio: percent_ratio(
1769 current.cache_creation_input_tokens,
1770 current.prompt_tokens,
1771 )
1772 .map(format_percent),
1773 });
1774 }
1775
1776 let truncated = drift.len() > MAX_PROMPT_CACHE_PREFIX_DRIFT;
1777 drift.truncate(MAX_PROMPT_CACHE_PREFIX_DRIFT);
1778 (drift, truncated)
1779}
1780
1781fn prompt_cache_prefix_drift_remediation(first_changed_field: &str) -> String {
1787 match first_changed_field {
1788 "cache_key" => "stabilize the prompt_cache_key: reuse the same thread/session id across turns so the provider serves the warm cached prefix instead of creating a cold one",
1789 "breakpoints" => "keep cache breakpoint placement constant: move the volatile block below the cache breakpoint so the stable prefix above it stays byte-identical and cached",
1790 "routing_affinity" => "pin routing/replica affinity: route every turn of the session to the same cache replica so the warm prefix is not lost to a cold replica",
1791 "provider" => "avoid switching providers mid-session: a provider change discards the warm cached prefix, so keep the session on one provider",
1792 "stable_prefix" => "stabilize the prefix bytes: move the volatile content that changed out of the stable prefix and below the cache breakpoint so the cached prefix stays byte-identical",
1793 "stable_prefix_fingerprint" => "the provider rotated the stable-prefix fingerprint: audit the cached prefix material and move any volatile content below the cache breakpoint so the fingerprint stays stable",
1794 _ => "stabilize the changed input or move the volatile field below the cache breakpoint so the cached prefix is reused",
1795 }
1796 .to_string()
1797}
1798
1799fn prompt_cache_ratio_drop_triggered(
1800 previous: &SessionCostTurn,
1801 current: &SessionCostTurn,
1802) -> bool {
1803 let Some(previous_ratio) = percent_ratio(previous.cached_input_tokens, previous.prompt_tokens)
1804 else {
1805 return false;
1806 };
1807 let Some(current_ratio) = percent_ratio(current.cached_input_tokens, current.prompt_tokens)
1808 else {
1809 return false;
1810 };
1811 previous_ratio - current_ratio >= PROMPT_CACHE_RATIO_DROP_WARN_PERCENT
1812}
1813
1814fn prompt_cache_creation_spike_triggered(turn: &SessionCostTurn) -> bool {
1815 turn.cache_creation_input_tokens > 0
1816 && percent_ratio(turn.cache_creation_input_tokens, turn.prompt_tokens)
1817 .is_some_and(|ratio| ratio >= PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT)
1818}
1819
1820fn prompt_cache_first_changed_field(
1821 previous: &SessionCostTurn,
1822 current: &SessionCostTurn,
1823) -> Option<SessionCostPromptCacheFieldChange> {
1824 prompt_cache_field_changes(previous, current)
1825 .into_iter()
1826 .next()
1827}
1828
1829fn prompt_cache_field_changes(
1830 previous: &SessionCostTurn,
1831 current: &SessionCostTurn,
1832) -> Vec<SessionCostPromptCacheFieldChange> {
1833 let Some(previous) = previous.prompt_cache_metadata.as_ref() else {
1834 return Vec::new();
1835 };
1836 let Some(current) = current.prompt_cache_metadata.as_ref() else {
1837 return Vec::new();
1838 };
1839
1840 let mut changes = Vec::new();
1850 push_prompt_cache_field_change(
1851 &mut changes,
1852 "cache_key",
1853 &prompt_cache_optional_value(previous.cache_key.as_deref()),
1854 &prompt_cache_optional_value(current.cache_key.as_deref()),
1855 );
1856 push_prompt_cache_field_change(
1857 &mut changes,
1858 "breakpoints",
1859 &prompt_cache_breakpoint_value(&previous.breakpoints),
1860 &prompt_cache_breakpoint_value(¤t.breakpoints),
1861 );
1862 push_prompt_cache_field_change(
1863 &mut changes,
1864 "routing_affinity",
1865 &prompt_cache_optional_value(previous.routing_affinity.as_deref()),
1866 &prompt_cache_optional_value(current.routing_affinity.as_deref()),
1867 );
1868 push_prompt_cache_field_change(
1869 &mut changes,
1870 "provider",
1871 &previous.provider,
1872 ¤t.provider,
1873 );
1874 push_prompt_cache_field_change(
1883 &mut changes,
1884 "stable_prefix",
1885 &prompt_cache_optional_value(previous.stable_prefix.as_deref()),
1886 &prompt_cache_optional_value(current.stable_prefix.as_deref()),
1887 );
1888 if current.stable_prefix_fingerprint_explicit {
1895 push_prompt_cache_field_change(
1896 &mut changes,
1897 "stable_prefix_fingerprint",
1898 &previous.stable_prefix_fingerprint,
1899 ¤t.stable_prefix_fingerprint,
1900 );
1901 }
1902 changes
1903}
1904
1905fn push_prompt_cache_field_change(
1906 changes: &mut Vec<SessionCostPromptCacheFieldChange>,
1907 field: &str,
1908 previous: &str,
1909 current: &str,
1910) {
1911 if previous != current {
1912 changes.push(SessionCostPromptCacheFieldChange {
1913 field: field.to_string(),
1914 previous: previous.to_string(),
1915 current: current.to_string(),
1916 });
1917 }
1918}
1919
1920fn prompt_cache_optional_value(value: Option<&str>) -> String {
1921 value
1922 .filter(|value| !value.trim().is_empty())
1923 .unwrap_or("-")
1924 .to_string()
1925}
1926
1927fn prompt_cache_breakpoint_value(breakpoints: &[String]) -> String {
1928 if breakpoints.is_empty() {
1929 "-".to_string()
1930 } else {
1931 breakpoints.join("; ")
1932 }
1933}
1934
1935fn prompt_cache_timeline(
1936 usage_turns: &[SessionCostTurn],
1937) -> Vec<SessionCostPromptCacheTimelineEntry> {
1938 let selected = if usage_turns.len() <= MAX_PROMPT_CACHE_TIMELINE {
1939 usage_turns.iter().collect::<Vec<_>>()
1940 } else {
1941 let tail_count = MAX_PROMPT_CACHE_TIMELINE.saturating_sub(1);
1942 let mut selected = Vec::with_capacity(MAX_PROMPT_CACHE_TIMELINE);
1943 if let Some(first) = usage_turns.first() {
1944 selected.push(first);
1945 }
1946 selected.extend(usage_turns.iter().skip(usage_turns.len() - tail_count));
1947 selected
1948 };
1949
1950 selected
1951 .into_iter()
1952 .map(|turn| SessionCostPromptCacheTimelineEntry {
1953 label: turn.label.clone(),
1954 prompt_tokens: turn.prompt_tokens,
1955 cached_input_tokens: turn.cached_input_tokens,
1956 cache_creation_input_tokens: turn.cache_creation_input_tokens,
1957 cached_input_ratio: percent_ratio(turn.cached_input_tokens, turn.prompt_tokens)
1958 .map(format_percent),
1959 cache_creation_ratio: percent_ratio(
1960 turn.cache_creation_input_tokens,
1961 turn.prompt_tokens,
1962 )
1963 .map(format_percent),
1964 prompt_cache_metadata: turn.prompt_cache_metadata.clone(),
1965 })
1966 .collect()
1967}
1968
1969fn prompt_cache_trend(sample_count: usize, ratio_delta: Option<f64>) -> &'static str {
1970 if sample_count < 2 {
1971 return "single_sample";
1972 }
1973 let Some(delta) = ratio_delta else {
1974 return "insufficient_data";
1975 };
1976 if delta >= PROMPT_CACHE_TREND_DELTA_PERCENT {
1977 "improving"
1978 } else if delta <= -PROMPT_CACHE_TREND_DELTA_PERCENT {
1979 "declining"
1980 } else {
1981 "stable"
1982 }
1983}
1984
1985fn percent_ratio(numerator: u64, denominator: u64) -> Option<f64> {
1986 (denominator > 0)
1987 .then_some(((numerator as f64) / (denominator as f64) * 10_000.0).round() / 100.0)
1988}
1989
1990fn format_percent(value: f64) -> String {
1991 format!("{value:.2}%")
1992}
1993
1994fn format_signed_percent(value: f64) -> String {
1995 format!("{value:+.2}%")
1996}
1997
1998pub(crate) fn signed_token_delta(read_tokens: u64, creation_tokens: u64) -> i64 {
1999 if read_tokens >= creation_tokens {
2000 i64::try_from(read_tokens - creation_tokens).unwrap_or(i64::MAX)
2001 } else {
2002 -i64::try_from(creation_tokens - read_tokens).unwrap_or(i64::MAX)
2003 }
2004}
2005
2006fn resolve_source(input: &str, source_hint: Option<&str>) -> Result<SessionCostSource> {
2007 if let Some(raw) = source_hint {
2008 return SessionCostSource::parse(raw);
2009 }
2010
2011 let non_empty = input
2012 .lines()
2013 .map(str::trim)
2014 .filter(|line| !line.is_empty())
2015 .collect::<Vec<_>>();
2016 if non_empty.is_empty() {
2017 bail!(
2018 "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
2019 );
2020 }
2021
2022 if non_empty
2023 .iter()
2024 .all(|line| line.starts_with('{') && serde_json::from_str::<Value>(line).is_ok())
2025 {
2026 for line in &non_empty {
2027 let value = serde_json::from_str::<Value>(line).unwrap_or(Value::Null);
2028 if value
2029 .get("message")
2030 .and_then(|message| message.get("usage"))
2031 .is_some()
2032 {
2033 return Ok(SessionCostSource::ClaudeJsonl);
2034 }
2035 if value.get("type").and_then(Value::as_str) == Some("event_msg")
2036 && value
2037 .get("payload")
2038 .and_then(|payload| payload.get("type"))
2039 .and_then(Value::as_str)
2040 == Some("token_count")
2041 {
2042 return Ok(SessionCostSource::CodexJsonl);
2043 }
2044 }
2045 if non_empty.iter().any(|line| line.contains("\"parentUuid\"")) {
2046 return Ok(SessionCostSource::ClaudeJsonl);
2047 }
2048 if non_empty
2049 .iter()
2050 .any(|line| line.contains("\"response_item\"") || line.contains("\"turn_context\""))
2051 {
2052 return Ok(SessionCostSource::CodexJsonl);
2053 }
2054 }
2055
2056 if non_empty
2057 .iter()
2058 .all(|line| line.starts_with('[') && line.contains(']'))
2059 {
2060 return Ok(SessionCostSource::AgentDocLog);
2061 }
2062
2063 bail!(
2064 "could not auto-detect session-cost input; pass --source claude-jsonl, codex-jsonl, or agent-doc-log"
2065 )
2066}
2067
2068fn ingest_claude_jsonl(input: &str, state: &mut CostState) -> Result<()> {
2069 let mut seen_keys = BTreeSet::new();
2070 for (index, raw_line) in input.lines().enumerate() {
2071 let trimmed = raw_line.trim();
2072 if trimmed.is_empty() {
2073 continue;
2074 }
2075 let value = match serde_json::from_str::<Value>(trimmed) {
2076 Ok(value) => value,
2077 Err(_) => {
2078 state.warnings.push(format!(
2079 "skipping malformed Claude transcript jsonl line {}",
2080 index + 1
2081 ));
2082 continue;
2083 }
2084 };
2085 let Some(message) = value.get("message") else {
2086 collect_claude_loop_signals(&value, state);
2087 continue;
2088 };
2089 collect_claude_loop_signals(&value, state);
2090 if message.get("role").and_then(Value::as_str) != Some("assistant") {
2091 continue;
2092 }
2093 let Some(usage) = message.get("usage") else {
2094 continue;
2095 };
2096
2097 let key = message
2098 .get("id")
2099 .and_then(Value::as_str)
2100 .or_else(|| value.get("requestId").and_then(Value::as_str))
2101 .or_else(|| value.get("uuid").and_then(Value::as_str))
2102 .map(|value| value.to_string())
2103 .unwrap_or_else(|| format!("line-{}", index + 1));
2104 if !seen_keys.insert(key.clone()) {
2105 continue;
2106 }
2107
2108 let prompt_tokens = usage_u64(usage, "input_tokens")
2109 + usage_u64(usage, "cache_creation_input_tokens")
2110 + usage_u64(usage, "cache_read_input_tokens");
2111 let cached_input_tokens = usage_u64(usage, "cache_read_input_tokens");
2112 let cache_creation_input_tokens = usage_u64(usage, "cache_creation_input_tokens");
2113 let output_tokens = usage_u64(usage, "output_tokens");
2114 let total_tokens = prompt_tokens + output_tokens;
2115 if prompt_tokens == 0 && output_tokens == 0 {
2116 continue;
2117 }
2118
2119 state.usage_turns.push(SessionCostTurn {
2120 label: value
2121 .get("timestamp")
2122 .and_then(Value::as_str)
2123 .map(|value| value.to_string())
2124 .unwrap_or(key),
2125 prompt_tokens,
2126 cached_input_tokens,
2127 cache_creation_input_tokens,
2128 output_tokens,
2129 reasoning_output_tokens: 0,
2130 total_tokens,
2131 prompt_cache_metadata: Some(prompt_cache_metadata(
2132 &value,
2133 SessionCostSource::ClaudeJsonl,
2134 )),
2135 });
2136 }
2137 Ok(())
2138}
2139
2140fn ingest_codex_jsonl(input: &str, state: &mut CostState) -> Result<()> {
2141 let mut previous = UsageTotals::default();
2142 let mut seen_cumulative_snapshots = BTreeSet::<UsageTotals>::new();
2143 let mut saw_token_count = false;
2144 for (index, raw_line) in input.lines().enumerate() {
2145 let trimmed = raw_line.trim();
2146 if trimmed.is_empty() {
2147 continue;
2148 }
2149 let value = match serde_json::from_str::<Value>(trimmed) {
2150 Ok(value) => value,
2151 Err(_) => {
2152 state.warnings.push(format!(
2153 "skipping malformed Codex transcript jsonl line {}",
2154 index + 1
2155 ));
2156 continue;
2157 }
2158 };
2159 match value.get("type").and_then(Value::as_str) {
2160 Some("response_item") => {
2161 collect_codex_response_item_loop_signals(&value, index + 1, state)
2162 }
2163 Some("event_msg") => collect_codex_event_msg_loop_signals(&value, index + 1, state),
2164 _ => {}
2165 }
2166 if value.get("type").and_then(Value::as_str) != Some("event_msg") {
2167 continue;
2168 }
2169 let Some(payload) = value.get("payload") else {
2170 continue;
2171 };
2172 if payload.get("type").and_then(Value::as_str) != Some("token_count") {
2173 continue;
2174 }
2175 saw_token_count = true;
2176
2177 let Some(total) = payload
2178 .get("info")
2179 .and_then(|info| info.get("total_token_usage"))
2180 else {
2181 state.warnings.push(format!(
2182 "codex token_count event on line {} did not include info.total_token_usage",
2183 index + 1
2184 ));
2185 continue;
2186 };
2187 let cumulative = codex_usage_totals(total);
2188 let duplicate_snapshot = !seen_cumulative_snapshots.insert(cumulative);
2189 let delta = if duplicate_snapshot {
2190 UsageTotals::default()
2191 } else if let Some(last) = payload
2192 .get("info")
2193 .and_then(|info| info.get("last_token_usage"))
2194 .map(codex_usage_totals)
2195 .filter(|last| !last.is_zero())
2196 {
2197 last
2198 } else if previous.is_zero() {
2199 cumulative
2200 } else {
2201 cumulative.delta_from(previous)
2202 };
2203 previous = cumulative;
2204 if delta.is_zero() {
2205 continue;
2206 }
2207
2208 state.usage_turns.push(SessionCostTurn {
2209 label: value
2210 .get("timestamp")
2211 .and_then(Value::as_str)
2212 .map(|value| value.to_string())
2213 .unwrap_or_else(|| format!("line-{}", index + 1)),
2214 prompt_tokens: delta.prompt_tokens,
2215 cached_input_tokens: delta.cached_input_tokens,
2216 cache_creation_input_tokens: 0,
2217 output_tokens: delta.output_tokens,
2218 reasoning_output_tokens: delta.reasoning_output_tokens,
2219 total_tokens: delta
2220 .total_tokens
2221 .max(delta.prompt_tokens + delta.output_tokens),
2222 prompt_cache_metadata: Some(prompt_cache_metadata(
2223 &value,
2224 SessionCostSource::CodexJsonl,
2225 )),
2226 });
2227 }
2228
2229 if !saw_token_count {
2230 state.warnings.push(
2231 "codex transcript did not contain any token_count events; no token cost summary could be derived"
2232 .to_string(),
2233 );
2234 }
2235 Ok(())
2236}
2237
2238fn ingest_agent_doc_log(input: &str, state: &mut CostState) {
2239 for raw_line in input.lines() {
2240 let trimmed = raw_line.trim();
2241 if trimmed.is_empty() {
2242 continue;
2243 }
2244 let Some((_, after_bracket)) = trimmed.split_once("] ") else {
2245 continue;
2246 };
2247 let detail = after_bracket.trim();
2248 let Some(event_name) = detail.split_whitespace().next() else {
2249 continue;
2250 };
2251 let normalized = normalize_runtime_event(event_name, detail);
2252 let closeout_event = is_closeout_runtime_event(event_name, &normalized);
2253 if should_count_runtime_event(event_name, detail, &normalized, state) {
2254 *state.runtime_events.entry(normalized.clone()).or_default() += 1;
2255 state.total_runtime_events += 1;
2256 if closeout_event {
2257 push_closeout_signal(&normalized, state);
2258 }
2259 }
2260 state.restart_churn.observe(event_name, detail);
2261 if let Some(restart_count) =
2262 extract_field(detail, "restart_count").and_then(|value| value.parse::<usize>().ok())
2263 {
2264 state.max_restart_count = Some(
2265 state
2266 .max_restart_count
2267 .map_or(restart_count, |current| current.max(restart_count)),
2268 );
2269 }
2270 }
2271}
2272
2273fn collect_claude_loop_signals(value: &Value, state: &mut CostState) {
2274 let mut blocks = Vec::new();
2275 collect_transcript_blocks(value, &mut blocks);
2276 if blocks.is_empty() && is_ignorable_claude_record(value) {
2277 return;
2278 }
2279 for block in blocks {
2280 match block {
2281 TranscriptBlock::Text { role, text } => {
2282 let user_bias = role
2283 .as_deref()
2284 .is_some_and(|value| value.eq_ignore_ascii_case("user"));
2285 collect_text_loop_signals(&text, user_bias, state);
2286 }
2287 TranscriptBlock::ToolUse { name, input } => {
2288 collect_tool_use_loop_signals(&name, &input, state);
2289 }
2290 }
2291 }
2292}
2293
2294fn collect_codex_response_item_loop_signals(
2295 value: &Value,
2296 line_number: usize,
2297 state: &mut CostState,
2298) {
2299 let Some(payload) = value.get("payload") else {
2300 return;
2301 };
2302 match payload.get("type").and_then(Value::as_str) {
2303 Some("message") => {
2304 let Some(content) = payload.get("content").and_then(Value::as_array) else {
2305 return;
2306 };
2307 for item in content {
2308 let Some(text) = item
2309 .get("text")
2310 .and_then(Value::as_str)
2311 .or_else(|| item.get("content").and_then(Value::as_str))
2312 else {
2313 continue;
2314 };
2315 collect_text_loop_signals(text, false, state);
2316 }
2317 }
2318 Some("function_call") => {
2319 let name = payload
2320 .get("name")
2321 .and_then(Value::as_str)
2322 .unwrap_or("function_call");
2323 let Some(arguments) = payload.get("arguments").and_then(Value::as_str) else {
2324 return;
2325 };
2326 let input = serde_json::from_str::<Value>(arguments).unwrap_or_else(|_| {
2327 state.warnings.push(format!(
2328 "codex function_call arguments on line {} were not valid JSON; loop extraction may be incomplete",
2329 line_number
2330 ));
2331 Value::String(arguments.to_string())
2332 });
2333 collect_tool_use_loop_signals(name, &input, state);
2334 }
2335 _ => {}
2336 }
2337}
2338
2339fn collect_codex_event_msg_loop_signals(value: &Value, _line_number: usize, state: &mut CostState) {
2340 let Some(payload) = value.get("payload") else {
2341 return;
2342 };
2343 match payload.get("type").and_then(Value::as_str) {
2344 Some("user_message") => {
2345 if let Some(message) = payload.get("message").and_then(Value::as_str) {
2346 collect_text_loop_signals(message, true, state);
2347 }
2348 }
2349 Some("agent_message") => {
2350 if let Some(message) = payload.get("message").and_then(Value::as_str) {
2351 collect_text_loop_signals(message, false, state);
2352 }
2353 }
2354 Some("exec_command_end") => {
2355 if let Some(command) = extract_raw_codex_exec_command(payload) {
2356 collect_file_read_command_signals(&command, state);
2357 }
2358 if let Some(command) = extract_codex_exec_command(payload) {
2359 push_command(command, state);
2360 }
2361 if let Some(output) = payload
2362 .get("aggregated_output")
2363 .and_then(Value::as_str)
2364 .or_else(|| payload.get("stdout").and_then(Value::as_str))
2365 {
2366 collect_text_loop_signals(output, false, state);
2367 }
2368 }
2369 _ => {}
2370 }
2371}
2372
2373fn collect_tool_use_loop_signals(name: &str, input: &Value, state: &mut CostState) {
2374 collect_file_read_tool_signals(name, input, state);
2375 if let Some(command) = extract_raw_tool_command(name, input) {
2376 collect_file_read_command_signals(&command, state);
2377 }
2378 if let Some(command) = extract_tool_command(name, input) {
2379 push_command(command, state);
2380 }
2381 if let Some(text) = extract_tool_text(input) {
2382 collect_text_loop_signals(&text, false, state);
2383 }
2384}
2385
2386fn collect_file_read_tool_signals(name: &str, input: &Value, state: &mut CostState) {
2387 let lower = name.to_ascii_lowercase();
2388 if !matches!(lower.as_str(), "read" | "file_read" | "read_file") {
2389 return;
2390 }
2391 let Value::Object(map) = input else {
2392 return;
2393 };
2394 let Some(path) = ["file_path", "path"]
2395 .iter()
2396 .find_map(|key| map.get(*key).and_then(Value::as_str))
2397 .map(normalize_file_read_path)
2398 .filter(|path| !path.is_empty())
2399 else {
2400 return;
2401 };
2402 let start = ["offset", "start", "line"]
2403 .iter()
2404 .find_map(|key| map.get(*key).and_then(Value::as_u64))
2405 .and_then(|value| usize::try_from(value).ok())
2406 .filter(|value| *value > 0);
2407 let lines = ["limit", "lines", "line_count"]
2408 .iter()
2409 .find_map(|key| map.get(*key).and_then(Value::as_u64))
2410 .and_then(|value| usize::try_from(value).ok())
2411 .filter(|value| *value > 0);
2412 push_file_read_signal(path, start, lines, state);
2413}
2414
2415fn collect_file_read_command_signals(command: &str, state: &mut CostState) {
2416 if let Some(signal) = parse_file_read_command(command) {
2417 state.file_read_signals.push(signal);
2418 }
2419}
2420
2421fn parse_file_read_command(command: &str) -> Option<FileReadSignal> {
2422 let tokens = shell_words(command);
2423 let head = tokens.first()?.as_str();
2424 match head {
2425 "cat" | "bat" | "batcat" | "nl" => {
2426 let path = first_non_option_arg(&tokens[1..])?;
2427 Some(file_read_signal(
2428 normalize_file_read_path(path),
2429 "full".to_string(),
2430 None,
2431 None,
2432 ))
2433 }
2434 "sed" => parse_sed_file_read(&tokens),
2435 "head" => parse_head_file_read(&tokens),
2436 "tail" => parse_tail_file_read(&tokens),
2437 _ => None,
2438 }
2439}
2440
2441fn parse_sed_file_read(tokens: &[String]) -> Option<FileReadSignal> {
2442 let mut expr = None::<String>;
2443 let mut path = None::<String>;
2444 let mut skip_next = false;
2445 for token in tokens.iter().skip(1) {
2446 if skip_next {
2447 skip_next = false;
2448 continue;
2449 }
2450 if token == "-n" {
2451 continue;
2452 }
2453 if token == "-e" {
2454 skip_next = true;
2455 continue;
2456 }
2457 if expr.is_none() && parse_sed_range(token).is_some() {
2458 expr = Some(token.clone());
2459 continue;
2460 }
2461 if !token.starts_with('-') {
2462 path = Some(token.clone());
2463 }
2464 }
2465 let expr = expr?;
2466 let path = path?;
2467 let (start, lines) = parse_sed_range(&expr)?;
2468 Some(file_read_signal(
2469 normalize_file_read_path(&path),
2470 format!("{}-{}", start, start + lines - 1),
2471 Some(start),
2472 Some(lines),
2473 ))
2474}
2475
2476fn parse_sed_range(expr: &str) -> Option<(usize, usize)> {
2477 let trimmed = expr.trim_matches(['\'', '"']).trim();
2478 let body = trimmed.strip_suffix('p')?;
2479 let (start_raw, end_raw) = body.split_once(',')?;
2480 let start = start_raw.trim().parse::<usize>().ok()?;
2481 let lines = if let Some(relative) = end_raw.trim().strip_prefix('+') {
2482 relative.trim().parse::<usize>().ok()?.saturating_add(1)
2483 } else {
2484 let end = end_raw.trim().parse::<usize>().ok()?;
2485 end.checked_sub(start)?.saturating_add(1)
2486 };
2487 (lines > 0).then_some((start, lines))
2488}
2489
2490fn parse_head_file_read(tokens: &[String]) -> Option<FileReadSignal> {
2491 let mut lines = 10_usize;
2492 let mut path = None::<String>;
2493 let mut index = 1_usize;
2494 while index < tokens.len() {
2495 let token = &tokens[index];
2496 if token == "-n" || token == "--lines" {
2497 index += 1;
2498 lines = tokens.get(index)?.parse::<usize>().ok()?;
2499 } else if let Some(value) = token.strip_prefix("-n") {
2500 lines = value.parse::<usize>().ok()?;
2501 } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
2502 lines = token[1..].parse::<usize>().ok()?;
2503 } else if !token.starts_with('-') {
2504 path = Some(token.clone());
2505 }
2506 index += 1;
2507 }
2508 let path = path?;
2509 Some(file_read_signal(
2510 normalize_file_read_path(&path),
2511 format!("head:{lines}"),
2512 Some(1),
2513 Some(lines),
2514 ))
2515}
2516
2517fn parse_tail_file_read(tokens: &[String]) -> Option<FileReadSignal> {
2518 let mut lines = 10_usize;
2519 let mut path = None::<String>;
2520 let mut index = 1_usize;
2521 while index < tokens.len() {
2522 let token = &tokens[index];
2523 if token == "-n" || token == "--lines" {
2524 index += 1;
2525 lines = tokens.get(index)?.parse::<usize>().ok()?;
2526 } else if let Some(value) = token.strip_prefix("-n") {
2527 lines = value.trim_start_matches('+').parse::<usize>().ok()?;
2528 } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
2529 lines = token[1..].parse::<usize>().ok()?;
2530 } else if !token.starts_with('-') {
2531 path = Some(token.clone());
2532 }
2533 index += 1;
2534 }
2535 let path = path?;
2536 Some(file_read_signal(
2537 normalize_file_read_path(&path),
2538 format!("tail:{lines}"),
2539 None,
2540 Some(lines),
2541 ))
2542}
2543
2544fn first_non_option_arg(tokens: &[String]) -> Option<&str> {
2545 tokens
2546 .iter()
2547 .find(|token| !token.starts_with('-'))
2548 .map(String::as_str)
2549}
2550
2551fn push_file_read_signal(
2552 path: String,
2553 start: Option<usize>,
2554 lines: Option<usize>,
2555 state: &mut CostState,
2556) {
2557 let range = match (start, lines) {
2558 (Some(start), Some(lines)) => format!("{}-{}", start, start + lines - 1),
2559 (Some(start), None) => format!("{start}-end"),
2560 (None, Some(lines)) => format!("window:{lines}"),
2561 (None, None) => "full".to_string(),
2562 };
2563 state
2564 .file_read_signals
2565 .push(file_read_signal(path, range, start, lines));
2566}
2567
2568fn file_read_signal(
2569 path: String,
2570 range: String,
2571 start: Option<usize>,
2572 lines: Option<usize>,
2573) -> FileReadSignal {
2574 FileReadSignal {
2575 path,
2576 range,
2577 start,
2578 lines,
2579 estimated_tokens: estimate_file_read_tokens(lines),
2580 }
2581}
2582
2583fn estimate_file_read_tokens(lines: Option<usize>) -> u64 {
2584 lines
2585 .map(|lines| (lines as u64).saturating_mul(ESTIMATED_TOKENS_PER_SOURCE_LINE))
2586 .unwrap_or(DEFAULT_FULL_FILE_READ_TOKENS)
2587 .max(80)
2588}
2589
2590fn collect_file_read_diagnostics(signals: &[FileReadSignal]) -> Vec<SessionCostFileReadDiagnostic> {
2591 let mut grouped = BTreeMap::<(String, String), FileReadDiagnosticBuilder>::new();
2592 for signal in signals {
2593 let entry = grouped
2594 .entry((signal.path.clone(), signal.range.clone()))
2595 .or_insert_with(|| FileReadDiagnosticBuilder {
2596 path: signal.path.clone(),
2597 range: signal.range.clone(),
2598 start: signal.start,
2599 lines: signal.lines,
2600 occurrences: 0,
2601 estimated_tokens: 0,
2602 max_single_read_tokens: 0,
2603 });
2604 entry.occurrences += 1;
2605 entry.estimated_tokens = entry
2606 .estimated_tokens
2607 .saturating_add(signal.estimated_tokens);
2608 entry.max_single_read_tokens = entry.max_single_read_tokens.max(signal.estimated_tokens);
2609 entry.start = entry.start.or(signal.start);
2610 entry.lines = entry.lines.or(signal.lines);
2611 }
2612
2613 let mut diagnostics = grouped
2614 .into_values()
2615 .filter(|entry| entry.occurrences >= 2)
2616 .map(|entry| {
2617 let duplicate_estimated_tokens = entry
2618 .estimated_tokens
2619 .saturating_sub(entry.max_single_read_tokens);
2620 SessionCostFileReadDiagnostic {
2621 path: entry.path.clone(),
2622 range: entry.range.clone(),
2623 occurrences: entry.occurrences,
2624 estimated_tokens: entry.estimated_tokens,
2625 duplicate_estimated_tokens,
2626 follow_up_commands: file_read_follow_up_commands(
2627 &entry.path,
2628 entry.start,
2629 entry.lines,
2630 ),
2631 }
2632 })
2633 .collect::<Vec<_>>();
2634 diagnostics.sort_by(|left, right| {
2635 right
2636 .duplicate_estimated_tokens
2637 .cmp(&left.duplicate_estimated_tokens)
2638 .then(right.occurrences.cmp(&left.occurrences))
2639 .then(left.path.cmp(&right.path))
2640 .then(left.range.cmp(&right.range))
2641 });
2642 diagnostics.truncate(MAX_FILE_READ_DIAGNOSTICS);
2643 diagnostics
2644}
2645
2646#[derive(Debug)]
2647struct FileReadDiagnosticBuilder {
2648 path: String,
2649 range: String,
2650 start: Option<usize>,
2651 lines: Option<usize>,
2652 occurrences: usize,
2653 estimated_tokens: u64,
2654 max_single_read_tokens: u64,
2655}
2656
2657fn file_read_follow_up_commands(
2658 path: &str,
2659 start: Option<usize>,
2660 lines: Option<usize>,
2661) -> Vec<String> {
2662 let start = start.unwrap_or(1);
2663 let lines = lines.unwrap_or(120).max(1);
2664 vec![
2665 format!(
2666 "tsift source-read {} --start {} --lines {} --budget normal",
2667 shell_quote(path),
2668 start,
2669 lines
2670 ),
2671 format!("tsift summarize --file {}", shell_quote(path)),
2672 ]
2673}
2674
2675fn normalize_file_read_path(raw: &str) -> String {
2676 raw.trim()
2677 .trim_matches(['\'', '"'])
2678 .trim_start_matches("./")
2679 .to_string()
2680}
2681
2682fn shell_words(command: &str) -> Vec<String> {
2683 let mut words = Vec::new();
2684 let mut current = String::new();
2685 let mut quote = None::<char>;
2686 let mut escaped = false;
2687
2688 for ch in command.chars() {
2689 if escaped {
2690 current.push(ch);
2691 escaped = false;
2692 continue;
2693 }
2694 if ch == '\\' {
2695 escaped = true;
2696 continue;
2697 }
2698 if let Some(quote_ch) = quote {
2699 if ch == quote_ch {
2700 quote = None;
2701 } else {
2702 current.push(ch);
2703 }
2704 continue;
2705 }
2706 if ch == '\'' || ch == '"' {
2707 quote = Some(ch);
2708 continue;
2709 }
2710 if ch.is_whitespace() {
2711 if !current.is_empty() {
2712 words.push(std::mem::take(&mut current));
2713 }
2714 continue;
2715 }
2716 current.push(ch);
2717 }
2718 if !current.is_empty() {
2719 words.push(current);
2720 }
2721 words
2722}
2723
2724fn shell_quote(value: &str) -> String {
2725 if value
2726 .chars()
2727 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
2728 {
2729 return value.to_string();
2730 }
2731 format!("'{}'", value.replace('\'', "'\\''"))
2732}
2733
2734fn collect_text_loop_signals(text: &str, user_bias: bool, state: &mut CostState) {
2735 for raw_line in text.lines() {
2736 let trimmed = raw_line.trim();
2737 if trimmed.is_empty() || looks_like_instruction_ballast(trimmed) {
2738 continue;
2739 }
2740 let prompt_candidate = trimmed
2741 .strip_prefix("❯ ")
2742 .or_else(|| trimmed.strip_prefix("> "))
2743 .unwrap_or(trimmed)
2744 .trim();
2745 if looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed) {
2746 push_prompt_signal(prompt_candidate, state);
2747 continue;
2748 }
2749 for (kind, detail) in detect_closeout(trimmed) {
2750 push_closeout_signal(&format!("{kind}: {detail}"), state);
2751 }
2752 }
2753}
2754
2755fn push_prompt_signal(text: &str, state: &mut CostState) {
2756 flush_pending_commands(state);
2757 push_loop_signal(LoopClusterKind::PromptRepeat, text, state);
2758}
2759
2760fn push_closeout_signal(text: &str, state: &mut CostState) {
2761 flush_pending_commands(state);
2762 push_loop_signal(LoopClusterKind::CloseoutChurn, text, state);
2763}
2764
2765fn push_command(command: String, state: &mut CostState) {
2766 let normalized = normalize_whitespace(&command);
2767 if normalized.is_empty() {
2768 return;
2769 }
2770 if state
2771 .pending_commands
2772 .last()
2773 .is_some_and(|existing| existing == &normalized)
2774 {
2775 return;
2776 }
2777 state.pending_commands.push(normalized);
2778}
2779
2780fn flush_pending_commands(state: &mut CostState) {
2781 if state.pending_commands.is_empty() {
2782 return;
2783 }
2784 let label = truncate_detail(
2785 &state
2786 .pending_commands
2787 .iter()
2788 .take(MAX_COMMANDS_PER_BUNDLE)
2789 .cloned()
2790 .collect::<Vec<_>>()
2791 .join(" -> "),
2792 220,
2793 );
2794 state.pending_commands.clear();
2795 push_loop_signal(LoopClusterKind::CommandBundle, &label, state);
2796}
2797
2798fn push_loop_signal(kind: LoopClusterKind, label: &str, state: &mut CostState) {
2799 let normalized = truncate_detail(&normalize_whitespace(label), 220);
2800 if normalized.is_empty() {
2801 return;
2802 }
2803 state.loop_signals.push(LoopSignal {
2804 kind,
2805 label: normalized,
2806 });
2807}
2808
2809fn collect_loop_clusters(signals: &[LoopSignal]) -> Vec<SessionCostLoopCluster> {
2810 let mut summary = BTreeMap::<(LoopClusterKind, String), (usize, usize)>::new();
2811 let mut previous = None::<(LoopClusterKind, String)>;
2812 let mut streak = 0_usize;
2813
2814 for signal in signals {
2815 let key = (signal.kind, signal.label.clone());
2816 let entry = summary.entry(key.clone()).or_insert((0, 0));
2817 entry.0 += 1;
2818 if previous.as_ref() == Some(&key) {
2819 streak += 1;
2820 } else {
2821 previous = Some(key.clone());
2822 streak = 1;
2823 }
2824 entry.1 = entry.1.max(streak);
2825 }
2826
2827 let mut clusters = summary
2828 .into_iter()
2829 .filter_map(|((kind, label), (occurrences, max_consecutive))| {
2830 (occurrences >= 2).then_some(SessionCostLoopCluster {
2831 kind: kind.as_str().to_string(),
2832 label,
2833 occurrences,
2834 max_consecutive,
2835 })
2836 })
2837 .collect::<Vec<_>>();
2838 clusters.sort_by(|left, right| {
2839 right
2840 .occurrences
2841 .cmp(&left.occurrences)
2842 .then(right.max_consecutive.cmp(&left.max_consecutive))
2843 .then(left.kind.cmp(&right.kind))
2844 .then(left.label.cmp(&right.label))
2845 });
2846 clusters.truncate(MAX_LOOP_CLUSTERS);
2847 clusters
2848}
2849
2850fn is_ignorable_claude_record(value: &Value) -> bool {
2851 value.get("attachment").is_some()
2852 || value.get("toolUseResult").is_some()
2853 || (value.get("message").is_none()
2854 && value.get("content").is_none()
2855 && value.get("text").is_none())
2856}
2857
2858fn collect_transcript_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2859 if let Some(message) = value.get("message") {
2860 collect_message_blocks(message, out);
2861 return;
2862 }
2863 collect_message_blocks(value, out);
2864}
2865
2866fn collect_message_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2867 let role = value
2868 .get("role")
2869 .and_then(Value::as_str)
2870 .map(|value| value.to_string());
2871 if let Some(content) = value.get("content") {
2872 match content {
2873 Value::String(text) => out.push(TranscriptBlock::Text {
2874 role,
2875 text: text.to_string(),
2876 }),
2877 Value::Array(items) => {
2878 for item in items {
2879 collect_content_block(role.clone(), item, out);
2880 }
2881 }
2882 _ => {}
2883 }
2884 } else if let Some(text) = value.get("text").and_then(Value::as_str) {
2885 out.push(TranscriptBlock::Text {
2886 role,
2887 text: text.to_string(),
2888 });
2889 }
2890}
2891
2892fn collect_content_block(role: Option<String>, value: &Value, out: &mut Vec<TranscriptBlock>) {
2893 match value.get("type").and_then(Value::as_str) {
2894 Some("text") => {
2895 if let Some(text) = value.get("text").and_then(Value::as_str) {
2896 out.push(TranscriptBlock::Text {
2897 role,
2898 text: text.to_string(),
2899 });
2900 }
2901 }
2902 Some("tool_use") => {
2903 let name = value
2904 .get("name")
2905 .and_then(Value::as_str)
2906 .unwrap_or("tool_use")
2907 .to_string();
2908 let input = value.get("input").cloned().unwrap_or(Value::Null);
2909 out.push(TranscriptBlock::ToolUse { name, input });
2910 }
2911 Some("tool_result") => match value.get("content") {
2912 Some(Value::String(text)) => out.push(TranscriptBlock::Text {
2913 role,
2914 text: text.to_string(),
2915 }),
2916 Some(Value::Array(items)) => {
2917 for item in items {
2918 collect_content_block(role.clone(), item, out);
2919 }
2920 }
2921 _ => {}
2922 },
2923 _ => {
2924 if let Some(text) = value.get("text").and_then(Value::as_str) {
2925 out.push(TranscriptBlock::Text {
2926 role,
2927 text: text.to_string(),
2928 });
2929 }
2930 }
2931 }
2932}
2933
2934fn extract_tool_command(name: &str, input: &Value) -> Option<String> {
2935 let normalized = extract_raw_tool_command(name, input)?;
2936 looks_like_command(&normalized).then_some(normalized)
2937}
2938
2939fn extract_raw_tool_command(name: &str, input: &Value) -> Option<String> {
2940 if !matches!(
2941 name.to_ascii_lowercase().as_str(),
2942 "bash" | "exec_command" | "shell" | "terminal" | "sh"
2943 ) {
2944 return None;
2945 }
2946
2947 match input {
2948 Value::Object(map) => {
2949 for key in ["command", "cmd", "shell_command"] {
2950 if let Some(raw) = map.get(key).and_then(Value::as_str) {
2951 let normalized = normalize_whitespace(raw);
2952 if !normalized.is_empty() {
2953 return Some(normalized);
2954 }
2955 }
2956 }
2957 None
2958 }
2959 Value::String(raw) => {
2960 let normalized = normalize_whitespace(raw);
2961 (!normalized.is_empty()).then_some(normalized)
2962 }
2963 _ => None,
2964 }
2965}
2966
2967fn extract_tool_text(input: &Value) -> Option<String> {
2968 match input {
2969 Value::Object(map) => {
2970 for key in ["text", "output", "stderr", "stdout", "content", "message"] {
2971 if let Some(raw) = map.get(key).and_then(Value::as_str) {
2972 return Some(raw.to_string());
2973 }
2974 }
2975 None
2976 }
2977 Value::String(raw) => Some(raw.to_string()),
2978 _ => None,
2979 }
2980}
2981
2982fn extract_codex_exec_command(payload: &Value) -> Option<String> {
2983 let normalized = extract_raw_codex_exec_command(payload)?;
2984 looks_like_command(&normalized).then_some(normalized)
2985}
2986
2987fn extract_raw_codex_exec_command(payload: &Value) -> Option<String> {
2988 if let Some(parsed) = payload.get("parsed_cmd").and_then(Value::as_array) {
2989 for item in parsed {
2990 if let Some(command) = item.get("cmd").and_then(Value::as_str) {
2991 let normalized = normalize_whitespace(command);
2992 if !normalized.is_empty() {
2993 return Some(normalized);
2994 }
2995 }
2996 }
2997 }
2998
2999 if let Some(command) = payload.get("command").and_then(Value::as_array)
3000 && let Some(last) = command.last().and_then(Value::as_str)
3001 {
3002 let normalized = normalize_whitespace(last);
3003 if !normalized.is_empty() {
3004 return Some(normalized);
3005 }
3006 }
3007 None
3008}
3009
3010fn looks_like_prompt_target(text: &str, user_bias: bool) -> bool {
3011 let trimmed = text.trim();
3012 if trimmed.is_empty()
3013 || looks_like_markdown_heading(trimmed)
3014 || looks_like_slash_command_example(trimmed)
3015 || trimmed == "#"
3016 || trimmed.starts_with("#!")
3017 || trimmed.starts_with("#[")
3018 || trimmed.starts_with("/**")
3019 || trimmed.starts_with("*/")
3020 || trimmed.starts_with("//")
3021 || trimmed.starts_with("###")
3022 || trimmed.starts_with("<!--")
3023 || trimmed.starts_with("- [")
3024 || trimmed == "###"
3025 {
3026 return false;
3027 }
3028
3029 if trimmed.starts_with("do ")
3030 || trimmed.starts_with('#')
3031 || looks_like_slash_prompt_target(trimmed)
3032 || trimmed.ends_with('?')
3033 {
3034 return true;
3035 }
3036
3037 if user_bias
3038 && (trimmed.contains("commit + push")
3039 || trimmed.contains("run tests")
3040 || trimmed.contains("build + install")
3041 || trimmed.contains("#spec-test"))
3042 {
3043 return true;
3044 }
3045
3046 false
3047}
3048
3049fn looks_like_instruction_ballast(text: &str) -> bool {
3050 let trimmed = strip_common_prefixes(text.trim());
3051 if trimmed.is_empty() {
3052 return false;
3053 }
3054
3055 looks_like_markdown_heading(trimmed)
3056 || looks_like_slash_command_example(trimmed)
3057 || looks_like_frontmatter_prompt_preset(trimmed)
3058 || looks_like_completed_backlog_archive(trimmed)
3059 || trimmed.starts_with("<!-- tsift:")
3060 || trimmed.starts_with("<!-- /tsift:")
3061 || looks_like_instruction_label(trimmed)
3062}
3063
3064fn looks_like_markdown_heading(text: &str) -> bool {
3065 let trimmed = text.trim_start();
3066 let heading_level = trimmed.chars().take_while(|ch| *ch == '#').count();
3067 heading_level > 0
3068 && heading_level <= 6
3069 && trimmed
3070 .chars()
3071 .nth(heading_level)
3072 .is_some_and(|ch| ch.is_whitespace())
3073}
3074
3075fn looks_like_slash_command_example(text: &str) -> bool {
3076 let trimmed = text.trim();
3077 trimmed.starts_with('/')
3078 && trimmed.contains('<')
3079 && trimmed.contains('>')
3080 && !trimmed.contains('`')
3081}
3082
3083fn looks_like_slash_prompt_target(text: &str) -> bool {
3084 let Some(first_token) = text.split_whitespace().next() else {
3085 return false;
3086 };
3087 first_token.starts_with('/') && !first_token[1..].contains('/')
3088}
3089
3090fn looks_like_instruction_label(text: &str) -> bool {
3091 let trimmed = text.trim();
3092 if !trimmed.starts_with("**") {
3093 return false;
3094 }
3095 let Some(label_end) = trimmed[2..].find("**") else {
3096 return false;
3097 };
3098 let label = &trimmed[..label_end + 4];
3099 if label.len() <= 4 {
3100 return false;
3101 }
3102 let remainder = trimmed[label_end + 4..]
3103 .trim_start_matches([' ', ':', '-', '—'])
3104 .trim_start();
3105 if remainder.is_empty() {
3106 return false;
3107 }
3108 let lower = remainder.to_ascii_lowercase();
3109 matches!(
3110 lower.split_whitespace().next(),
3111 Some("run")
3112 | Some("use")
3113 | Some("treat")
3114 | Some("respond")
3115 | Some("print")
3116 | Some("prefer")
3117 | Some("preserve")
3118 | Some("show")
3119 | Some("complete")
3120 | Some("append")
3121 | Some("when")
3122 | Some("if")
3123 )
3124}
3125
3126fn strip_common_prefixes(text: &str) -> &str {
3127 text.strip_prefix("❯ ")
3128 .or_else(|| text.strip_prefix("- "))
3129 .or_else(|| text.strip_prefix("* "))
3130 .or_else(|| text.strip_prefix("> "))
3131 .unwrap_or(text)
3132 .trim()
3133}
3134
3135fn looks_like_frontmatter_prompt_preset(text: &str) -> bool {
3136 let trimmed = strip_common_prefixes(text.trim());
3137 if trimmed == "prompt_presets:" || trimmed.starts_with("prompt_presets:") {
3138 return true;
3139 }
3140 let Some((key, _)) = trimmed.split_once(':') else {
3141 return false;
3142 };
3143 let key = key.trim().trim_matches(['"', '\'']);
3144 key.starts_with('#') && key.len() > 1 && key[1..].chars().all(is_prompt_preset_char)
3145}
3146
3147fn is_prompt_preset_char(ch: char) -> bool {
3148 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')
3149}
3150
3151fn looks_like_completed_backlog_archive(text: &str) -> bool {
3152 let stripped = strip_common_prefixes(text.trim());
3153 let Some(date) = stripped.get(..10) else {
3154 return false;
3155 };
3156 date.chars().enumerate().all(|(index, ch)| match index {
3157 4 | 7 => ch == '-',
3158 _ => ch.is_ascii_digit(),
3159 }) && stripped[10..].contains("[#")
3160}
3161
3162fn looks_like_command(text: &str) -> bool {
3163 if text.is_empty()
3164 || text.contains('\n')
3165 || text.contains("://")
3166 || text.starts_with('/')
3167 || text.starts_with("###")
3168 {
3169 return false;
3170 }
3171
3172 let head = text.split_whitespace().next().unwrap_or_default();
3173 matches!(
3174 head,
3175 "agent-doc"
3176 | "cargo"
3177 | "git"
3178 | "make"
3179 | "pytest"
3180 | "python"
3181 | "uv"
3182 | "tsift"
3183 | "npm"
3184 | "pnpm"
3185 | "yarn"
3186 | "bash"
3187 | "zsh"
3188 | "rg"
3189 | "grep"
3190 | "./scripts/run_benchmark.sh"
3191 ) || head.starts_with("./")
3192}
3193
3194fn detect_closeout(text: &str) -> Vec<(String, String)> {
3195 let mut out = Vec::new();
3196 let normalized = normalize_whitespace(strip_common_prefixes(text));
3197 let lower = normalized.to_ascii_lowercase();
3198
3199 if normalized.starts_with("document_cycle ") {
3200 let phase = extract_field(&normalized, "phase");
3201 let event = extract_field(&normalized, "event");
3202 if phase == Some("committed")
3203 && let Some(event) = event
3204 {
3205 out.push((
3206 "commit".to_string(),
3207 format!("document_cycle phase=committed event={event}"),
3208 ));
3209 }
3210 return dedupe_pairs(out);
3211 }
3212
3213 if lower.contains("verification passed") || lower.starts_with("verification in ") {
3214 out.push((
3215 "verification".to_string(),
3216 truncate_detail(&normalized, 220),
3217 ));
3218 }
3219 if lower.contains("cargo build")
3220 || lower.contains("make check")
3221 || lower.contains("cargo test")
3222 || lower.contains("pytest")
3223 {
3224 out.push((
3225 "verification".to_string(),
3226 truncate_detail(&normalized, 220),
3227 ));
3228 }
3229 if lower.contains("cargo install") || lower.contains("installed") {
3230 out.push(("install".to_string(), truncate_detail(&normalized, 220)));
3231 }
3232 if lower.contains("committed and pushed") {
3233 out.push(("push".to_string(), truncate_detail(&normalized, 220)));
3234 } else if lower.contains("committed") {
3235 out.push(("commit".to_string(), truncate_detail(&normalized, 220)));
3236 }
3237 if lower.contains("tsift --version") || lower.contains("tsift v0.") {
3238 out.push(("version".to_string(), truncate_detail(&normalized, 220)));
3239 }
3240 if lower.contains("agent-doc finalize") || lower.contains("session-check") {
3241 out.push(("closeout".to_string(), truncate_detail(&normalized, 220)));
3242 }
3243
3244 dedupe_pairs(out)
3245}
3246
3247fn is_closeout_runtime_event(event_name: &str, normalized: &str) -> bool {
3248 event_name == "document_cycle"
3249 || matches!(
3250 normalized,
3251 "preflight_started"
3252 | "response_captured"
3253 | "commit_staging"
3254 | "commit_success"
3255 | "commit_already_current"
3256 | "snapshot_save"
3257 | "write_origin"
3258 | "ipc_write_attempt"
3259 | "ipc_write_consumed"
3260 | "out_of_band_write"
3261 )
3262}
3263
3264fn dedupe_pairs(items: Vec<(String, String)>) -> Vec<(String, String)> {
3265 let mut seen = BTreeSet::new();
3266 let mut deduped = Vec::new();
3267 for item in items {
3268 if seen.insert(item.clone()) {
3269 deduped.push(item);
3270 }
3271 }
3272 deduped
3273}
3274
3275fn normalize_whitespace(raw: &str) -> String {
3276 raw.split_whitespace().collect::<Vec<_>>().join(" ")
3277}
3278
3279fn truncate_detail(text: &str, max_chars: usize) -> String {
3280 if text.chars().count() <= max_chars {
3281 return text.to_string();
3282 }
3283 let mut truncated = String::new();
3284 for ch in text.chars().take(max_chars.saturating_sub(1)) {
3285 truncated.push(ch);
3286 }
3287 truncated.push('…');
3288 truncated
3289}
3290
3291fn normalize_runtime_event(event_name: &str, detail: &str) -> String {
3292 if event_name == "document_cycle"
3293 && let Some(document_event) = extract_field(detail, "event")
3294 {
3295 return document_event.to_string();
3296 }
3297 if matches!(
3298 event_name,
3299 "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
3300 ) && let Some(mode) = extract_field(detail, "mode")
3301 {
3302 return format!("{event_name}:{mode}");
3303 }
3304 event_name.to_string()
3305}
3306
3307fn should_count_runtime_event(
3308 event_name: &str,
3309 detail: &str,
3310 normalized: &str,
3311 state: &mut CostState,
3312) -> bool {
3313 if event_name == "document_cycle"
3314 && let Some(cycle) = extract_field(detail, "cycle")
3315 {
3316 return state
3317 .seen_document_cycle_events
3318 .insert((cycle.to_string(), normalized.to_string()));
3319 }
3320 true
3321}
3322
3323fn prompt_cache_metadata(
3324 value: &Value,
3325 source: SessionCostSource,
3326) -> SessionCostPromptCacheMetadata {
3327 let provider = find_first_string_field(
3328 value,
3329 &[
3330 "provider",
3331 "model_provider",
3332 "provider_id",
3333 "model_provider_id",
3334 ],
3335 )
3336 .unwrap_or_else(|| default_prompt_cache_provider(source).to_string());
3337 let cache_key = find_first_string_field(
3338 value,
3339 &[
3340 "prompt_cache_key",
3341 "promptCacheKey",
3342 "cache_key",
3343 "cacheKey",
3344 ],
3345 );
3346 let routing_affinity = find_first_string_field(
3347 value,
3348 &[
3349 "routing_affinity",
3350 "routingAffinity",
3351 "replica",
3352 "replica_id",
3353 "replicaId",
3354 "deployment_id",
3355 "deploymentId",
3356 ],
3357 );
3358 let explicit_fingerprint = find_first_string_field(
3359 value,
3360 &[
3361 "stable_prefix_fingerprint",
3362 "stablePrefixFingerprint",
3363 "prefix_fingerprint",
3364 "prefixFingerprint",
3365 ],
3366 );
3367 let stable_prefix = find_first_string_field(
3368 value,
3369 &[
3370 "stable_prefix",
3371 "stablePrefix",
3372 "cached_prefix",
3373 "cachedPrefix",
3374 "prompt_prefix",
3375 "promptPrefix",
3376 ],
3377 );
3378 let mut breakpoints = Vec::new();
3379 collect_prompt_cache_breakpoints(value, "$", &mut breakpoints);
3380 let mut breakpoints = aggregate_prompt_cache_breakpoint_counts(breakpoints);
3385 breakpoints.truncate(MAX_PROMPT_CACHE_BREAKPOINTS);
3386
3387 let stable_prefix_fingerprint_explicit = explicit_fingerprint.is_some();
3388 let stable_prefix_fingerprint = explicit_fingerprint.unwrap_or_else(|| {
3389 let mut material = vec![format!("provider={provider}")];
3390 if let Some(cache_key) = &cache_key {
3391 material.push(format!("cache_key={cache_key}"));
3392 }
3393 if let Some(stable_prefix) = &stable_prefix {
3394 material.push(format!("stable_prefix={stable_prefix}"));
3395 }
3396 for breakpoint in &breakpoints {
3397 material.push(format!("breakpoint={breakpoint}"));
3398 }
3399 stable_prompt_cache_fingerprint(&material.join("\n"))
3400 });
3401
3402 SessionCostPromptCacheMetadata {
3403 provider,
3404 cache_key,
3405 stable_prefix_fingerprint,
3406 stable_prefix_fingerprint_explicit,
3407 stable_prefix,
3408 breakpoints,
3409 routing_affinity,
3410 }
3411}
3412
3413fn default_prompt_cache_provider(source: SessionCostSource) -> &'static str {
3414 match source {
3415 SessionCostSource::ClaudeJsonl => "anthropic",
3416 SessionCostSource::CodexJsonl => "openai",
3417 SessionCostSource::AgentDocLog => "agent_doc_log",
3418 }
3419}
3420
3421fn find_first_string_field(value: &Value, keys: &[&str]) -> Option<String> {
3422 let mut matches = Vec::new();
3423 collect_string_field_matches(value, "$", keys, &mut matches);
3424 matches.sort_by(|left, right| left.0.cmp(&right.0));
3425 matches
3426 .into_iter()
3427 .map(|(_, value)| value)
3428 .find(|value| !value.trim().is_empty())
3429}
3430
3431fn collect_string_field_matches(
3432 value: &Value,
3433 path: &str,
3434 keys: &[&str],
3435 matches: &mut Vec<(String, String)>,
3436) {
3437 match value {
3438 Value::Object(object) => {
3439 for (key, child) in object {
3440 let child_path = json_child_path(path, key);
3441 if metadata_key_matches(key, keys)
3442 && let Some(text) = child.as_str()
3443 {
3444 matches.push((child_path.clone(), text.to_string()));
3445 }
3446 collect_string_field_matches(child, &child_path, keys, matches);
3447 }
3448 }
3449 Value::Array(items) => {
3450 for (index, child) in items.iter().enumerate() {
3451 let child_path = format!("{path}[{index}]");
3452 collect_string_field_matches(child, &child_path, keys, matches);
3453 }
3454 }
3455 _ => {}
3456 }
3457}
3458
3459fn collect_prompt_cache_breakpoints(value: &Value, path: &str, breakpoints: &mut Vec<String>) {
3460 match value {
3461 Value::Object(object) => {
3462 for (key, child) in object {
3463 let child_path = json_child_path(path, key);
3464 if metadata_key_matches(
3465 key,
3466 &[
3467 "cache_control",
3468 "cacheControl",
3469 "cache_breakpoint",
3470 "cacheBreakpoint",
3471 "prompt_cache_breakpoint",
3472 "promptCacheBreakpoint",
3473 ],
3474 ) {
3475 breakpoints.push(format!(
3476 "{}={}",
3477 strip_json_array_indices(child_path.trim_start_matches("$.")),
3478 describe_prompt_cache_breakpoint(child)
3479 ));
3480 }
3481 collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
3482 }
3483 }
3484 Value::Array(items) => {
3485 for (index, child) in items.iter().enumerate() {
3486 let child_path = format!("{path}[{index}]");
3487 collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
3488 }
3489 }
3490 _ => {}
3491 }
3492}
3493
3494fn strip_json_array_indices(path: &str) -> String {
3499 let mut out = String::with_capacity(path.len());
3500 let mut in_index = false;
3501 for ch in path.chars() {
3502 match ch {
3503 '[' => in_index = true,
3504 ']' => in_index = false,
3505 _ if !in_index => out.push(ch),
3506 _ => {}
3507 }
3508 }
3509 out
3510}
3511
3512fn aggregate_prompt_cache_breakpoint_counts(mut breakpoints: Vec<String>) -> Vec<String> {
3516 for breakpoint in &mut breakpoints {
3523 if let Some(open) = breakpoint_count_suffix_start(breakpoint) {
3524 *breakpoint = format!("{} (\\x{}", &breakpoint[..open], &breakpoint[open + 3..]);
3525 }
3526 }
3527 breakpoints.sort();
3528 let mut aggregated: Vec<String> = Vec::new();
3529 let mut index = 0;
3530 while index < breakpoints.len() {
3531 let breakpoint = &breakpoints[index];
3532 let mut count = 1;
3533 while index + count < breakpoints.len() && breakpoints[index + count] == *breakpoint {
3534 count += 1;
3535 }
3536 if count > 1 {
3537 aggregated.push(format!("{breakpoint} (x{count})"));
3538 } else {
3539 aggregated.push(breakpoint.clone());
3540 }
3541 index += count;
3542 }
3543 aggregated
3544}
3545
3546fn breakpoint_count_suffix_start(breakpoint: &str) -> Option<usize> {
3550 let inner = breakpoint.strip_suffix(')')?;
3551 let open = inner.rfind(" (x")?;
3552 let digits = &inner[open + 3..];
3553 if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
3554 Some(open)
3555 } else {
3556 None
3557 }
3558}
3559
3560fn describe_prompt_cache_breakpoint(value: &Value) -> String {
3561 if let Some(text) = value.as_str() {
3562 return text.to_string();
3563 }
3564 if let Some(enabled) = value.as_bool() {
3565 return enabled.to_string();
3566 }
3567 if let Some(object) = value.as_object()
3568 && let Some(kind) = object.get("type").and_then(Value::as_str)
3569 {
3570 return format!("type:{kind}");
3571 }
3572 value.to_string()
3573}
3574
3575fn metadata_has_cache_control_breakpoint(metadata: &SessionCostPromptCacheMetadata) -> bool {
3576 metadata.breakpoints.iter().any(|breakpoint| {
3577 let key = breakpoint
3578 .split_once('=')
3579 .map_or(breakpoint.as_str(), |(key, _)| key);
3580 normalize_metadata_key(key).contains("cachecontrol")
3581 })
3582}
3583
3584fn is_anthropic_provider(provider: &str) -> bool {
3585 let provider = normalize_metadata_key(provider);
3586 provider.contains("anthropic") || provider.contains("claude")
3587}
3588
3589fn is_openai_provider(provider: &str) -> bool {
3590 let provider = normalize_metadata_key(provider);
3591 provider.contains("openai") || provider.contains("azureopenai") || provider.contains("codex")
3592}
3593
3594fn metadata_key_matches(key: &str, candidates: &[&str]) -> bool {
3595 let key = normalize_metadata_key(key);
3596 candidates
3597 .iter()
3598 .any(|candidate| key == normalize_metadata_key(candidate))
3599}
3600
3601fn normalize_metadata_key(key: &str) -> String {
3602 key.chars()
3603 .filter(|value| *value != '_' && *value != '-')
3604 .flat_map(char::to_lowercase)
3605 .collect()
3606}
3607
3608fn json_child_path(parent: &str, key: &str) -> String {
3609 if parent == "$" {
3610 format!("$.{key}")
3611 } else {
3612 format!("{parent}.{key}")
3613 }
3614}
3615
3616fn stable_prompt_cache_fingerprint(material: &str) -> String {
3617 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
3618 for byte in material.as_bytes() {
3619 hash ^= u64::from(*byte);
3620 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
3621 }
3622 format!("spfx-{hash:016x}")
3623}
3624
3625fn usage_u64(value: &Value, key: &str) -> u64 {
3626 value.get(key).and_then(Value::as_u64).unwrap_or(0)
3627}
3628
3629fn codex_usage_totals(value: &Value) -> UsageTotals {
3630 UsageTotals {
3631 prompt_tokens: usage_u64(value, "input_tokens"),
3632 cached_input_tokens: usage_u64(value, "cached_input_tokens"),
3633 cache_creation_input_tokens: 0,
3634 output_tokens: usage_u64(value, "output_tokens"),
3635 reasoning_output_tokens: usage_u64(value, "reasoning_output_tokens"),
3636 total_tokens: usage_u64(value, "total_tokens"),
3637 }
3638}
3639
3640fn count_restart_family(restart_churn: &[RestartChurnSummary], family: &str) -> usize {
3641 restart_churn
3642 .iter()
3643 .find(|entry| entry.family == family)
3644 .map_or(0, |entry| entry.occurrences)
3645}
3646
3647fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
3648 let needle = format!("{key}=");
3649 let start = detail.find(&needle)? + needle.len();
3650 let remainder = &detail[start..];
3651 let end = remainder
3652 .find(char::is_whitespace)
3653 .unwrap_or(remainder.len());
3654 Some(remainder[..end].trim_matches('"'))
3655}
3656
3657#[cfg(test)]
3658mod tests {
3659 use super::*;
3660
3661 fn prompt_cache_adapter_status<'a>(
3662 plan: &'a SessionCostPromptCachePlan,
3663 provider: &str,
3664 ) -> Option<&'a str> {
3665 plan.provider_adapters
3666 .iter()
3667 .find(|adapter| adapter.provider == provider)
3668 .map(|adapter| adapter.status.as_str())
3669 }
3670
3671 #[test]
3672 fn auto_detects_claude_jsonl_and_dedupes_usage_by_message_id() {
3673 let input = concat!(
3674 r#"{"timestamp":"2026-05-05T00:00:01Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
3675 "\n",
3676 r#"{"timestamp":"2026-05-05T00:00:02Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
3677 "\n",
3678 r#"{"timestamp":"2026-05-05T00:00:03Z","requestId":"req-2","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":800,"output_tokens":8}}}"#,
3679 "\n"
3680 );
3681
3682 let report = compute(input, None).unwrap();
3683 assert_eq!(report.source, "claude_jsonl");
3684 assert_eq!(report.usage_samples, 2);
3685 assert_eq!(report.prompt_tokens, 2321);
3686 assert_eq!(report.cached_input_tokens, 2000);
3687 assert_eq!(report.cache_creation_input_tokens, 300);
3688 assert_eq!(report.output_tokens, 18);
3689 assert_eq!(report.total_tokens, 2339);
3690 assert_eq!(report.cached_input_ratio, Some(86.17));
3691 }
3692
3693 #[test]
3694 fn codex_jsonl_uses_cumulative_deltas_and_skips_duplicate_snapshots() {
3695 let input = concat!(
3696 r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
3697 "\n",
3698 r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
3699 "\n",
3700 r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
3701 "\n"
3702 );
3703
3704 let report = compute(input, Some("codex-jsonl")).unwrap();
3705 assert_eq!(report.usage_samples, 2);
3706 assert_eq!(report.prompt_tokens, 1600);
3707 assert_eq!(report.cached_input_tokens, 1400);
3708 assert_eq!(report.output_tokens, 90);
3709 assert_eq!(report.reasoning_output_tokens, 20);
3710 assert_eq!(report.total_tokens, 1690);
3711 assert_eq!(report.largest_turn_total_tokens, 1050);
3712 assert_eq!(report.largest_turns[0].total_tokens, 1050);
3713 assert_eq!(report.largest_turns[1].total_tokens, 640);
3714 }
3715
3716 #[test]
3717 fn codex_jsonl_prefers_last_usage_for_interleaved_cumulative_streams() {
3718 let input = concat!(
3719 r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
3720 "\n",
3721 r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520},"last_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520}}}}"#,
3722 "\n",
3723 r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690},"last_token_usage":{"input_tokens":600,"cached_input_tokens":500,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":640}}}}"#,
3724 "\n",
3725 r#"{"timestamp":"2026-05-05T00:00:04Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
3726 "\n",
3727 r#"{"timestamp":"2026-05-05T00:00:05Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
3728 "\n"
3729 );
3730
3731 let report = compute(input, Some("codex-jsonl")).unwrap();
3732 assert_eq!(report.usage_samples, 4);
3733 assert_eq!(report.prompt_tokens, 2500);
3734 assert_eq!(report.cached_input_tokens, 2200);
3735 assert_eq!(report.output_tokens, 135);
3736 assert_eq!(report.reasoning_output_tokens, 30);
3737 assert_eq!(report.total_tokens, 2635);
3738 assert_eq!(report.largest_turn_total_tokens, 1050);
3739 }
3740
3741 #[test]
3742 fn prompt_cache_plan_summarizes_effectiveness_over_time() {
3743 let input = concat!(
3744 r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
3745 "\n",
3746 r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2000,"cached_input_tokens":600,"output_tokens":100,"reasoning_output_tokens":0,"total_tokens":2100},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":500,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
3747 "\n",
3748 r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":3000,"cached_input_tokens":1500,"output_tokens":150,"reasoning_output_tokens":0,"total_tokens":3150},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
3749 "\n",
3750 );
3751
3752 let report = compute(input, Some("codex-jsonl")).unwrap();
3753 let analytics = report
3754 .prompt_cache_plan
3755 .as_ref()
3756 .and_then(|plan| plan.analytics.as_ref())
3757 .expect("prompt cache analytics should be present");
3758
3759 assert_eq!(analytics.sample_count, 3);
3760 assert!(!analytics.effective);
3761 assert_eq!(analytics.trend, "improving");
3762 assert_eq!(
3763 analytics.average_cached_input_ratio.as_deref(),
3764 Some("50.00%")
3765 );
3766 assert_eq!(
3767 analytics.first_cached_input_ratio.as_deref(),
3768 Some("10.00%")
3769 );
3770 assert_eq!(analytics.last_cached_input_ratio.as_deref(), Some("90.00%"));
3771 assert_eq!(
3772 analytics.cached_input_ratio_delta.as_deref(),
3773 Some("+80.00%")
3774 );
3775 assert_eq!(analytics.net_cached_input_tokens, 1500);
3776 assert_eq!(analytics.timeline.len(), 3);
3777 assert_eq!(
3778 analytics.timeline[2].cached_input_ratio.as_deref(),
3779 Some("90.00%")
3780 );
3781 }
3782
3783 #[test]
3784 fn prompt_cache_timeline_emits_attribution_metadata() {
3785 let input = concat!(
3786 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":900,"output_tokens":10}}}"#,
3787 "\n",
3788 r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1100,"cache_creation_input_tokens":0,"cache_read_input_tokens":1000,"output_tokens":12}}}"#,
3789 "\n",
3790 );
3791
3792 let report = compute(input, Some("claude-jsonl")).unwrap();
3793 let plan = report
3794 .prompt_cache_plan
3795 .as_ref()
3796 .expect("prompt cache plan should be present");
3797 let analytics = report
3798 .prompt_cache_plan
3799 .as_ref()
3800 .and_then(|plan| plan.analytics.as_ref())
3801 .expect("prompt cache analytics should be present");
3802 let first = analytics.timeline[0]
3803 .prompt_cache_metadata
3804 .as_ref()
3805 .expect("timeline should include prompt cache metadata");
3806 let second = analytics.timeline[1]
3807 .prompt_cache_metadata
3808 .as_ref()
3809 .expect("timeline should include prompt cache metadata");
3810
3811 assert_eq!(first.provider, "anthropic");
3812 assert_eq!(first.cache_key.as_deref(), Some("agent-doc:tsift"));
3813 assert_eq!(first.routing_affinity.as_deref(), Some("replica-a"));
3814 assert!(
3817 first
3818 .breakpoints
3819 .iter()
3820 .any(|breakpoint| { breakpoint == "message.content.cache_control=type:ephemeral" })
3821 );
3822 assert!(first.stable_prefix_fingerprint.starts_with("spfx-"));
3823 assert_eq!(
3824 first.stable_prefix_fingerprint,
3825 second.stable_prefix_fingerprint
3826 );
3827 assert_eq!(
3828 prompt_cache_adapter_status(plan, "anthropic"),
3829 Some("cache_control")
3830 );
3831 assert_eq!(
3832 prompt_cache_adapter_status(plan, "replica_local"),
3833 Some("routing_affinity")
3834 );
3835 }
3836
3837 #[test]
3838 fn prompt_cache_plan_marks_missing_provider_adapter_evidence() {
3839 let input = concat!(
3840 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#,
3841 "\n",
3842 );
3843
3844 let report = compute(input, Some("codex-jsonl")).unwrap();
3845 let plan = report
3846 .prompt_cache_plan
3847 .as_ref()
3848 .expect("prompt cache plan should be present");
3849
3850 assert_eq!(
3851 prompt_cache_adapter_status(plan, "openai"),
3852 Some("missing_prompt_cache_key")
3853 );
3854 assert_eq!(
3855 prompt_cache_adapter_status(plan, "replica_local"),
3856 Some("missing_routing_affinity")
3857 );
3858 assert!(plan.actions.iter().any(|action| {
3859 action.kind == "fix_openai_prompt_cache_key"
3860 && action.guidance.contains("prompt_cache_key")
3861 }));
3862 assert!(plan.actions.iter().any(|action| {
3863 action.kind == "fix_replica_routing_affinity"
3864 && action.guidance.contains("same provider replica")
3865 }));
3866
3867 let anthropic = concat!(
3868 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","routing_affinity":"replica-a","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":900,"output_tokens":10}}}"#,
3869 "\n",
3870 );
3871 let report = compute(anthropic, Some("claude-jsonl")).unwrap();
3872 let plan = report
3873 .prompt_cache_plan
3874 .as_ref()
3875 .expect("prompt cache plan should be present");
3876 assert_eq!(
3877 prompt_cache_adapter_status(plan, "anthropic"),
3878 Some("missing_cache_control")
3879 );
3880 assert!(plan.actions.iter().any(|action| {
3881 action.kind == "fix_anthropic_cache_control"
3882 && action.guidance.contains("cache_control")
3883 }));
3884 }
3885
3886 #[test]
3887 fn prompt_cache_plan_marks_routing_affinity_churn() {
3888 let input = concat!(
3889 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#,
3890 "\n",
3891 r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-b","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#,
3892 "\n",
3893 );
3894
3895 let report = compute(input, Some("codex-jsonl")).unwrap();
3896 let plan = report
3897 .prompt_cache_plan
3898 .as_ref()
3899 .expect("prompt cache plan should be present");
3900
3901 assert_eq!(
3902 prompt_cache_adapter_status(plan, "openai"),
3903 Some("prompt_cache_key")
3904 );
3905 assert_eq!(
3906 prompt_cache_adapter_status(plan, "replica_local"),
3907 Some("routing_affinity_churn")
3908 );
3909 assert!(
3910 plan.actions
3911 .iter()
3912 .any(|action| action.kind == "fix_replica_routing_affinity")
3913 );
3914 }
3915
3916 #[test]
3917 fn prompt_cache_plan_classifies_likely_invalidation_diagnostics() {
3918 let input = concat!(
3919 r#"{"timestamp":"2026-05-05T00:00:01Z","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
3920 "\n",
3921 r#"{"timestamp":"2026-05-05T00:00:02Z","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3922 "\n",
3923 r#"{"timestamp":"2026-05-05T00:00:03Z","message":{"id":"msg-3","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3924 "\n",
3925 );
3926
3927 let report = compute(input, Some("claude-jsonl")).unwrap();
3928 let diagnostics = &report
3929 .prompt_cache_plan
3930 .as_ref()
3931 .and_then(|plan| plan.analytics.as_ref())
3932 .expect("prompt cache analytics should be present")
3933 .diagnostics;
3934
3935 assert!(diagnostics.iter().any(|diagnostic| {
3936 diagnostic.kind == "cached_ratio_drop"
3937 && diagnostic.label == "2026-05-05T00:00:02Z"
3938 && diagnostic
3939 .likely_causes
3940 .iter()
3941 .any(|cause| cause.contains("prompt_cache_key"))
3942 }));
3943 assert!(diagnostics.iter().any(|diagnostic| {
3944 diagnostic.kind == "cache_creation_spike" && diagnostic.message.contains("60.00%")
3945 }));
3946 assert!(diagnostics.iter().any(|diagnostic| {
3947 diagnostic.kind == "read_create_regression" && diagnostic.message.contains("0.92x")
3948 }));
3949 }
3950
3951 #[test]
3952 fn prompt_cache_prefix_drift_points_regressions_at_first_changed_field() {
3953 let input = concat!(
3958 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
3959 "\n",
3960 r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift-cold","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3961 "\n",
3962 );
3963
3964 let report = compute(input, Some("claude-jsonl")).unwrap();
3965 let analytics = report
3966 .prompt_cache_plan
3967 .as_ref()
3968 .and_then(|plan| plan.analytics.as_ref())
3969 .expect("prompt cache analytics should be present");
3970
3971 assert_eq!(analytics.prefix_drift.len(), 1);
3972 let drift = &analytics.prefix_drift[0];
3973 assert_eq!(drift.trigger, "cached_ratio_drop_and_cache_creation_spike");
3974 assert_eq!(drift.severity, "warn");
3975 assert_eq!(drift.first_changed_field, "cache_key");
3977 assert!(
3980 drift.remediation.contains("prompt_cache_key"),
3981 "cache_key drift remediation should name the prompt_cache_key fix, got: {}",
3982 drift.remediation
3983 );
3984 assert_eq!(drift.cached_input_ratio_before.as_deref(), Some("90.00%"));
3985 assert_eq!(drift.cached_input_ratio_after.as_deref(), Some("10.00%"));
3986 assert_eq!(drift.cache_creation_ratio.as_deref(), Some("60.00%"));
3987 assert!(
3991 drift
3992 .field_changes
3993 .iter()
3994 .any(|change| change.field == "cache_key"),
3995 "expected drift field cache_key"
3996 );
3997 for field in [
3998 "provider",
3999 "routing_affinity",
4000 "breakpoints",
4001 "stable_prefix",
4002 "stable_prefix_fingerprint",
4003 ] {
4004 assert!(
4005 !drift
4006 .field_changes
4007 .iter()
4008 .any(|change| change.field == field),
4009 "unchanged field {field} must not be reported as drift"
4010 );
4011 }
4012 assert!(analytics.diagnostics.iter().any(|diagnostic| {
4013 diagnostic.kind == "cached_ratio_drop"
4014 && diagnostic
4015 .message
4016 .contains("first changed prompt-cache field: cache_key")
4017 }));
4018 assert!(analytics.diagnostics.iter().any(|diagnostic| {
4019 diagnostic.kind == "cache_creation_spike"
4020 && diagnostic
4021 .likely_causes
4022 .iter()
4023 .any(|cause| cause.contains("first changed prompt-cache field: cache_key"))
4024 }));
4025 }
4026
4027 #[test]
4028 fn prompt_cache_drift_attributes_pure_prefix_content_change_to_stable_prefix() {
4029 let input = concat!(
4035 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
4036 "\n",
4037 r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v2 with edits","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
4038 "\n",
4039 );
4040
4041 let report = compute(input, Some("claude-jsonl")).unwrap();
4042 let analytics = report
4043 .prompt_cache_plan
4044 .as_ref()
4045 .and_then(|plan| plan.analytics.as_ref())
4046 .expect("prompt cache analytics should be present");
4047
4048 assert_eq!(analytics.prefix_drift.len(), 1);
4049 let drift = &analytics.prefix_drift[0];
4050 assert_eq!(drift.first_changed_field, "stable_prefix");
4052 assert_eq!(drift.field_changes.len(), 1);
4057 assert_eq!(drift.field_changes[0].field, "stable_prefix");
4058 assert!(
4061 drift.remediation.contains("below the cache breakpoint"),
4062 "stable_prefix drift remediation should suggest moving volatile content below the cache breakpoint, got: {}",
4063 drift.remediation
4064 );
4065 assert!(
4066 !drift
4067 .field_changes
4068 .iter()
4069 .any(|change| change.field == "stable_prefix_fingerprint"),
4070 "derived fingerprint echo must be suppressed when a sub-field changed"
4071 );
4072 }
4073
4074 #[test]
4075 fn prompt_cache_prefix_drift_remediation_is_field_specific() {
4076 assert!(prompt_cache_prefix_drift_remediation("cache_key").contains("prompt_cache_key"));
4080 assert!(
4081 prompt_cache_prefix_drift_remediation("breakpoints").contains("breakpoint placement")
4082 );
4083 assert!(
4084 prompt_cache_prefix_drift_remediation("routing_affinity").contains("replica affinity")
4085 );
4086 assert!(prompt_cache_prefix_drift_remediation("provider").contains("provider"));
4087 assert!(prompt_cache_prefix_drift_remediation("stable_prefix").contains("prefix bytes"));
4088 assert!(
4089 prompt_cache_prefix_drift_remediation("stable_prefix_fingerprint")
4090 .contains("fingerprint")
4091 );
4092 let fallback = prompt_cache_prefix_drift_remediation("some_new_field");
4094 assert!(fallback.contains("cache breakpoint"));
4095 assert!(!fallback.is_empty());
4096 }
4097
4098 #[test]
4099 fn prompt_cache_drift_attributes_prefix_change_under_explicit_fingerprint() {
4100 let input = concat!(
4106 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix_fingerprint":"provider-fpr-constant","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
4107 "\n",
4108 r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix_fingerprint":"provider-fpr-constant","stable_prefix":"agent-doc stable prefix v2 with edits","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
4109 "\n",
4110 );
4111
4112 let report = compute(input, Some("claude-jsonl")).unwrap();
4113 let analytics = report
4114 .prompt_cache_plan
4115 .as_ref()
4116 .and_then(|plan| plan.analytics.as_ref())
4117 .expect("prompt cache analytics should be present");
4118
4119 assert_eq!(analytics.prefix_drift.len(), 1);
4120 let drift = &analytics.prefix_drift[0];
4121 assert_eq!(drift.first_changed_field, "stable_prefix");
4124 assert_eq!(drift.field_changes.len(), 1);
4125 assert_eq!(drift.field_changes[0].field, "stable_prefix");
4126 assert!(
4127 !drift
4128 .field_changes
4129 .iter()
4130 .any(|change| change.field == "stable_prefix_fingerprint"),
4131 "constant explicit fingerprint must not be reported as drift"
4132 );
4133 }
4134
4135 #[test]
4136 fn prompt_cache_breakpoint_identity_is_position_independent() {
4137 let turn_a = serde_json::json!({
4138 "message": { "content": [
4139 { "type": "text", "text": "sys", "cache_control": { "type": "ephemeral" } }
4140 ]}
4141 });
4142 let turn_b = serde_json::json!({
4144 "message": { "content": [
4145 { "type": "thinking", "text": "..." },
4146 { "type": "text", "text": "sys", "cache_control": { "type": "ephemeral" } }
4147 ]}
4148 });
4149
4150 let mut bp_a = Vec::new();
4151 collect_prompt_cache_breakpoints(&turn_a, "$", &mut bp_a);
4152 let bp_a = aggregate_prompt_cache_breakpoint_counts(bp_a);
4153
4154 let mut bp_b = Vec::new();
4155 collect_prompt_cache_breakpoints(&turn_b, "$", &mut bp_b);
4156 let bp_b = aggregate_prompt_cache_breakpoint_counts(bp_b);
4157
4158 assert_eq!(
4159 bp_a,
4160 vec!["message.content.cache_control=type:ephemeral".to_string()]
4161 );
4162 assert_eq!(
4163 bp_a, bp_b,
4164 "inserting a non-cached block must not change breakpoint identity (#pcachebp)"
4165 );
4166 }
4167
4168 #[test]
4169 fn prompt_cache_breakpoints_keep_count_of_repeated_shapes() {
4170 let turn = serde_json::json!({
4171 "message": { "content": [
4172 { "type": "text", "cache_control": { "type": "ephemeral" } },
4173 { "type": "text", "cache_control": { "type": "ephemeral" } }
4174 ]}
4175 });
4176 let mut bp = Vec::new();
4177 collect_prompt_cache_breakpoints(&turn, "$", &mut bp);
4178 let bp = aggregate_prompt_cache_breakpoint_counts(bp);
4179 assert_eq!(
4180 bp,
4181 vec!["message.content.cache_control=type:ephemeral (x2)".to_string()]
4182 );
4183 }
4184
4185 #[test]
4186 fn prompt_cache_breakpoint_literal_count_suffix_does_not_collide_with_aggregation() {
4187 let literal = aggregate_prompt_cache_breakpoint_counts(vec!["foo (x2)".to_string()]);
4192 let aggregated =
4193 aggregate_prompt_cache_breakpoint_counts(vec!["foo".to_string(), "foo".to_string()]);
4194 assert_ne!(literal, aggregated);
4195 assert_eq!(literal, vec!["foo (\\x2)".to_string()]);
4197 assert_eq!(aggregated, vec!["foo (x2)".to_string()]);
4198 let two_literals = aggregate_prompt_cache_breakpoint_counts(vec![
4201 "foo (x2)".to_string(),
4202 "foo (x2)".to_string(),
4203 ]);
4204 assert_eq!(two_literals, vec!["foo (\\x2) (x2)".to_string()]);
4205 assert_ne!(two_literals, literal);
4206 }
4207
4208 #[test]
4209 fn read_create_regression_survives_diagnostics_truncation() {
4210 let turns: Vec<SessionCostTurn> = (0..8)
4215 .map(|idx| SessionCostTurn {
4216 label: format!("t{idx}"),
4217 prompt_tokens: 1000,
4218 cached_input_tokens: 100,
4219 cache_creation_input_tokens: 500,
4220 output_tokens: 0,
4221 reasoning_output_tokens: 0,
4222 total_tokens: 1100,
4223 prompt_cache_metadata: None,
4224 })
4225 .collect();
4226
4227 let diagnostics = derive_prompt_cache_diagnostics(&turns, 800, 4000);
4228
4229 assert_eq!(diagnostics.len(), MAX_PROMPT_CACHE_DIAGNOSTICS);
4230 assert!(
4233 diagnostics
4234 .iter()
4235 .any(|diagnostic| diagnostic.kind == "read_create_regression"),
4236 "session-level read/create regression must survive diagnostics truncation: {:?}",
4237 diagnostics
4238 .iter()
4239 .map(|diagnostic| diagnostic.kind.as_str())
4240 .collect::<Vec<_>>()
4241 );
4242 }
4243
4244 #[test]
4245 fn prompt_cache_effectiveness_fixture_passes_thresholds() {
4246 let fixture = SessionCostPromptCacheEffectivenessFixture {
4247 schema_version: 1,
4248 description: "fixture".to_string(),
4249 required_regression_scenarios: Vec::new(),
4250 cases: vec![SessionCostPromptCacheEffectivenessCase {
4251 name: "warm-codex-prefix".to_string(),
4252 source: "codex-jsonl".to_string(),
4253 input_lines: vec![
4254 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
4255 r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
4256 ],
4257 minimum_cached_input_ratio: 90.0,
4258 minimum_net_cached_input_tokens: 40_000,
4259 maximum_read_create_regressions: 0,
4260 regression_scenarios: Vec::new(),
4261 required_prefix_drift_fields: Vec::new(),
4262 required_diagnostics: Vec::new(),
4263 }],
4264 };
4265
4266 let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4267
4268 assert!(report.pass);
4269 assert_eq!(report.totals.passed, 1);
4270 assert_eq!(report.totals.failed, 0);
4271 assert_eq!(report.cases[0].status, "pass");
4272 assert_eq!(report.cases[0].cached_input_ratio, Some(96.0));
4273 assert_eq!(report.cases[0].net_cached_input_tokens, 48_000);
4274 assert_eq!(report.cases[0].read_create_regressions, 0);
4275 }
4276
4277 #[test]
4278 fn prompt_cache_effectiveness_fixture_fails_missing_adapter_evidence() {
4279 let fixture = SessionCostPromptCacheEffectivenessFixture {
4280 schema_version: 1,
4281 description: "fixture".to_string(),
4282 required_regression_scenarios: Vec::new(),
4283 cases: vec![
4284 SessionCostPromptCacheEffectivenessCase {
4285 name: "missing-openai-key".to_string(),
4286 source: "codex-jsonl".to_string(),
4287 input_lines: vec![
4288 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
4289 r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
4290 ],
4291 minimum_cached_input_ratio: 90.0,
4292 minimum_net_cached_input_tokens: 40_000,
4293 maximum_read_create_regressions: 0,
4294 regression_scenarios: Vec::new(),
4295 required_prefix_drift_fields: Vec::new(),
4296 required_diagnostics: Vec::new(),
4297 },
4298 SessionCostPromptCacheEffectivenessCase {
4299 name: "missing-anthropic-cache-control".to_string(),
4300 source: "claude-jsonl".to_string(),
4301 input_lines: vec![
4302 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":9000,"output_tokens":10}}}"#.to_string(),
4303 r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1100,"cache_creation_input_tokens":0,"cache_read_input_tokens":10000,"output_tokens":12}}}"#.to_string(),
4304 ],
4305 minimum_cached_input_ratio: 70.0,
4306 minimum_net_cached_input_tokens: 1,
4307 maximum_read_create_regressions: 0,
4308 regression_scenarios: Vec::new(),
4309 required_prefix_drift_fields: Vec::new(),
4310 required_diagnostics: Vec::new(),
4311 },
4312 ],
4313 };
4314
4315 let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4316
4317 assert!(!report.pass);
4318 assert_eq!(report.totals.failed, 2);
4319 assert!(report.cases[0].failures.iter().any(|failure| {
4320 failure.contains("OpenAI prompt_cache_key")
4321 && failure.contains("missing_prompt_cache_key")
4322 }));
4323 assert!(report.cases[0].failures.iter().any(|failure| {
4324 failure.contains("replica-local routing_affinity")
4325 && failure.contains("missing_routing_affinity")
4326 }));
4327 assert!(report.cases[1].failures.iter().any(|failure| {
4328 failure.contains("Anthropic cache_control") && failure.contains("missing_cache_control")
4329 }));
4330 }
4331
4332 #[test]
4333 fn prompt_cache_effectiveness_fixture_fails_read_create_regression() {
4334 let fixture = SessionCostPromptCacheEffectivenessFixture {
4335 schema_version: 1,
4336 description: "fixture".to_string(),
4337 required_regression_scenarios: Vec::new(),
4338 cases: vec![SessionCostPromptCacheEffectivenessCase {
4339 name: "cold-rewrite".to_string(),
4340 source: "claude-jsonl".to_string(),
4341 input_lines: vec![
4342 r#"{"timestamp":"2026-05-05T00:00:01Z","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#.to_string(),
4343 r#"{"timestamp":"2026-05-05T00:00:02Z","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#.to_string(),
4344 r#"{"timestamp":"2026-05-05T00:00:03Z","message":{"id":"msg-3","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#.to_string(),
4345 ],
4346 minimum_cached_input_ratio: 70.0,
4347 minimum_net_cached_input_tokens: 1,
4348 maximum_read_create_regressions: 0,
4349 regression_scenarios: Vec::new(),
4350 required_prefix_drift_fields: Vec::new(),
4351 required_diagnostics: Vec::new(),
4352 }],
4353 };
4354
4355 let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4356
4357 assert!(!report.pass);
4358 assert_eq!(report.totals.failed, 1);
4359 assert_eq!(report.cases[0].status, "fail");
4360 assert_eq!(report.cases[0].read_create_regressions, 1);
4361 assert!(
4362 report.cases[0]
4363 .failures
4364 .iter()
4365 .any(|failure| failure.contains("read_create_regressions"))
4366 );
4367 }
4368
4369 #[test]
4370 fn prompt_cache_effectiveness_fixture_requires_regression_coverage_and_drift_fields() {
4371 let fixture = SessionCostPromptCacheEffectivenessFixture {
4372 schema_version: 1,
4373 description: "fixture".to_string(),
4374 required_regression_scenarios: vec![
4375 "volatile_prefix_generated_header".to_string(),
4376 "openai_prompt_cache_key_churn".to_string(),
4377 ],
4378 cases: vec![SessionCostPromptCacheEffectivenessCase {
4379 name: "volatile-prefix".to_string(),
4380 source: "codex-jsonl".to_string(),
4381 input_lines: vec![
4382 r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix\nGenerated: 2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
4383 r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix\nGenerated: 2026-05-05T00:00:04Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
4384 ],
4385 minimum_cached_input_ratio: 90.0,
4386 minimum_net_cached_input_tokens: 40_000,
4387 maximum_read_create_regressions: 0,
4388 regression_scenarios: vec!["volatile_prefix_generated_header".to_string()],
4389 required_prefix_drift_fields: vec!["stable_prefix".to_string()],
4393 required_diagnostics: Vec::new(),
4394 }],
4395 };
4396
4397 let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4398
4399 assert!(!report.pass);
4400 assert_eq!(
4401 report.missing_regression_scenarios,
4402 vec!["openai_prompt_cache_key_churn".to_string()]
4403 );
4404 assert_eq!(
4405 report.covered_regression_scenarios,
4406 vec!["volatile_prefix_generated_header".to_string()]
4407 );
4408 assert!(report.cases[0].failures.is_empty());
4409 }
4410
4411 #[test]
4412 fn agent_doc_log_summarizes_runtime_churn() {
4413 let input = "\
4414[1776452736] claude_start mode=fresh restart_count=0
4415[1776528398] claude_start mode=fresh_restart restart_count=1
4416[1776528446] auto_trigger_timeout (no prompt after 30s)
4417[1776528450] ctrl_d_restart_fresh restart_count=2
4418[1776528582] claude_start mode=fresh_restart restart_count=2
4419[1776528599] codex_start mode=continue restart_count=3
4420[1776528601] user_quit_after_ctrl_d
4421[1776528602] commit_already_current file=tasks/software/tsift.md basis=head
4422[1776528603] commit_already_current file=tasks/software/tsift.md basis=head
4423[1776528604] commit_already_current file=tasks/software/tsift.md basis=head
4424";
4425
4426 let report = compute(input, Some("agent-doc-log")).unwrap();
4427 assert_eq!(report.source, "agent_doc_log");
4428 assert_eq!(report.usage_samples, 0);
4429 assert_eq!(report.runtime_event_groups, 7);
4430 assert_eq!(report.total_runtime_events, 10);
4431 assert_eq!(report.restart_churn_groups, 4);
4432 assert_eq!(report.max_restart_count, Some(3));
4433 assert!(
4434 report
4435 .runtime_events
4436 .iter()
4437 .any(|event| event.event == "claude_start:fresh_restart" && event.occurrences == 2)
4438 );
4439 assert!(
4440 report
4441 .runtime_events
4442 .iter()
4443 .any(|event| event.event == "auto_trigger_timeout" && event.occurrences == 1)
4444 );
4445 assert!(
4446 report
4447 .restart_churn
4448 .iter()
4449 .any(|entry| entry.family == "fresh_restart" && entry.occurrences == 3)
4450 );
4451 assert!(
4452 report
4453 .restart_churn
4454 .iter()
4455 .any(|entry| entry.family == "ctrl_d_restart_loop" && entry.occurrences == 1)
4456 );
4457 assert!(
4458 report
4459 .restart_churn
4460 .iter()
4461 .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 1)
4462 );
4463 assert!(
4464 report
4465 .guardrails
4466 .iter()
4467 .any(|guardrail| guardrail.kind == "restart_loop")
4468 );
4469 assert!(
4470 report
4471 .guardrails
4472 .iter()
4473 .any(|guardrail| guardrail.kind == "noop_closeout")
4474 );
4475 assert!(
4476 report
4477 .loop_clusters
4478 .iter()
4479 .any(|cluster| cluster.kind == "closeout_churn"
4480 && cluster.label == "commit_already_current"
4481 && cluster.occurrences == 3)
4482 );
4483 }
4484
4485 #[test]
4486 fn agent_doc_log_dedupes_document_cycle_runtime_events_by_cycle() {
4487 let input = "\
4488[1777603275] document_cycle phase=response_captured cycle=cycle-1 event=response_captured capture_id=cycle-1
4489[1777603276] document_cycle phase=committed cycle=cycle-1 event=commit_success capture_id=cycle-1
4490[1777603403] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
4491[1777603404] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
4492[1777603405] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
4493[1777603500] document_cycle phase=preflight_started cycle=cycle-2 event=preflight_started
4494[1777603600] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
4495[1777603601] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
4496[1777603700] document_cycle phase=committed cycle=cycle-3 event=commit_already_current
4497";
4498
4499 let report = compute(input, Some("agent-doc-log")).unwrap();
4500
4501 assert_eq!(report.total_runtime_events, 6);
4502 assert!(
4503 report
4504 .runtime_events
4505 .iter()
4506 .any(|event| event.event == "commit_already_current" && event.occurrences == 3)
4507 );
4508 assert!(
4509 report
4510 .runtime_events
4511 .iter()
4512 .any(|event| event.event == "commit_success" && event.occurrences == 1)
4513 );
4514 assert!(
4515 report
4516 .runtime_events
4517 .iter()
4518 .any(|event| event.event == "response_captured" && event.occurrences == 1)
4519 );
4520 assert!(
4521 report
4522 .guardrails
4523 .iter()
4524 .any(|guardrail| guardrail.kind == "noop_closeout")
4525 );
4526 assert!(
4527 report
4528 .loop_clusters
4529 .iter()
4530 .any(|cluster| cluster.kind == "closeout_churn"
4531 && cluster.label == "commit_already_current"
4532 && cluster.occurrences == 3)
4533 );
4534 }
4535
4536 #[test]
4537 fn codex_jsonl_surfaces_prompt_and_command_loop_clusters() {
4538 let input = concat!(
4539 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
4540 "\n",
4541 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4542 "\n",
4543 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
4544 "\n",
4545 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
4546 "\n",
4547 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
4548 "\n",
4549 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4550 "\n",
4551 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
4552 "\n",
4553 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
4554 "\n"
4555 );
4556
4557 let report = compute(input, Some("codex-jsonl")).unwrap();
4558
4559 assert!(
4560 report
4561 .loop_clusters
4562 .iter()
4563 .any(|cluster| cluster.kind == "prompt_repeat"
4564 && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
4565 && cluster.occurrences == 2)
4566 );
4567 assert!(
4568 report
4569 .loop_clusters
4570 .iter()
4571 .any(|cluster| cluster.kind == "command_bundle"
4572 && cluster.label == "cargo test -> cargo build --release"
4573 && cluster.occurrences == 2)
4574 );
4575 assert!(report.loop_clusters.iter().any(|cluster| {
4576 cluster.kind == "closeout_churn"
4577 && cluster
4578 .label
4579 .contains("Committed and pushed in `src/tsift`")
4580 && cluster.occurrences == 2
4581 }));
4582 }
4583
4584 #[test]
4585 fn codex_jsonl_surfaces_repeated_file_read_diagnostics() {
4586 let input = concat!(
4587 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
4588 "\n",
4589 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
4590 "\n",
4591 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
4592 "\n",
4593 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
4594 "\n"
4595 );
4596
4597 let report = compute(input, Some("codex-jsonl")).unwrap();
4598
4599 assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
4600 diagnostic.path == "src/session_cost.rs"
4601 && diagnostic.range == "1-220"
4602 && diagnostic.occurrences == 2
4603 && diagnostic.duplicate_estimated_tokens == 3_960
4604 && diagnostic.follow_up_commands.iter().any(|command| {
4605 command == "tsift source-read src/session_cost.rs --start 1 --lines 220 --budget normal"
4606 })
4607 }));
4608 assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
4609 diagnostic.path == "src/main.rs"
4610 && diagnostic.range == "full"
4611 && diagnostic.duplicate_estimated_tokens == 4_000
4612 && diagnostic
4613 .follow_up_commands
4614 .iter()
4615 .any(|command| command == "tsift summarize --file src/main.rs")
4616 }));
4617 }
4618
4619 #[test]
4620 fn claude_jsonl_surfaces_repeated_native_read_tool_diagnostics() {
4621 let input = concat!(
4622 r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
4623 "\n",
4624 r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
4625 "\n"
4626 );
4627
4628 let report = compute(input, Some("claude-jsonl")).unwrap();
4629
4630 assert_eq!(report.file_read_diagnostics.len(), 1);
4631 let diagnostic = &report.file_read_diagnostics[0];
4632 assert_eq!(diagnostic.path, "src/lib.rs");
4633 assert_eq!(diagnostic.range, "40-119");
4634 assert_eq!(diagnostic.occurrences, 2);
4635 assert_eq!(diagnostic.duplicate_estimated_tokens, 1_440);
4636 assert!(diagnostic.follow_up_commands.iter().any(|command| {
4637 command == "tsift source-read src/lib.rs --start 40 --lines 80 --budget normal"
4638 }));
4639 }
4640
4641 #[test]
4642 fn derive_guardrails_flags_large_prompt_turns() {
4643 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
4644 largest_prompt_turn_tokens: 140_000,
4645 largest_prompt_turn_label: Some("2026-05-05T00:00:01Z".to_string()),
4646 ..SessionCostGuardrailInput::default()
4647 });
4648
4649 assert!(
4650 guardrails
4651 .iter()
4652 .any(|guardrail| guardrail.kind == "prompt_budget")
4653 );
4654 }
4655
4656 #[test]
4657 fn derive_guardrails_flags_cached_resend_ratio() {
4658 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
4659 prompt_tokens: 80_000,
4660 cached_input_ratio: Some(96.0),
4661 ..SessionCostGuardrailInput::default()
4662 });
4663
4664 assert!(
4665 guardrails
4666 .iter()
4667 .any(|guardrail| guardrail.kind == "cache_resend")
4668 );
4669 }
4670
4671 #[test]
4672 fn derive_guardrails_ignores_restart_count_without_churn() {
4673 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
4674 max_restart_count: Some(3),
4675 ..SessionCostGuardrailInput::default()
4676 });
4677
4678 assert!(
4679 guardrails
4680 .iter()
4681 .all(|guardrail| guardrail.kind != "restart_loop")
4682 );
4683 }
4684}