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_BREAKPOINTS: usize = 8;
16const MAX_COMMANDS_PER_BUNDLE: usize = 6;
17const PROMPT_BUDGET_WARN_TOKENS: u64 = 100_000;
18const CACHED_RATIO_WARN_PERCENT: f64 = 90.0;
19const CACHED_RATIO_WARN_PROMPT_TOKENS: u64 = 50_000;
20const PROMPT_CACHE_CANDIDATE_TOKENS: u64 = 16_000;
21const PROMPT_CACHE_GOOD_HIT_PERCENT: f64 = 75.0;
22const PROMPT_CACHE_TREND_DELTA_PERCENT: f64 = 5.0;
23const PROMPT_CACHE_RATIO_DROP_WARN_PERCENT: f64 = 20.0;
24const PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT: f64 = 20.0;
25const PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO: f64 = 2.0;
26const RESTART_LOOP_WARN_OCCURRENCES: usize = 3;
27const NOOP_CLOSEOUT_WARN_OCCURRENCES: usize = 3;
28const DEFAULT_FULL_FILE_READ_TOKENS: u64 = 4_000;
29const ESTIMATED_TOKENS_PER_SOURCE_LINE: u64 = 18;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum SessionCostSource {
34 ClaudeJsonl,
35 CodexJsonl,
36 AgentDocLog,
37}
38
39impl SessionCostSource {
40 pub fn parse(raw: &str) -> Result<Self> {
41 match raw.trim().to_ascii_lowercase().as_str() {
42 "claude" | "claude-jsonl" => Ok(Self::ClaudeJsonl),
43 "codex" | "codex-jsonl" => Ok(Self::CodexJsonl),
44 "agent-doc-log" | "agent_doc_log" | "log" => Ok(Self::AgentDocLog),
45 other => bail!(
46 "unsupported session-cost source `{other}`; expected claude-jsonl, codex-jsonl, or agent-doc-log"
47 ),
48 }
49 }
50
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::ClaudeJsonl => "claude_jsonl",
54 Self::CodexJsonl => "codex_jsonl",
55 Self::AgentDocLog => "agent_doc_log",
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct SessionCostPromptCacheMetadata {
62 pub provider: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub cache_key: Option<String>,
65 pub stable_prefix_fingerprint: String,
66 #[serde(skip_serializing_if = "Vec::is_empty", default)]
67 pub breakpoints: Vec<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub routing_affinity: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct SessionCostTurn {
74 pub label: String,
75 pub prompt_tokens: u64,
76 pub cached_input_tokens: u64,
77 pub cache_creation_input_tokens: u64,
78 pub output_tokens: u64,
79 pub reasoning_output_tokens: u64,
80 pub total_tokens: u64,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86pub struct SessionCostRuntimeEvent {
87 pub event: String,
88 pub occurrences: usize,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct SessionCostGuardrail {
93 pub kind: String,
94 pub severity: String,
95 pub message: String,
96 pub guidance: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100pub struct SessionCostPromptCachePlan {
101 pub status: String,
102 pub feasible: bool,
103 pub observed_cached_input_tokens: u64,
104 pub observed_cache_creation_tokens: u64,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub observed_cached_input_ratio: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub analytics: Option<SessionCostPromptCacheAnalytics>,
109 pub invariants: Vec<String>,
110 pub provider_adapters: Vec<SessionCostPromptCacheProvider>,
111 pub actions: Vec<SessionCostPromptCacheAction>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct SessionCostPromptCacheProvider {
116 pub provider: String,
117 pub status: String,
118 pub requirements: Vec<String>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122pub struct SessionCostPromptCacheAction {
123 pub kind: String,
124 pub severity: String,
125 pub message: String,
126 pub guidance: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct SessionCostPromptCacheAnalytics {
131 pub sample_count: usize,
132 pub effective: bool,
133 pub trend: String,
134 pub total_prompt_tokens: u64,
135 pub total_cached_input_tokens: u64,
136 pub total_cache_creation_tokens: u64,
137 pub net_cached_input_tokens: i64,
138 pub timeline_truncated: bool,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub average_cached_input_ratio: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub first_cached_input_ratio: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub last_cached_input_ratio: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub cached_input_ratio_delta: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub cache_read_to_creation_ratio: Option<String>,
149 #[serde(skip_serializing_if = "Vec::is_empty", default)]
150 pub diagnostics: Vec<SessionCostPromptCacheDiagnostic>,
151 pub timeline: Vec<SessionCostPromptCacheTimelineEntry>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub struct SessionCostPromptCacheDiagnostic {
156 pub kind: String,
157 pub severity: String,
158 pub label: String,
159 pub message: String,
160 pub likely_causes: Vec<String>,
161 pub guidance: String,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct SessionCostPromptCacheTimelineEntry {
166 pub label: String,
167 pub prompt_tokens: u64,
168 pub cached_input_tokens: u64,
169 pub cache_creation_input_tokens: u64,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub cached_input_ratio: Option<String>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub cache_creation_ratio: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct SessionCostLoopCluster {
180 pub kind: String,
181 pub label: String,
182 pub occurrences: usize,
183 pub max_consecutive: usize,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct SessionCostFileReadDiagnostic {
188 pub path: String,
189 pub range: String,
190 pub occurrences: usize,
191 pub estimated_tokens: u64,
192 pub duplicate_estimated_tokens: u64,
193 pub follow_up_commands: Vec<String>,
194}
195
196#[derive(Debug, Clone, Default)]
197pub struct SessionCostGuardrailInput {
198 pub largest_prompt_turn_tokens: u64,
199 pub largest_prompt_turn_label: Option<String>,
200 pub prompt_tokens: u64,
201 pub cached_input_ratio: Option<f64>,
202 pub fresh_restart_occurrences: usize,
203 pub auto_trigger_timeout_occurrences: usize,
204 pub ctrl_d_restart_loop_occurrences: usize,
205 pub noop_closeout_occurrences: usize,
206 pub max_restart_count: Option<usize>,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize)]
210pub struct SessionCostReport {
211 pub source: String,
212 pub record_count: usize,
213 pub usage_samples: usize,
214 pub prompt_tokens: u64,
215 pub cached_input_tokens: u64,
216 pub cache_creation_input_tokens: u64,
217 pub output_tokens: u64,
218 pub reasoning_output_tokens: u64,
219 pub total_tokens: u64,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub cached_input_ratio: Option<f64>,
222 pub largest_turn_total_tokens: u64,
223 pub runtime_event_groups: usize,
224 pub total_runtime_events: usize,
225 pub restart_churn_groups: usize,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub max_restart_count: Option<usize>,
228 pub largest_turns: Vec<SessionCostTurn>,
229 pub runtime_events: Vec<SessionCostRuntimeEvent>,
230 #[serde(skip_serializing_if = "Vec::is_empty", default)]
231 pub loop_clusters: Vec<SessionCostLoopCluster>,
232 #[serde(skip_serializing_if = "Vec::is_empty", default)]
233 pub file_read_diagnostics: Vec<SessionCostFileReadDiagnostic>,
234 #[serde(skip_serializing_if = "Vec::is_empty", default)]
235 pub restart_churn: Vec<RestartChurnSummary>,
236 #[serde(skip_serializing_if = "Vec::is_empty", default)]
237 pub guardrails: Vec<SessionCostGuardrail>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub prompt_cache_plan: Option<SessionCostPromptCachePlan>,
240 #[serde(skip_serializing_if = "Vec::is_empty", default)]
241 pub warnings: Vec<String>,
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct SessionCostPromptCacheEffectivenessFixture {
246 pub schema_version: u64,
247 #[serde(default)]
248 pub description: String,
249 pub cases: Vec<SessionCostPromptCacheEffectivenessCase>,
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct SessionCostPromptCacheEffectivenessCase {
254 pub name: String,
255 pub source: String,
256 pub input_lines: Vec<String>,
257 pub minimum_cached_input_ratio: f64,
258 pub minimum_net_cached_input_tokens: i64,
259 pub maximum_read_create_regressions: usize,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize)]
263pub struct SessionCostPromptCacheEffectivenessReport {
264 pub schema_version: u64,
265 pub pass: bool,
266 pub totals: SessionCostPromptCacheEffectivenessTotals,
267 pub cases: Vec<SessionCostPromptCacheEffectivenessCaseReport>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
271pub struct SessionCostPromptCacheEffectivenessTotals {
272 pub cases: usize,
273 pub passed: usize,
274 pub failed: usize,
275 pub prompt_tokens: u64,
276 pub cached_input_tokens: u64,
277 pub cache_creation_input_tokens: u64,
278 pub net_cached_input_tokens: i64,
279 pub read_create_regressions: usize,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize)]
283pub struct SessionCostPromptCacheEffectivenessCaseReport {
284 pub name: String,
285 pub source: String,
286 pub status: String,
287 pub prompt_tokens: u64,
288 pub cached_input_tokens: u64,
289 pub cache_creation_input_tokens: u64,
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub cached_input_ratio: Option<f64>,
292 pub minimum_cached_input_ratio: f64,
293 pub net_cached_input_tokens: i64,
294 pub minimum_net_cached_input_tokens: i64,
295 pub read_create_regressions: usize,
296 pub maximum_read_create_regressions: usize,
297 #[serde(skip_serializing_if = "Vec::is_empty", default)]
298 pub failures: Vec<String>,
299}
300
301#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
302struct UsageTotals {
303 prompt_tokens: u64,
304 cached_input_tokens: u64,
305 cache_creation_input_tokens: u64,
306 output_tokens: u64,
307 reasoning_output_tokens: u64,
308 total_tokens: u64,
309}
310
311impl UsageTotals {
312 fn delta_from(self, previous: Self) -> Self {
313 Self {
314 prompt_tokens: self.prompt_tokens.saturating_sub(previous.prompt_tokens),
315 cached_input_tokens: self
316 .cached_input_tokens
317 .saturating_sub(previous.cached_input_tokens),
318 cache_creation_input_tokens: self
319 .cache_creation_input_tokens
320 .saturating_sub(previous.cache_creation_input_tokens),
321 output_tokens: self.output_tokens.saturating_sub(previous.output_tokens),
322 reasoning_output_tokens: self
323 .reasoning_output_tokens
324 .saturating_sub(previous.reasoning_output_tokens),
325 total_tokens: self.total_tokens.saturating_sub(previous.total_tokens),
326 }
327 }
328
329 fn is_zero(self) -> bool {
330 self.prompt_tokens == 0
331 && self.cached_input_tokens == 0
332 && self.cache_creation_input_tokens == 0
333 && self.output_tokens == 0
334 && self.reasoning_output_tokens == 0
335 && self.total_tokens == 0
336 }
337}
338
339#[derive(Debug, Default)]
340struct CostState {
341 warnings: Vec<String>,
342 usage_turns: Vec<SessionCostTurn>,
343 runtime_events: BTreeMap<String, usize>,
344 seen_document_cycle_events: BTreeSet<(String, String)>,
345 total_runtime_events: usize,
346 max_restart_count: Option<usize>,
347 restart_churn: RestartChurnState,
348 pending_commands: Vec<String>,
349 loop_signals: Vec<LoopSignal>,
350 file_read_signals: Vec<FileReadSignal>,
351}
352
353#[derive(Debug, Default)]
354struct PromptCacheAdapterEvidence {
355 anthropic_samples: usize,
356 anthropic_cache_control_samples: usize,
357 openai_samples: usize,
358 openai_prompt_cache_key_samples: usize,
359 openai_prompt_cache_keys: BTreeSet<String>,
360 routed_provider_samples: usize,
361 routing_affinity_samples: usize,
362 routing_affinity_values: BTreeSet<String>,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
366struct LoopSignal {
367 kind: LoopClusterKind,
368 label: String,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372struct FileReadSignal {
373 path: String,
374 range: String,
375 start: Option<usize>,
376 lines: Option<usize>,
377 estimated_tokens: u64,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
381enum LoopClusterKind {
382 PromptRepeat,
383 CommandBundle,
384 CloseoutChurn,
385}
386
387impl LoopClusterKind {
388 fn as_str(self) -> &'static str {
389 match self {
390 Self::PromptRepeat => "prompt_repeat",
391 Self::CommandBundle => "command_bundle",
392 Self::CloseoutChurn => "closeout_churn",
393 }
394 }
395}
396
397#[derive(Debug, Clone)]
398enum TranscriptBlock {
399 Text { role: Option<String>, text: String },
400 ToolUse { name: String, input: Value },
401}
402
403pub fn compute(input: &str, source_hint: Option<&str>) -> Result<SessionCostReport> {
404 if input.trim().is_empty() {
405 bail!(
406 "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
407 );
408 }
409
410 let source = resolve_source(input, source_hint)?;
411 let mut state = CostState::default();
412 let record_count = input.lines().filter(|line| !line.trim().is_empty()).count();
413
414 match source {
415 SessionCostSource::ClaudeJsonl => ingest_claude_jsonl(input, &mut state)?,
416 SessionCostSource::CodexJsonl => ingest_codex_jsonl(input, &mut state)?,
417 SessionCostSource::AgentDocLog => ingest_agent_doc_log(input, &mut state),
418 }
419
420 let usage_samples = state.usage_turns.len();
421 let mut prompt_tokens = 0_u64;
422 let mut cached_input_tokens = 0_u64;
423 let mut cache_creation_input_tokens = 0_u64;
424 let mut output_tokens = 0_u64;
425 let mut reasoning_output_tokens = 0_u64;
426 let mut total_tokens = 0_u64;
427 let mut largest_turn_total_tokens = 0_u64;
428 for turn in &state.usage_turns {
429 prompt_tokens += turn.prompt_tokens;
430 cached_input_tokens += turn.cached_input_tokens;
431 cache_creation_input_tokens += turn.cache_creation_input_tokens;
432 output_tokens += turn.output_tokens;
433 reasoning_output_tokens += turn.reasoning_output_tokens;
434 total_tokens += turn.total_tokens;
435 largest_turn_total_tokens = largest_turn_total_tokens.max(turn.total_tokens);
436 }
437
438 let cached_input_ratio = (prompt_tokens > 0).then_some(
439 ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
440 );
441 let largest_prompt_turn = state
442 .usage_turns
443 .iter()
444 .max_by(|left, right| {
445 left.prompt_tokens
446 .cmp(&right.prompt_tokens)
447 .then(left.label.cmp(&right.label))
448 })
449 .map(|turn| (turn.prompt_tokens, turn.label.clone()));
450 let noop_closeout_occurrences = state
451 .runtime_events
452 .get("commit_already_current")
453 .copied()
454 .unwrap_or(0);
455 flush_pending_commands(&mut state);
456 let loop_clusters = collect_loop_clusters(&state.loop_signals);
457 let file_read_diagnostics = collect_file_read_diagnostics(&state.file_read_signals);
458 let prompt_cache_plan = derive_prompt_cache_plan(
459 prompt_tokens,
460 cached_input_tokens,
461 cache_creation_input_tokens,
462 cached_input_ratio,
463 &state.usage_turns,
464 );
465
466 let mut largest_turns = state.usage_turns;
467 largest_turns.sort_by(|left, right| {
468 right
469 .total_tokens
470 .cmp(&left.total_tokens)
471 .then(right.prompt_tokens.cmp(&left.prompt_tokens))
472 .then(left.label.cmp(&right.label))
473 });
474 largest_turns.truncate(MAX_LARGEST_TURNS);
475
476 let mut runtime_events = state
477 .runtime_events
478 .into_iter()
479 .map(|(event, occurrences)| SessionCostRuntimeEvent { event, occurrences })
480 .collect::<Vec<_>>();
481 runtime_events.sort_by(|left, right| {
482 right
483 .occurrences
484 .cmp(&left.occurrences)
485 .then(left.event.cmp(&right.event))
486 });
487 let runtime_event_groups = runtime_events.len();
488 runtime_events.truncate(MAX_RUNTIME_EVENTS);
489 let restart_churn_groups = state.restart_churn.groups();
490 let restart_churn = state.restart_churn.summaries();
491 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
492 largest_prompt_turn_tokens: largest_prompt_turn.as_ref().map_or(0, |turn| turn.0),
493 largest_prompt_turn_label: largest_prompt_turn.as_ref().map(|turn| turn.1.clone()),
494 prompt_tokens,
495 cached_input_ratio,
496 fresh_restart_occurrences: count_restart_family(&restart_churn, "fresh_restart"),
497 auto_trigger_timeout_occurrences: count_restart_family(
498 &restart_churn,
499 "auto_trigger_timeout",
500 ),
501 ctrl_d_restart_loop_occurrences: count_restart_family(
502 &restart_churn,
503 "ctrl_d_restart_loop",
504 ),
505 noop_closeout_occurrences,
506 max_restart_count: state.max_restart_count,
507 });
508
509 if usage_samples == 0 && runtime_event_groups == 0 {
510 state
511 .warnings
512 .push("no cost or runtime signals were detected in the provided input".to_string());
513 }
514
515 Ok(SessionCostReport {
516 source: source.as_str().to_string(),
517 record_count,
518 usage_samples,
519 prompt_tokens,
520 cached_input_tokens,
521 cache_creation_input_tokens,
522 output_tokens,
523 reasoning_output_tokens,
524 total_tokens,
525 cached_input_ratio,
526 largest_turn_total_tokens,
527 runtime_event_groups,
528 total_runtime_events: state.total_runtime_events,
529 restart_churn_groups,
530 max_restart_count: state.max_restart_count,
531 largest_turns,
532 runtime_events,
533 loop_clusters,
534 file_read_diagnostics,
535 restart_churn,
536 guardrails,
537 prompt_cache_plan,
538 warnings: state.warnings,
539 })
540}
541
542pub fn build_prompt_cache_effectiveness_report(
543 fixture: &SessionCostPromptCacheEffectivenessFixture,
544) -> Result<SessionCostPromptCacheEffectivenessReport> {
545 if fixture.cases.is_empty() {
546 bail!("prompt-cache effectiveness fixture has no cases");
547 }
548
549 let mut cases = Vec::new();
550 let mut totals = SessionCostPromptCacheEffectivenessTotals {
551 cases: 0,
552 passed: 0,
553 failed: 0,
554 prompt_tokens: 0,
555 cached_input_tokens: 0,
556 cache_creation_input_tokens: 0,
557 net_cached_input_tokens: 0,
558 read_create_regressions: 0,
559 };
560
561 for case in &fixture.cases {
562 if case.input_lines.is_empty() {
563 bail!(
564 "prompt-cache fixture case `{}` has no input_lines",
565 case.name
566 );
567 }
568 let input = format!("{}\n", case.input_lines.join("\n"));
569 let report = compute(&input, Some(&case.source))
570 .map_err(|err| err.context(format!("evaluating prompt-cache fixture {}", case.name)))?;
571 let analytics = report
572 .prompt_cache_plan
573 .as_ref()
574 .and_then(|plan| plan.analytics.as_ref());
575 let net_cached_input_tokens = analytics.map_or(
576 signed_token_delta(
577 report.cached_input_tokens,
578 report.cache_creation_input_tokens,
579 ),
580 |analytics| analytics.net_cached_input_tokens,
581 );
582 let read_create_regressions = analytics.map_or(0, |analytics| {
583 analytics
584 .diagnostics
585 .iter()
586 .filter(|diagnostic| diagnostic.kind == "read_create_regression")
587 .count()
588 });
589
590 let mut failures = Vec::new();
591 if report.prompt_cache_plan.is_none() {
592 failures.push("missing prompt_cache_plan".to_string());
593 }
594 if analytics.is_none() {
595 failures.push("missing prompt_cache_plan.analytics".to_string());
596 }
597 match report.cached_input_ratio {
598 Some(ratio) if ratio >= case.minimum_cached_input_ratio => {}
599 Some(ratio) => failures.push(format!(
600 "cached_input_ratio {:.2}% below required {:.2}%",
601 ratio, case.minimum_cached_input_ratio
602 )),
603 None => failures.push(format!(
604 "cached_input_ratio missing; required {:.2}%",
605 case.minimum_cached_input_ratio
606 )),
607 }
608 if net_cached_input_tokens < case.minimum_net_cached_input_tokens {
609 failures.push(format!(
610 "net_cached_input_tokens {} below required {}",
611 net_cached_input_tokens, case.minimum_net_cached_input_tokens
612 ));
613 }
614 if read_create_regressions > case.maximum_read_create_regressions {
615 failures.push(format!(
616 "read_create_regressions {} exceeded allowed {}",
617 read_create_regressions, case.maximum_read_create_regressions
618 ));
619 }
620 failures.extend(prompt_cache_provider_adapter_failures(
621 case,
622 report.prompt_cache_plan.as_ref(),
623 ));
624
625 let status = if failures.is_empty() {
626 "pass".to_string()
627 } else {
628 "fail".to_string()
629 };
630 totals.cases += 1;
631 if status == "pass" {
632 totals.passed += 1;
633 } else {
634 totals.failed += 1;
635 }
636 totals.prompt_tokens += report.prompt_tokens;
637 totals.cached_input_tokens += report.cached_input_tokens;
638 totals.cache_creation_input_tokens += report.cache_creation_input_tokens;
639 totals.net_cached_input_tokens += net_cached_input_tokens;
640 totals.read_create_regressions += read_create_regressions;
641
642 cases.push(SessionCostPromptCacheEffectivenessCaseReport {
643 name: case.name.clone(),
644 source: report.source,
645 status,
646 prompt_tokens: report.prompt_tokens,
647 cached_input_tokens: report.cached_input_tokens,
648 cache_creation_input_tokens: report.cache_creation_input_tokens,
649 cached_input_ratio: report.cached_input_ratio,
650 minimum_cached_input_ratio: case.minimum_cached_input_ratio,
651 net_cached_input_tokens,
652 minimum_net_cached_input_tokens: case.minimum_net_cached_input_tokens,
653 read_create_regressions,
654 maximum_read_create_regressions: case.maximum_read_create_regressions,
655 failures,
656 });
657 }
658
659 Ok(SessionCostPromptCacheEffectivenessReport {
660 schema_version: fixture.schema_version,
661 pass: totals.failed == 0,
662 totals,
663 cases,
664 })
665}
666
667fn prompt_cache_provider_adapter_failures(
668 case: &SessionCostPromptCacheEffectivenessCase,
669 plan: Option<&SessionCostPromptCachePlan>,
670) -> Vec<String> {
671 let mut failures = Vec::new();
672 let Some(plan) = plan else {
673 return failures;
674 };
675 let Ok(source) = SessionCostSource::parse(&case.source) else {
676 return failures;
677 };
678
679 match source {
680 SessionCostSource::ClaudeJsonl => require_prompt_cache_provider_adapter(
681 plan,
682 "anthropic",
683 "cache_control",
684 "Anthropic cache_control",
685 &mut failures,
686 ),
687 SessionCostSource::CodexJsonl => require_prompt_cache_provider_adapter(
688 plan,
689 "openai",
690 "prompt_cache_key",
691 "OpenAI prompt_cache_key",
692 &mut failures,
693 ),
694 SessionCostSource::AgentDocLog => {}
695 }
696 if matches!(
697 source,
698 SessionCostSource::ClaudeJsonl | SessionCostSource::CodexJsonl
699 ) {
700 require_prompt_cache_provider_adapter(
701 plan,
702 "replica_local",
703 "routing_affinity",
704 "replica-local routing_affinity",
705 &mut failures,
706 );
707 }
708
709 failures
710}
711
712fn require_prompt_cache_provider_adapter(
713 plan: &SessionCostPromptCachePlan,
714 provider: &str,
715 expected_status: &str,
716 label: &str,
717 failures: &mut Vec<String>,
718) {
719 match plan
720 .provider_adapters
721 .iter()
722 .find(|adapter| adapter.provider == provider)
723 {
724 Some(adapter) if adapter.status == expected_status => {}
725 Some(adapter) => failures.push(format!(
726 "{label} adapter status `{}`; expected `{expected_status}`",
727 adapter.status
728 )),
729 None => failures.push(format!("missing {label} adapter")),
730 }
731}
732
733pub fn derive_guardrails(input: &SessionCostGuardrailInput) -> Vec<SessionCostGuardrail> {
734 let mut guardrails = Vec::new();
735
736 if input.largest_prompt_turn_tokens >= PROMPT_BUDGET_WARN_TOKENS {
737 let label = input
738 .largest_prompt_turn_label
739 .as_deref()
740 .map(|label| format!(" at {label}"))
741 .unwrap_or_default();
742 guardrails.push(SessionCostGuardrail {
743 kind: "prompt_budget".to_string(),
744 severity: "warn".to_string(),
745 message: format!(
746 "largest prompt turn reached {} tokens{label}",
747 input.largest_prompt_turn_tokens
748 ),
749 guidance:
750 "compact the session or split the task before another large turn resends the same context"
751 .to_string(),
752 });
753 }
754
755 if input.prompt_tokens >= CACHED_RATIO_WARN_PROMPT_TOKENS
756 && input
757 .cached_input_ratio
758 .is_some_and(|ratio| ratio >= CACHED_RATIO_WARN_PERCENT)
759 {
760 guardrails.push(SessionCostGuardrail {
761 kind: "cache_resend".to_string(),
762 severity: "warn".to_string(),
763 message: format!(
764 "cached input ratio was {:.2}% across {} prompt tokens",
765 input.cached_input_ratio.unwrap_or_default(),
766 input.prompt_tokens
767 ),
768 guidance:
769 "compact or restart the session when most prompt spend is cached context instead of new work"
770 .to_string(),
771 });
772 }
773
774 let restart_signal_count = input.fresh_restart_occurrences
775 + input.auto_trigger_timeout_occurrences
776 + input.ctrl_d_restart_loop_occurrences;
777 if restart_signal_count >= RESTART_LOOP_WARN_OCCURRENCES
778 || input.ctrl_d_restart_loop_occurrences > 0
779 || input.auto_trigger_timeout_occurrences > 0
780 {
781 let max_restart = input
782 .max_restart_count
783 .map(|count| format!(" max_restart={count}."))
784 .unwrap_or_default();
785 guardrails.push(SessionCostGuardrail {
786 kind: "restart_loop".to_string(),
787 severity: "warn".to_string(),
788 message: format!(
789 "restart churn detected: fresh_restart={} auto_trigger_timeout={} ctrl_d_restart_loop={}.{}",
790 input.fresh_restart_occurrences,
791 input.auto_trigger_timeout_occurrences,
792 input.ctrl_d_restart_loop_occurrences,
793 max_restart
794 )
795 .trim()
796 .to_string(),
797 guidance:
798 "fix the startup/retry issue before another restart, or compact and reopen cleanly instead of looping"
799 .to_string(),
800 });
801 }
802
803 if input.noop_closeout_occurrences >= NOOP_CLOSEOUT_WARN_OCCURRENCES {
804 guardrails.push(SessionCostGuardrail {
805 kind: "noop_closeout".to_string(),
806 severity: "warn".to_string(),
807 message: format!(
808 "commit_already_current appeared {} times",
809 input.noop_closeout_occurrences
810 ),
811 guidance:
812 "compact the document or avoid reopening it without new edits when closeouts are mostly no-ops"
813 .to_string(),
814 });
815 }
816
817 guardrails.truncate(MAX_GUARDRAILS);
818 guardrails
819}
820
821fn derive_prompt_cache_plan(
822 prompt_tokens: u64,
823 cached_input_tokens: u64,
824 cache_creation_input_tokens: u64,
825 cached_input_ratio: Option<f64>,
826 usage_turns: &[SessionCostTurn],
827) -> Option<SessionCostPromptCachePlan> {
828 let usage_samples = usage_turns.len();
829 if usage_samples == 0 {
830 return None;
831 }
832
833 let observed = cached_input_tokens > 0 || cache_creation_input_tokens > 0;
834 let candidate = prompt_tokens >= PROMPT_CACHE_CANDIDATE_TOKENS;
835 if !observed && !candidate {
836 return None;
837 }
838
839 let adapter_evidence = prompt_cache_adapter_evidence(usage_turns);
840 let mut actions = Vec::new();
841 if !observed {
842 actions.push(SessionCostPromptCacheAction {
843 kind: "enable_provider_cache".to_string(),
844 severity: "recommend".to_string(),
845 message: format!(
846 "prompt volume reached {prompt_tokens} tokens without observed cache reads"
847 ),
848 guidance: "add a provider adapter that keeps stable context byte-identical and passes the provider cache hint on each turn"
849 .to_string(),
850 });
851 } else if cached_input_ratio.is_some_and(|ratio| ratio < PROMPT_CACHE_GOOD_HIT_PERCENT) {
852 actions.push(SessionCostPromptCacheAction {
853 kind: "improve_cache_hit_rate".to_string(),
854 severity: "recommend".to_string(),
855 message: format!(
856 "cached input ratio was {:.2}% across {prompt_tokens} prompt tokens",
857 cached_input_ratio.unwrap_or_default()
858 ),
859 guidance:
860 "move volatile timestamps, generated headers, and one-off compaction prompts after the cached prefix"
861 .to_string(),
862 });
863 } else {
864 actions.push(SessionCostPromptCacheAction {
865 kind: "preserve_cache_shape".to_string(),
866 severity: "info".to_string(),
867 message: format!(
868 "cache reads were observed across {cached_input_tokens} input tokens"
869 ),
870 guidance:
871 "keep the stable prefix and append-only transcript shape intact while adding new tools or context"
872 .to_string(),
873 });
874 }
875
876 if cache_creation_input_tokens > cached_input_tokens && cached_input_tokens > 0 {
877 actions.push(SessionCostPromptCacheAction {
878 kind: "reduce_cache_rewrites".to_string(),
879 severity: "recommend".to_string(),
880 message: format!(
881 "cache creation tokens ({cache_creation_input_tokens}) exceeded cache read tokens ({cached_input_tokens})"
882 ),
883 guidance:
884 "check for prefix churn before each model call; repeated writes can erase the economics of prompt caching"
885 .to_string(),
886 });
887 }
888 push_prompt_cache_adapter_actions(&adapter_evidence, &mut actions);
889
890 Some(SessionCostPromptCachePlan {
891 status: if observed { "observed" } else { "candidate" }.to_string(),
892 feasible: true,
893 observed_cached_input_tokens: cached_input_tokens,
894 observed_cache_creation_tokens: cache_creation_input_tokens,
895 observed_cached_input_ratio: cached_input_ratio.map(|ratio| format!("{ratio:.2}%")),
896 analytics: derive_prompt_cache_analytics(
897 usage_turns,
898 prompt_tokens,
899 cached_input_tokens,
900 cache_creation_input_tokens,
901 cached_input_ratio,
902 ),
903 invariants: vec![
904 "place stable system/developer context before per-turn content".to_string(),
905 "treat conversation history as append-only until an intentional compaction boundary"
906 .to_string(),
907 "exclude volatile timestamps, random ids, and transient instructions from the cached prefix"
908 .to_string(),
909 "run compaction against the same live prefix whenever the provider cache is still warm"
910 .to_string(),
911 ],
912 provider_adapters: derive_prompt_cache_provider_adapters(&adapter_evidence),
913 actions,
914 })
915}
916
917fn derive_prompt_cache_provider_adapters(
918 evidence: &PromptCacheAdapterEvidence,
919) -> Vec<SessionCostPromptCacheProvider> {
920 vec![
921 SessionCostPromptCacheProvider {
922 provider: "anthropic".to_string(),
923 status: anthropic_cache_control_status(evidence).to_string(),
924 requirements: vec![
925 "attach cache_control to the stable system block".to_string(),
926 "attach cache_control to the final tool definition when tools are sent".to_string(),
927 "attach cache_control to the last two user-role messages; skip one-off compaction instructions"
928 .to_string(),
929 ],
930 },
931 SessionCostPromptCacheProvider {
932 provider: "openai".to_string(),
933 status: openai_prompt_cache_key_status(evidence).to_string(),
934 requirements: vec![
935 "derive prompt_cache_key from the stable thread/session id".to_string(),
936 "keep prefixes byte-identical across consecutive calls for the same key".to_string(),
937 ],
938 },
939 SessionCostPromptCacheProvider {
940 provider: "replica_local".to_string(),
941 status: replica_local_routing_affinity_status(evidence).to_string(),
942 requirements: vec![
943 "route consecutive calls for the same cache key to the same replica when the provider cache is replica-local"
944 .to_string(),
945 ],
946 },
947 ]
948}
949
950fn prompt_cache_adapter_evidence(usage_turns: &[SessionCostTurn]) -> PromptCacheAdapterEvidence {
951 let mut evidence = PromptCacheAdapterEvidence::default();
952 for metadata in usage_turns
953 .iter()
954 .filter_map(|turn| turn.prompt_cache_metadata.as_ref())
955 {
956 let anthropic = is_anthropic_provider(&metadata.provider);
957 let openai = is_openai_provider(&metadata.provider);
958 if anthropic {
959 evidence.anthropic_samples += 1;
960 if metadata_has_cache_control_breakpoint(metadata) {
961 evidence.anthropic_cache_control_samples += 1;
962 }
963 }
964 if openai {
965 evidence.openai_samples += 1;
966 if let Some(cache_key) = metadata.cache_key.as_ref() {
967 evidence.openai_prompt_cache_key_samples += 1;
968 evidence.openai_prompt_cache_keys.insert(cache_key.clone());
969 }
970 }
971 if anthropic || openai {
972 evidence.routed_provider_samples += 1;
973 if let Some(routing_affinity) = metadata.routing_affinity.as_ref() {
974 evidence.routing_affinity_samples += 1;
975 evidence
976 .routing_affinity_values
977 .insert(routing_affinity.clone());
978 }
979 }
980 }
981 evidence
982}
983
984fn anthropic_cache_control_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
985 if evidence.anthropic_samples == 0 {
986 "not_observed"
987 } else if evidence.anthropic_cache_control_samples == evidence.anthropic_samples {
988 "cache_control"
989 } else if evidence.anthropic_cache_control_samples > 0 {
990 "partial_cache_control"
991 } else {
992 "missing_cache_control"
993 }
994}
995
996fn openai_prompt_cache_key_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
997 if evidence.openai_samples == 0 {
998 "not_observed"
999 } else if evidence.openai_prompt_cache_key_samples < evidence.openai_samples {
1000 if evidence.openai_prompt_cache_key_samples == 0 {
1001 "missing_prompt_cache_key"
1002 } else {
1003 "partial_prompt_cache_key"
1004 }
1005 } else if evidence.openai_prompt_cache_keys.len() > 1 {
1006 "prompt_cache_key_churn"
1007 } else {
1008 "prompt_cache_key"
1009 }
1010}
1011
1012fn replica_local_routing_affinity_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1013 if evidence.routed_provider_samples == 0 {
1014 "not_observed"
1015 } else if evidence.routing_affinity_samples < evidence.routed_provider_samples {
1016 if evidence.routing_affinity_samples == 0 {
1017 "missing_routing_affinity"
1018 } else {
1019 "partial_routing_affinity"
1020 }
1021 } else if evidence.routing_affinity_values.len() > 1 {
1022 "routing_affinity_churn"
1023 } else {
1024 "routing_affinity"
1025 }
1026}
1027
1028fn push_prompt_cache_adapter_actions(
1029 evidence: &PromptCacheAdapterEvidence,
1030 actions: &mut Vec<SessionCostPromptCacheAction>,
1031) {
1032 match anthropic_cache_control_status(evidence) {
1033 "missing_cache_control" | "partial_cache_control" => {
1034 actions.push(SessionCostPromptCacheAction {
1035 kind: "fix_anthropic_cache_control".to_string(),
1036 severity: "recommend".to_string(),
1037 message: "Anthropic prompt-cache calls are missing cache_control breakpoints"
1038 .to_string(),
1039 guidance: "attach cache_control to the stable Anthropic system/tool/user blocks that should be cached"
1040 .to_string(),
1041 });
1042 }
1043 _ => {}
1044 }
1045 match openai_prompt_cache_key_status(evidence) {
1046 "missing_prompt_cache_key" | "partial_prompt_cache_key" | "prompt_cache_key_churn" => {
1047 actions.push(SessionCostPromptCacheAction {
1048 kind: "fix_openai_prompt_cache_key".to_string(),
1049 severity: "recommend".to_string(),
1050 message: "OpenAI prompt-cache calls need a stable prompt_cache_key".to_string(),
1051 guidance: "derive prompt_cache_key from the stable session/thread id and keep it unchanged across warm-prefix calls"
1052 .to_string(),
1053 });
1054 }
1055 _ => {}
1056 }
1057 match replica_local_routing_affinity_status(evidence) {
1058 "missing_routing_affinity" | "partial_routing_affinity" | "routing_affinity_churn" => {
1059 actions.push(SessionCostPromptCacheAction {
1060 kind: "fix_replica_routing_affinity".to_string(),
1061 severity: "recommend".to_string(),
1062 message: "prompt-cache calls need stable replica-local routing affinity"
1063 .to_string(),
1064 guidance: "route consecutive calls for the same cache key to the same provider replica or deployment"
1065 .to_string(),
1066 });
1067 }
1068 _ => {}
1069 }
1070}
1071
1072fn derive_prompt_cache_analytics(
1073 usage_turns: &[SessionCostTurn],
1074 prompt_tokens: u64,
1075 cached_input_tokens: u64,
1076 cache_creation_input_tokens: u64,
1077 cached_input_ratio: Option<f64>,
1078) -> Option<SessionCostPromptCacheAnalytics> {
1079 if usage_turns.is_empty() {
1080 return None;
1081 }
1082
1083 let first_ratio = usage_turns
1084 .first()
1085 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1086 let last_ratio = usage_turns
1087 .last()
1088 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1089 let ratio_delta = first_ratio
1090 .zip(last_ratio)
1091 .map(|(first, last)| last - first);
1092 let trend = prompt_cache_trend(usage_turns.len(), ratio_delta).to_string();
1093 let effective = cached_input_ratio.is_some_and(|ratio| ratio >= PROMPT_CACHE_GOOD_HIT_PERCENT)
1094 && cached_input_tokens >= cache_creation_input_tokens;
1095 let cache_read_to_creation_ratio = (cache_creation_input_tokens > 0).then(|| {
1096 format!(
1097 "{:.2}x",
1098 (cached_input_tokens as f64) / (cache_creation_input_tokens as f64)
1099 )
1100 });
1101 let timeline = prompt_cache_timeline(usage_turns);
1102 let diagnostics = derive_prompt_cache_diagnostics(
1103 usage_turns,
1104 cached_input_tokens,
1105 cache_creation_input_tokens,
1106 );
1107
1108 Some(SessionCostPromptCacheAnalytics {
1109 sample_count: usage_turns.len(),
1110 effective,
1111 trend,
1112 total_prompt_tokens: prompt_tokens,
1113 total_cached_input_tokens: cached_input_tokens,
1114 total_cache_creation_tokens: cache_creation_input_tokens,
1115 net_cached_input_tokens: signed_token_delta(
1116 cached_input_tokens,
1117 cache_creation_input_tokens,
1118 ),
1119 timeline_truncated: usage_turns.len() > MAX_PROMPT_CACHE_TIMELINE,
1120 average_cached_input_ratio: cached_input_ratio.map(format_percent),
1121 first_cached_input_ratio: first_ratio.map(format_percent),
1122 last_cached_input_ratio: last_ratio.map(format_percent),
1123 cached_input_ratio_delta: ratio_delta.map(format_signed_percent),
1124 cache_read_to_creation_ratio,
1125 diagnostics,
1126 timeline,
1127 })
1128}
1129
1130fn derive_prompt_cache_diagnostics(
1131 usage_turns: &[SessionCostTurn],
1132 cached_input_tokens: u64,
1133 cache_creation_input_tokens: u64,
1134) -> Vec<SessionCostPromptCacheDiagnostic> {
1135 let mut diagnostics = Vec::new();
1136
1137 for pair in usage_turns.windows(2) {
1138 let previous = &pair[0];
1139 let current = &pair[1];
1140 let Some(previous_ratio) =
1141 percent_ratio(previous.cached_input_tokens, previous.prompt_tokens)
1142 else {
1143 continue;
1144 };
1145 let Some(current_ratio) = percent_ratio(current.cached_input_tokens, current.prompt_tokens)
1146 else {
1147 continue;
1148 };
1149 let drop = previous_ratio - current_ratio;
1150 if drop >= PROMPT_CACHE_RATIO_DROP_WARN_PERCENT {
1151 diagnostics.push(SessionCostPromptCacheDiagnostic {
1152 kind: "cached_ratio_drop".to_string(),
1153 severity: "warn".to_string(),
1154 label: current.label.clone(),
1155 message: format!(
1156 "cached input ratio dropped from {} to {} at {}",
1157 format_percent(previous_ratio),
1158 format_percent(current_ratio),
1159 current.label
1160 ),
1161 likely_causes: vec![
1162 "stable prefix bytes changed before the cache boundary".to_string(),
1163 "prompt_cache_key or thread/session id changed".to_string(),
1164 "replica-local cache affinity was lost".to_string(),
1165 ],
1166 guidance:
1167 "compare the prefix, tool set, cache key, compaction boundary, and routing between the previous turn and this turn"
1168 .to_string(),
1169 });
1170 }
1171 }
1172
1173 for turn in usage_turns {
1174 let Some(creation_ratio) =
1175 percent_ratio(turn.cache_creation_input_tokens, turn.prompt_tokens)
1176 else {
1177 continue;
1178 };
1179 if turn.cache_creation_input_tokens > 0
1180 && creation_ratio >= PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT
1181 {
1182 diagnostics.push(SessionCostPromptCacheDiagnostic {
1183 kind: "cache_creation_spike".to_string(),
1184 severity: "warn".to_string(),
1185 label: turn.label.clone(),
1186 message: format!(
1187 "cache creation was {} of prompt tokens at {}",
1188 format_percent(creation_ratio),
1189 turn.label
1190 ),
1191 likely_causes: vec![
1192 "provider created a fresh cached prefix instead of reusing the warm prefix"
1193 .to_string(),
1194 "system, developer, or tool block changed before the cache boundary"
1195 .to_string(),
1196 "compaction or transient instructions entered the cached prefix".to_string(),
1197 ],
1198 guidance:
1199 "inspect the cached prefix and provider breakpoint placement for this turn before treating the cache as effective"
1200 .to_string(),
1201 });
1202 }
1203 }
1204
1205 if cache_creation_input_tokens > 0 {
1206 let read_to_creation = (cached_input_tokens as f64) / (cache_creation_input_tokens as f64);
1207 if read_to_creation < PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO {
1208 diagnostics.push(SessionCostPromptCacheDiagnostic {
1209 kind: "read_create_regression".to_string(),
1210 severity: "recommend".to_string(),
1211 label: "session".to_string(),
1212 message: format!(
1213 "cache read/create ratio was {read_to_creation:.2}x ({cached_input_tokens} read tokens, {cache_creation_input_tokens} creation tokens)"
1214 ),
1215 likely_causes: vec![
1216 "cached prefix is being rewritten too often for warm reuse".to_string(),
1217 "volatile values are inside the cached prefix".to_string(),
1218 "cache key or replica routing is changing between turns".to_string(),
1219 ],
1220 guidance:
1221 "stabilize the prefix/key/routing path until cache reads clearly exceed creation work"
1222 .to_string(),
1223 });
1224 }
1225 }
1226
1227 diagnostics.truncate(MAX_PROMPT_CACHE_DIAGNOSTICS);
1228 diagnostics
1229}
1230
1231fn prompt_cache_timeline(
1232 usage_turns: &[SessionCostTurn],
1233) -> Vec<SessionCostPromptCacheTimelineEntry> {
1234 let selected = if usage_turns.len() <= MAX_PROMPT_CACHE_TIMELINE {
1235 usage_turns.iter().collect::<Vec<_>>()
1236 } else {
1237 let tail_count = MAX_PROMPT_CACHE_TIMELINE.saturating_sub(1);
1238 let mut selected = Vec::with_capacity(MAX_PROMPT_CACHE_TIMELINE);
1239 if let Some(first) = usage_turns.first() {
1240 selected.push(first);
1241 }
1242 selected.extend(usage_turns.iter().skip(usage_turns.len() - tail_count));
1243 selected
1244 };
1245
1246 selected
1247 .into_iter()
1248 .map(|turn| SessionCostPromptCacheTimelineEntry {
1249 label: turn.label.clone(),
1250 prompt_tokens: turn.prompt_tokens,
1251 cached_input_tokens: turn.cached_input_tokens,
1252 cache_creation_input_tokens: turn.cache_creation_input_tokens,
1253 cached_input_ratio: percent_ratio(turn.cached_input_tokens, turn.prompt_tokens)
1254 .map(format_percent),
1255 cache_creation_ratio: percent_ratio(
1256 turn.cache_creation_input_tokens,
1257 turn.prompt_tokens,
1258 )
1259 .map(format_percent),
1260 prompt_cache_metadata: turn.prompt_cache_metadata.clone(),
1261 })
1262 .collect()
1263}
1264
1265fn prompt_cache_trend(sample_count: usize, ratio_delta: Option<f64>) -> &'static str {
1266 if sample_count < 2 {
1267 return "single_sample";
1268 }
1269 let Some(delta) = ratio_delta else {
1270 return "insufficient_data";
1271 };
1272 if delta >= PROMPT_CACHE_TREND_DELTA_PERCENT {
1273 "improving"
1274 } else if delta <= -PROMPT_CACHE_TREND_DELTA_PERCENT {
1275 "declining"
1276 } else {
1277 "stable"
1278 }
1279}
1280
1281fn percent_ratio(numerator: u64, denominator: u64) -> Option<f64> {
1282 (denominator > 0)
1283 .then_some(((numerator as f64) / (denominator as f64) * 10_000.0).round() / 100.0)
1284}
1285
1286fn format_percent(value: f64) -> String {
1287 format!("{value:.2}%")
1288}
1289
1290fn format_signed_percent(value: f64) -> String {
1291 format!("{value:+.2}%")
1292}
1293
1294fn signed_token_delta(read_tokens: u64, creation_tokens: u64) -> i64 {
1295 if read_tokens >= creation_tokens {
1296 i64::try_from(read_tokens - creation_tokens).unwrap_or(i64::MAX)
1297 } else {
1298 -i64::try_from(creation_tokens - read_tokens).unwrap_or(i64::MAX)
1299 }
1300}
1301
1302fn resolve_source(input: &str, source_hint: Option<&str>) -> Result<SessionCostSource> {
1303 if let Some(raw) = source_hint {
1304 return SessionCostSource::parse(raw);
1305 }
1306
1307 let non_empty = input
1308 .lines()
1309 .map(str::trim)
1310 .filter(|line| !line.is_empty())
1311 .collect::<Vec<_>>();
1312 if non_empty.is_empty() {
1313 bail!(
1314 "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
1315 );
1316 }
1317
1318 if non_empty
1319 .iter()
1320 .all(|line| line.starts_with('{') && serde_json::from_str::<Value>(line).is_ok())
1321 {
1322 for line in &non_empty {
1323 let value = serde_json::from_str::<Value>(line).unwrap_or(Value::Null);
1324 if value
1325 .get("message")
1326 .and_then(|message| message.get("usage"))
1327 .is_some()
1328 {
1329 return Ok(SessionCostSource::ClaudeJsonl);
1330 }
1331 if value.get("type").and_then(Value::as_str) == Some("event_msg")
1332 && value
1333 .get("payload")
1334 .and_then(|payload| payload.get("type"))
1335 .and_then(Value::as_str)
1336 == Some("token_count")
1337 {
1338 return Ok(SessionCostSource::CodexJsonl);
1339 }
1340 }
1341 if non_empty.iter().any(|line| line.contains("\"parentUuid\"")) {
1342 return Ok(SessionCostSource::ClaudeJsonl);
1343 }
1344 if non_empty
1345 .iter()
1346 .any(|line| line.contains("\"response_item\"") || line.contains("\"turn_context\""))
1347 {
1348 return Ok(SessionCostSource::CodexJsonl);
1349 }
1350 }
1351
1352 if non_empty
1353 .iter()
1354 .all(|line| line.starts_with('[') && line.contains(']'))
1355 {
1356 return Ok(SessionCostSource::AgentDocLog);
1357 }
1358
1359 bail!(
1360 "could not auto-detect session-cost input; pass --source claude-jsonl, codex-jsonl, or agent-doc-log"
1361 )
1362}
1363
1364fn ingest_claude_jsonl(input: &str, state: &mut CostState) -> Result<()> {
1365 let mut seen_keys = BTreeSet::new();
1366 for (index, raw_line) in input.lines().enumerate() {
1367 let trimmed = raw_line.trim();
1368 if trimmed.is_empty() {
1369 continue;
1370 }
1371 let value = match serde_json::from_str::<Value>(trimmed) {
1372 Ok(value) => value,
1373 Err(_) => {
1374 state.warnings.push(format!(
1375 "skipping malformed Claude transcript jsonl line {}",
1376 index + 1
1377 ));
1378 continue;
1379 }
1380 };
1381 let Some(message) = value.get("message") else {
1382 collect_claude_loop_signals(&value, state);
1383 continue;
1384 };
1385 collect_claude_loop_signals(&value, state);
1386 if message.get("role").and_then(Value::as_str) != Some("assistant") {
1387 continue;
1388 }
1389 let Some(usage) = message.get("usage") else {
1390 continue;
1391 };
1392
1393 let key = message
1394 .get("id")
1395 .and_then(Value::as_str)
1396 .or_else(|| value.get("requestId").and_then(Value::as_str))
1397 .or_else(|| value.get("uuid").and_then(Value::as_str))
1398 .map(|value| value.to_string())
1399 .unwrap_or_else(|| format!("line-{}", index + 1));
1400 if !seen_keys.insert(key.clone()) {
1401 continue;
1402 }
1403
1404 let prompt_tokens = usage_u64(usage, "input_tokens")
1405 + usage_u64(usage, "cache_creation_input_tokens")
1406 + usage_u64(usage, "cache_read_input_tokens");
1407 let cached_input_tokens = usage_u64(usage, "cache_read_input_tokens");
1408 let cache_creation_input_tokens = usage_u64(usage, "cache_creation_input_tokens");
1409 let output_tokens = usage_u64(usage, "output_tokens");
1410 let total_tokens = prompt_tokens + output_tokens;
1411 if prompt_tokens == 0 && output_tokens == 0 {
1412 continue;
1413 }
1414
1415 state.usage_turns.push(SessionCostTurn {
1416 label: value
1417 .get("timestamp")
1418 .and_then(Value::as_str)
1419 .map(|value| value.to_string())
1420 .unwrap_or(key),
1421 prompt_tokens,
1422 cached_input_tokens,
1423 cache_creation_input_tokens,
1424 output_tokens,
1425 reasoning_output_tokens: 0,
1426 total_tokens,
1427 prompt_cache_metadata: Some(prompt_cache_metadata(
1428 &value,
1429 SessionCostSource::ClaudeJsonl,
1430 )),
1431 });
1432 }
1433 Ok(())
1434}
1435
1436fn ingest_codex_jsonl(input: &str, state: &mut CostState) -> Result<()> {
1437 let mut previous = UsageTotals::default();
1438 let mut seen_cumulative_snapshots = BTreeSet::<UsageTotals>::new();
1439 let mut saw_token_count = false;
1440 for (index, raw_line) in input.lines().enumerate() {
1441 let trimmed = raw_line.trim();
1442 if trimmed.is_empty() {
1443 continue;
1444 }
1445 let value = match serde_json::from_str::<Value>(trimmed) {
1446 Ok(value) => value,
1447 Err(_) => {
1448 state.warnings.push(format!(
1449 "skipping malformed Codex transcript jsonl line {}",
1450 index + 1
1451 ));
1452 continue;
1453 }
1454 };
1455 match value.get("type").and_then(Value::as_str) {
1456 Some("response_item") => {
1457 collect_codex_response_item_loop_signals(&value, index + 1, state)
1458 }
1459 Some("event_msg") => collect_codex_event_msg_loop_signals(&value, index + 1, state),
1460 _ => {}
1461 }
1462 if value.get("type").and_then(Value::as_str) != Some("event_msg") {
1463 continue;
1464 }
1465 let Some(payload) = value.get("payload") else {
1466 continue;
1467 };
1468 if payload.get("type").and_then(Value::as_str) != Some("token_count") {
1469 continue;
1470 }
1471 saw_token_count = true;
1472
1473 let Some(total) = payload
1474 .get("info")
1475 .and_then(|info| info.get("total_token_usage"))
1476 else {
1477 state.warnings.push(format!(
1478 "codex token_count event on line {} did not include info.total_token_usage",
1479 index + 1
1480 ));
1481 continue;
1482 };
1483 let cumulative = codex_usage_totals(total);
1484 let duplicate_snapshot = !seen_cumulative_snapshots.insert(cumulative);
1485 let delta = if duplicate_snapshot {
1486 UsageTotals::default()
1487 } else if let Some(last) = payload
1488 .get("info")
1489 .and_then(|info| info.get("last_token_usage"))
1490 .map(codex_usage_totals)
1491 .filter(|last| !last.is_zero())
1492 {
1493 last
1494 } else if previous.is_zero() {
1495 cumulative
1496 } else {
1497 cumulative.delta_from(previous)
1498 };
1499 previous = cumulative;
1500 if delta.is_zero() {
1501 continue;
1502 }
1503
1504 state.usage_turns.push(SessionCostTurn {
1505 label: value
1506 .get("timestamp")
1507 .and_then(Value::as_str)
1508 .map(|value| value.to_string())
1509 .unwrap_or_else(|| format!("line-{}", index + 1)),
1510 prompt_tokens: delta.prompt_tokens,
1511 cached_input_tokens: delta.cached_input_tokens,
1512 cache_creation_input_tokens: 0,
1513 output_tokens: delta.output_tokens,
1514 reasoning_output_tokens: delta.reasoning_output_tokens,
1515 total_tokens: delta
1516 .total_tokens
1517 .max(delta.prompt_tokens + delta.output_tokens),
1518 prompt_cache_metadata: Some(prompt_cache_metadata(
1519 &value,
1520 SessionCostSource::CodexJsonl,
1521 )),
1522 });
1523 }
1524
1525 if !saw_token_count {
1526 state.warnings.push(
1527 "codex transcript did not contain any token_count events; no token cost summary could be derived"
1528 .to_string(),
1529 );
1530 }
1531 Ok(())
1532}
1533
1534fn ingest_agent_doc_log(input: &str, state: &mut CostState) {
1535 for raw_line in input.lines() {
1536 let trimmed = raw_line.trim();
1537 if trimmed.is_empty() {
1538 continue;
1539 }
1540 let Some((_, after_bracket)) = trimmed.split_once("] ") else {
1541 continue;
1542 };
1543 let detail = after_bracket.trim();
1544 let Some(event_name) = detail.split_whitespace().next() else {
1545 continue;
1546 };
1547 let normalized = normalize_runtime_event(event_name, detail);
1548 let closeout_event = is_closeout_runtime_event(event_name, &normalized);
1549 if should_count_runtime_event(event_name, detail, &normalized, state) {
1550 *state.runtime_events.entry(normalized.clone()).or_default() += 1;
1551 state.total_runtime_events += 1;
1552 if closeout_event {
1553 push_closeout_signal(&normalized, state);
1554 }
1555 }
1556 state.restart_churn.observe(event_name, detail);
1557 if let Some(restart_count) =
1558 extract_field(detail, "restart_count").and_then(|value| value.parse::<usize>().ok())
1559 {
1560 state.max_restart_count = Some(
1561 state
1562 .max_restart_count
1563 .map_or(restart_count, |current| current.max(restart_count)),
1564 );
1565 }
1566 }
1567}
1568
1569fn collect_claude_loop_signals(value: &Value, state: &mut CostState) {
1570 let mut blocks = Vec::new();
1571 collect_transcript_blocks(value, &mut blocks);
1572 if blocks.is_empty() && is_ignorable_claude_record(value) {
1573 return;
1574 }
1575 for block in blocks {
1576 match block {
1577 TranscriptBlock::Text { role, text } => {
1578 let user_bias = role
1579 .as_deref()
1580 .is_some_and(|value| value.eq_ignore_ascii_case("user"));
1581 collect_text_loop_signals(&text, user_bias, state);
1582 }
1583 TranscriptBlock::ToolUse { name, input } => {
1584 collect_tool_use_loop_signals(&name, &input, state);
1585 }
1586 }
1587 }
1588}
1589
1590fn collect_codex_response_item_loop_signals(
1591 value: &Value,
1592 line_number: usize,
1593 state: &mut CostState,
1594) {
1595 let Some(payload) = value.get("payload") else {
1596 return;
1597 };
1598 match payload.get("type").and_then(Value::as_str) {
1599 Some("message") => {
1600 let Some(content) = payload.get("content").and_then(Value::as_array) else {
1601 return;
1602 };
1603 for item in content {
1604 let Some(text) = item
1605 .get("text")
1606 .and_then(Value::as_str)
1607 .or_else(|| item.get("content").and_then(Value::as_str))
1608 else {
1609 continue;
1610 };
1611 collect_text_loop_signals(text, false, state);
1612 }
1613 }
1614 Some("function_call") => {
1615 let name = payload
1616 .get("name")
1617 .and_then(Value::as_str)
1618 .unwrap_or("function_call");
1619 let Some(arguments) = payload.get("arguments").and_then(Value::as_str) else {
1620 return;
1621 };
1622 let input = serde_json::from_str::<Value>(arguments).unwrap_or_else(|_| {
1623 state.warnings.push(format!(
1624 "codex function_call arguments on line {} were not valid JSON; loop extraction may be incomplete",
1625 line_number
1626 ));
1627 Value::String(arguments.to_string())
1628 });
1629 collect_tool_use_loop_signals(name, &input, state);
1630 }
1631 _ => {}
1632 }
1633}
1634
1635fn collect_codex_event_msg_loop_signals(value: &Value, _line_number: usize, state: &mut CostState) {
1636 let Some(payload) = value.get("payload") else {
1637 return;
1638 };
1639 match payload.get("type").and_then(Value::as_str) {
1640 Some("user_message") => {
1641 if let Some(message) = payload.get("message").and_then(Value::as_str) {
1642 collect_text_loop_signals(message, true, state);
1643 }
1644 }
1645 Some("agent_message") => {
1646 if let Some(message) = payload.get("message").and_then(Value::as_str) {
1647 collect_text_loop_signals(message, false, state);
1648 }
1649 }
1650 Some("exec_command_end") => {
1651 if let Some(command) = extract_raw_codex_exec_command(payload) {
1652 collect_file_read_command_signals(&command, state);
1653 }
1654 if let Some(command) = extract_codex_exec_command(payload) {
1655 push_command(command, state);
1656 }
1657 if let Some(output) = payload
1658 .get("aggregated_output")
1659 .and_then(Value::as_str)
1660 .or_else(|| payload.get("stdout").and_then(Value::as_str))
1661 {
1662 collect_text_loop_signals(output, false, state);
1663 }
1664 }
1665 _ => {}
1666 }
1667}
1668
1669fn collect_tool_use_loop_signals(name: &str, input: &Value, state: &mut CostState) {
1670 collect_file_read_tool_signals(name, input, state);
1671 if let Some(command) = extract_raw_tool_command(name, input) {
1672 collect_file_read_command_signals(&command, state);
1673 }
1674 if let Some(command) = extract_tool_command(name, input) {
1675 push_command(command, state);
1676 }
1677 if let Some(text) = extract_tool_text(input) {
1678 collect_text_loop_signals(&text, false, state);
1679 }
1680}
1681
1682fn collect_file_read_tool_signals(name: &str, input: &Value, state: &mut CostState) {
1683 let lower = name.to_ascii_lowercase();
1684 if !matches!(lower.as_str(), "read" | "file_read" | "read_file") {
1685 return;
1686 }
1687 let Value::Object(map) = input else {
1688 return;
1689 };
1690 let Some(path) = ["file_path", "path"]
1691 .iter()
1692 .find_map(|key| map.get(*key).and_then(Value::as_str))
1693 .map(normalize_file_read_path)
1694 .filter(|path| !path.is_empty())
1695 else {
1696 return;
1697 };
1698 let start = ["offset", "start", "line"]
1699 .iter()
1700 .find_map(|key| map.get(*key).and_then(Value::as_u64))
1701 .and_then(|value| usize::try_from(value).ok())
1702 .filter(|value| *value > 0);
1703 let lines = ["limit", "lines", "line_count"]
1704 .iter()
1705 .find_map(|key| map.get(*key).and_then(Value::as_u64))
1706 .and_then(|value| usize::try_from(value).ok())
1707 .filter(|value| *value > 0);
1708 push_file_read_signal(path, start, lines, state);
1709}
1710
1711fn collect_file_read_command_signals(command: &str, state: &mut CostState) {
1712 if let Some(signal) = parse_file_read_command(command) {
1713 state.file_read_signals.push(signal);
1714 }
1715}
1716
1717fn parse_file_read_command(command: &str) -> Option<FileReadSignal> {
1718 let tokens = shell_words(command);
1719 let head = tokens.first()?.as_str();
1720 match head {
1721 "cat" | "bat" | "batcat" | "nl" => {
1722 let path = first_non_option_arg(&tokens[1..])?;
1723 Some(file_read_signal(
1724 normalize_file_read_path(path),
1725 "full".to_string(),
1726 None,
1727 None,
1728 ))
1729 }
1730 "sed" => parse_sed_file_read(&tokens),
1731 "head" => parse_head_file_read(&tokens),
1732 "tail" => parse_tail_file_read(&tokens),
1733 _ => None,
1734 }
1735}
1736
1737fn parse_sed_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1738 let mut expr = None::<String>;
1739 let mut path = None::<String>;
1740 let mut skip_next = false;
1741 for token in tokens.iter().skip(1) {
1742 if skip_next {
1743 skip_next = false;
1744 continue;
1745 }
1746 if token == "-n" {
1747 continue;
1748 }
1749 if token == "-e" {
1750 skip_next = true;
1751 continue;
1752 }
1753 if expr.is_none() && parse_sed_range(token).is_some() {
1754 expr = Some(token.clone());
1755 continue;
1756 }
1757 if !token.starts_with('-') {
1758 path = Some(token.clone());
1759 }
1760 }
1761 let expr = expr?;
1762 let path = path?;
1763 let (start, lines) = parse_sed_range(&expr)?;
1764 Some(file_read_signal(
1765 normalize_file_read_path(&path),
1766 format!("{}-{}", start, start + lines - 1),
1767 Some(start),
1768 Some(lines),
1769 ))
1770}
1771
1772fn parse_sed_range(expr: &str) -> Option<(usize, usize)> {
1773 let trimmed = expr.trim_matches(['\'', '"']).trim();
1774 let body = trimmed.strip_suffix('p')?;
1775 let (start_raw, end_raw) = body.split_once(',')?;
1776 let start = start_raw.trim().parse::<usize>().ok()?;
1777 let lines = if let Some(relative) = end_raw.trim().strip_prefix('+') {
1778 relative.trim().parse::<usize>().ok()?.saturating_add(1)
1779 } else {
1780 let end = end_raw.trim().parse::<usize>().ok()?;
1781 end.checked_sub(start)?.saturating_add(1)
1782 };
1783 (lines > 0).then_some((start, lines))
1784}
1785
1786fn parse_head_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1787 let mut lines = 10_usize;
1788 let mut path = None::<String>;
1789 let mut index = 1_usize;
1790 while index < tokens.len() {
1791 let token = &tokens[index];
1792 if token == "-n" || token == "--lines" {
1793 index += 1;
1794 lines = tokens.get(index)?.parse::<usize>().ok()?;
1795 } else if let Some(value) = token.strip_prefix("-n") {
1796 lines = value.parse::<usize>().ok()?;
1797 } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
1798 lines = token[1..].parse::<usize>().ok()?;
1799 } else if !token.starts_with('-') {
1800 path = Some(token.clone());
1801 }
1802 index += 1;
1803 }
1804 let path = path?;
1805 Some(file_read_signal(
1806 normalize_file_read_path(&path),
1807 format!("head:{lines}"),
1808 Some(1),
1809 Some(lines),
1810 ))
1811}
1812
1813fn parse_tail_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1814 let mut lines = 10_usize;
1815 let mut path = None::<String>;
1816 let mut index = 1_usize;
1817 while index < tokens.len() {
1818 let token = &tokens[index];
1819 if token == "-n" || token == "--lines" {
1820 index += 1;
1821 lines = tokens.get(index)?.parse::<usize>().ok()?;
1822 } else if let Some(value) = token.strip_prefix("-n") {
1823 lines = value.trim_start_matches('+').parse::<usize>().ok()?;
1824 } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
1825 lines = token[1..].parse::<usize>().ok()?;
1826 } else if !token.starts_with('-') {
1827 path = Some(token.clone());
1828 }
1829 index += 1;
1830 }
1831 let path = path?;
1832 Some(file_read_signal(
1833 normalize_file_read_path(&path),
1834 format!("tail:{lines}"),
1835 None,
1836 Some(lines),
1837 ))
1838}
1839
1840fn first_non_option_arg(tokens: &[String]) -> Option<&str> {
1841 tokens
1842 .iter()
1843 .find(|token| !token.starts_with('-'))
1844 .map(String::as_str)
1845}
1846
1847fn push_file_read_signal(
1848 path: String,
1849 start: Option<usize>,
1850 lines: Option<usize>,
1851 state: &mut CostState,
1852) {
1853 let range = match (start, lines) {
1854 (Some(start), Some(lines)) => format!("{}-{}", start, start + lines - 1),
1855 (Some(start), None) => format!("{start}-end"),
1856 (None, Some(lines)) => format!("window:{lines}"),
1857 (None, None) => "full".to_string(),
1858 };
1859 state
1860 .file_read_signals
1861 .push(file_read_signal(path, range, start, lines));
1862}
1863
1864fn file_read_signal(
1865 path: String,
1866 range: String,
1867 start: Option<usize>,
1868 lines: Option<usize>,
1869) -> FileReadSignal {
1870 FileReadSignal {
1871 path,
1872 range,
1873 start,
1874 lines,
1875 estimated_tokens: estimate_file_read_tokens(lines),
1876 }
1877}
1878
1879fn estimate_file_read_tokens(lines: Option<usize>) -> u64 {
1880 lines
1881 .map(|lines| (lines as u64).saturating_mul(ESTIMATED_TOKENS_PER_SOURCE_LINE))
1882 .unwrap_or(DEFAULT_FULL_FILE_READ_TOKENS)
1883 .max(80)
1884}
1885
1886fn collect_file_read_diagnostics(signals: &[FileReadSignal]) -> Vec<SessionCostFileReadDiagnostic> {
1887 let mut grouped = BTreeMap::<(String, String), FileReadDiagnosticBuilder>::new();
1888 for signal in signals {
1889 let entry = grouped
1890 .entry((signal.path.clone(), signal.range.clone()))
1891 .or_insert_with(|| FileReadDiagnosticBuilder {
1892 path: signal.path.clone(),
1893 range: signal.range.clone(),
1894 start: signal.start,
1895 lines: signal.lines,
1896 occurrences: 0,
1897 estimated_tokens: 0,
1898 max_single_read_tokens: 0,
1899 });
1900 entry.occurrences += 1;
1901 entry.estimated_tokens = entry
1902 .estimated_tokens
1903 .saturating_add(signal.estimated_tokens);
1904 entry.max_single_read_tokens = entry.max_single_read_tokens.max(signal.estimated_tokens);
1905 entry.start = entry.start.or(signal.start);
1906 entry.lines = entry.lines.or(signal.lines);
1907 }
1908
1909 let mut diagnostics = grouped
1910 .into_values()
1911 .filter(|entry| entry.occurrences >= 2)
1912 .map(|entry| {
1913 let duplicate_estimated_tokens = entry
1914 .estimated_tokens
1915 .saturating_sub(entry.max_single_read_tokens);
1916 SessionCostFileReadDiagnostic {
1917 path: entry.path.clone(),
1918 range: entry.range.clone(),
1919 occurrences: entry.occurrences,
1920 estimated_tokens: entry.estimated_tokens,
1921 duplicate_estimated_tokens,
1922 follow_up_commands: file_read_follow_up_commands(
1923 &entry.path,
1924 entry.start,
1925 entry.lines,
1926 ),
1927 }
1928 })
1929 .collect::<Vec<_>>();
1930 diagnostics.sort_by(|left, right| {
1931 right
1932 .duplicate_estimated_tokens
1933 .cmp(&left.duplicate_estimated_tokens)
1934 .then(right.occurrences.cmp(&left.occurrences))
1935 .then(left.path.cmp(&right.path))
1936 .then(left.range.cmp(&right.range))
1937 });
1938 diagnostics.truncate(MAX_FILE_READ_DIAGNOSTICS);
1939 diagnostics
1940}
1941
1942#[derive(Debug)]
1943struct FileReadDiagnosticBuilder {
1944 path: String,
1945 range: String,
1946 start: Option<usize>,
1947 lines: Option<usize>,
1948 occurrences: usize,
1949 estimated_tokens: u64,
1950 max_single_read_tokens: u64,
1951}
1952
1953fn file_read_follow_up_commands(
1954 path: &str,
1955 start: Option<usize>,
1956 lines: Option<usize>,
1957) -> Vec<String> {
1958 let start = start.unwrap_or(1);
1959 let lines = lines.unwrap_or(120).max(1);
1960 vec![
1961 format!(
1962 "tsift source-read {} --start {} --lines {} --budget normal",
1963 shell_quote(path),
1964 start,
1965 lines
1966 ),
1967 format!("tsift summarize --file {}", shell_quote(path)),
1968 ]
1969}
1970
1971fn normalize_file_read_path(raw: &str) -> String {
1972 raw.trim()
1973 .trim_matches(['\'', '"'])
1974 .trim_start_matches("./")
1975 .to_string()
1976}
1977
1978fn shell_words(command: &str) -> Vec<String> {
1979 let mut words = Vec::new();
1980 let mut current = String::new();
1981 let mut quote = None::<char>;
1982 let mut escaped = false;
1983
1984 for ch in command.chars() {
1985 if escaped {
1986 current.push(ch);
1987 escaped = false;
1988 continue;
1989 }
1990 if ch == '\\' {
1991 escaped = true;
1992 continue;
1993 }
1994 if let Some(quote_ch) = quote {
1995 if ch == quote_ch {
1996 quote = None;
1997 } else {
1998 current.push(ch);
1999 }
2000 continue;
2001 }
2002 if ch == '\'' || ch == '"' {
2003 quote = Some(ch);
2004 continue;
2005 }
2006 if ch.is_whitespace() {
2007 if !current.is_empty() {
2008 words.push(std::mem::take(&mut current));
2009 }
2010 continue;
2011 }
2012 current.push(ch);
2013 }
2014 if !current.is_empty() {
2015 words.push(current);
2016 }
2017 words
2018}
2019
2020fn shell_quote(value: &str) -> String {
2021 if value
2022 .chars()
2023 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
2024 {
2025 return value.to_string();
2026 }
2027 format!("'{}'", value.replace('\'', "'\\''"))
2028}
2029
2030fn collect_text_loop_signals(text: &str, user_bias: bool, state: &mut CostState) {
2031 for raw_line in text.lines() {
2032 let trimmed = raw_line.trim();
2033 if trimmed.is_empty() || looks_like_instruction_ballast(trimmed) {
2034 continue;
2035 }
2036 let prompt_candidate = trimmed
2037 .strip_prefix("❯ ")
2038 .or_else(|| trimmed.strip_prefix("> "))
2039 .unwrap_or(trimmed)
2040 .trim();
2041 if looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed) {
2042 push_prompt_signal(prompt_candidate, state);
2043 continue;
2044 }
2045 for (kind, detail) in detect_closeout(trimmed) {
2046 push_closeout_signal(&format!("{kind}: {detail}"), state);
2047 }
2048 }
2049}
2050
2051fn push_prompt_signal(text: &str, state: &mut CostState) {
2052 flush_pending_commands(state);
2053 push_loop_signal(LoopClusterKind::PromptRepeat, text, state);
2054}
2055
2056fn push_closeout_signal(text: &str, state: &mut CostState) {
2057 flush_pending_commands(state);
2058 push_loop_signal(LoopClusterKind::CloseoutChurn, text, state);
2059}
2060
2061fn push_command(command: String, state: &mut CostState) {
2062 let normalized = normalize_whitespace(&command);
2063 if normalized.is_empty() {
2064 return;
2065 }
2066 if state
2067 .pending_commands
2068 .last()
2069 .is_some_and(|existing| existing == &normalized)
2070 {
2071 return;
2072 }
2073 state.pending_commands.push(normalized);
2074}
2075
2076fn flush_pending_commands(state: &mut CostState) {
2077 if state.pending_commands.is_empty() {
2078 return;
2079 }
2080 let label = truncate_detail(
2081 &state
2082 .pending_commands
2083 .iter()
2084 .take(MAX_COMMANDS_PER_BUNDLE)
2085 .cloned()
2086 .collect::<Vec<_>>()
2087 .join(" -> "),
2088 220,
2089 );
2090 state.pending_commands.clear();
2091 push_loop_signal(LoopClusterKind::CommandBundle, &label, state);
2092}
2093
2094fn push_loop_signal(kind: LoopClusterKind, label: &str, state: &mut CostState) {
2095 let normalized = truncate_detail(&normalize_whitespace(label), 220);
2096 if normalized.is_empty() {
2097 return;
2098 }
2099 state.loop_signals.push(LoopSignal {
2100 kind,
2101 label: normalized,
2102 });
2103}
2104
2105fn collect_loop_clusters(signals: &[LoopSignal]) -> Vec<SessionCostLoopCluster> {
2106 let mut summary = BTreeMap::<(LoopClusterKind, String), (usize, usize)>::new();
2107 let mut previous = None::<(LoopClusterKind, String)>;
2108 let mut streak = 0_usize;
2109
2110 for signal in signals {
2111 let key = (signal.kind, signal.label.clone());
2112 let entry = summary.entry(key.clone()).or_insert((0, 0));
2113 entry.0 += 1;
2114 if previous.as_ref() == Some(&key) {
2115 streak += 1;
2116 } else {
2117 previous = Some(key.clone());
2118 streak = 1;
2119 }
2120 entry.1 = entry.1.max(streak);
2121 }
2122
2123 let mut clusters = summary
2124 .into_iter()
2125 .filter_map(|((kind, label), (occurrences, max_consecutive))| {
2126 (occurrences >= 2).then_some(SessionCostLoopCluster {
2127 kind: kind.as_str().to_string(),
2128 label,
2129 occurrences,
2130 max_consecutive,
2131 })
2132 })
2133 .collect::<Vec<_>>();
2134 clusters.sort_by(|left, right| {
2135 right
2136 .occurrences
2137 .cmp(&left.occurrences)
2138 .then(right.max_consecutive.cmp(&left.max_consecutive))
2139 .then(left.kind.cmp(&right.kind))
2140 .then(left.label.cmp(&right.label))
2141 });
2142 clusters.truncate(MAX_LOOP_CLUSTERS);
2143 clusters
2144}
2145
2146fn is_ignorable_claude_record(value: &Value) -> bool {
2147 value.get("attachment").is_some()
2148 || value.get("toolUseResult").is_some()
2149 || (value.get("message").is_none()
2150 && value.get("content").is_none()
2151 && value.get("text").is_none())
2152}
2153
2154fn collect_transcript_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2155 if let Some(message) = value.get("message") {
2156 collect_message_blocks(message, out);
2157 return;
2158 }
2159 collect_message_blocks(value, out);
2160}
2161
2162fn collect_message_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2163 let role = value
2164 .get("role")
2165 .and_then(Value::as_str)
2166 .map(|value| value.to_string());
2167 if let Some(content) = value.get("content") {
2168 match content {
2169 Value::String(text) => out.push(TranscriptBlock::Text {
2170 role,
2171 text: text.to_string(),
2172 }),
2173 Value::Array(items) => {
2174 for item in items {
2175 collect_content_block(role.clone(), item, out);
2176 }
2177 }
2178 _ => {}
2179 }
2180 } else if let Some(text) = value.get("text").and_then(Value::as_str) {
2181 out.push(TranscriptBlock::Text {
2182 role,
2183 text: text.to_string(),
2184 });
2185 }
2186}
2187
2188fn collect_content_block(role: Option<String>, value: &Value, out: &mut Vec<TranscriptBlock>) {
2189 match value.get("type").and_then(Value::as_str) {
2190 Some("text") => {
2191 if let Some(text) = value.get("text").and_then(Value::as_str) {
2192 out.push(TranscriptBlock::Text {
2193 role,
2194 text: text.to_string(),
2195 });
2196 }
2197 }
2198 Some("tool_use") => {
2199 let name = value
2200 .get("name")
2201 .and_then(Value::as_str)
2202 .unwrap_or("tool_use")
2203 .to_string();
2204 let input = value.get("input").cloned().unwrap_or(Value::Null);
2205 out.push(TranscriptBlock::ToolUse { name, input });
2206 }
2207 Some("tool_result") => match value.get("content") {
2208 Some(Value::String(text)) => out.push(TranscriptBlock::Text {
2209 role,
2210 text: text.to_string(),
2211 }),
2212 Some(Value::Array(items)) => {
2213 for item in items {
2214 collect_content_block(role.clone(), item, out);
2215 }
2216 }
2217 _ => {}
2218 },
2219 _ => {
2220 if let Some(text) = value.get("text").and_then(Value::as_str) {
2221 out.push(TranscriptBlock::Text {
2222 role,
2223 text: text.to_string(),
2224 });
2225 }
2226 }
2227 }
2228}
2229
2230fn extract_tool_command(name: &str, input: &Value) -> Option<String> {
2231 let normalized = extract_raw_tool_command(name, input)?;
2232 looks_like_command(&normalized).then_some(normalized)
2233}
2234
2235fn extract_raw_tool_command(name: &str, input: &Value) -> Option<String> {
2236 if !matches!(
2237 name.to_ascii_lowercase().as_str(),
2238 "bash" | "exec_command" | "shell" | "terminal" | "sh"
2239 ) {
2240 return None;
2241 }
2242
2243 match input {
2244 Value::Object(map) => {
2245 for key in ["command", "cmd", "shell_command"] {
2246 if let Some(raw) = map.get(key).and_then(Value::as_str) {
2247 let normalized = normalize_whitespace(raw);
2248 if !normalized.is_empty() {
2249 return Some(normalized);
2250 }
2251 }
2252 }
2253 None
2254 }
2255 Value::String(raw) => {
2256 let normalized = normalize_whitespace(raw);
2257 (!normalized.is_empty()).then_some(normalized)
2258 }
2259 _ => None,
2260 }
2261}
2262
2263fn extract_tool_text(input: &Value) -> Option<String> {
2264 match input {
2265 Value::Object(map) => {
2266 for key in ["text", "output", "stderr", "stdout", "content", "message"] {
2267 if let Some(raw) = map.get(key).and_then(Value::as_str) {
2268 return Some(raw.to_string());
2269 }
2270 }
2271 None
2272 }
2273 Value::String(raw) => Some(raw.to_string()),
2274 _ => None,
2275 }
2276}
2277
2278fn extract_codex_exec_command(payload: &Value) -> Option<String> {
2279 let normalized = extract_raw_codex_exec_command(payload)?;
2280 looks_like_command(&normalized).then_some(normalized)
2281}
2282
2283fn extract_raw_codex_exec_command(payload: &Value) -> Option<String> {
2284 if let Some(parsed) = payload.get("parsed_cmd").and_then(Value::as_array) {
2285 for item in parsed {
2286 if let Some(command) = item.get("cmd").and_then(Value::as_str) {
2287 let normalized = normalize_whitespace(command);
2288 if !normalized.is_empty() {
2289 return Some(normalized);
2290 }
2291 }
2292 }
2293 }
2294
2295 if let Some(command) = payload.get("command").and_then(Value::as_array)
2296 && let Some(last) = command.last().and_then(Value::as_str)
2297 {
2298 let normalized = normalize_whitespace(last);
2299 if !normalized.is_empty() {
2300 return Some(normalized);
2301 }
2302 }
2303 None
2304}
2305
2306fn looks_like_prompt_target(text: &str, user_bias: bool) -> bool {
2307 let trimmed = text.trim();
2308 if trimmed.is_empty()
2309 || looks_like_markdown_heading(trimmed)
2310 || looks_like_slash_command_example(trimmed)
2311 || trimmed == "#"
2312 || trimmed.starts_with("#!")
2313 || trimmed.starts_with("#[")
2314 || trimmed.starts_with("/**")
2315 || trimmed.starts_with("*/")
2316 || trimmed.starts_with("//")
2317 || trimmed.starts_with("###")
2318 || trimmed.starts_with("<!--")
2319 || trimmed.starts_with("- [")
2320 || trimmed == "###"
2321 {
2322 return false;
2323 }
2324
2325 if trimmed.starts_with("do ")
2326 || trimmed.starts_with('#')
2327 || looks_like_slash_prompt_target(trimmed)
2328 || trimmed.ends_with('?')
2329 {
2330 return true;
2331 }
2332
2333 if user_bias
2334 && (trimmed.contains("commit + push")
2335 || trimmed.contains("run tests")
2336 || trimmed.contains("build + install")
2337 || trimmed.contains("#spec-test"))
2338 {
2339 return true;
2340 }
2341
2342 false
2343}
2344
2345fn looks_like_instruction_ballast(text: &str) -> bool {
2346 let trimmed = strip_common_prefixes(text.trim());
2347 if trimmed.is_empty() {
2348 return false;
2349 }
2350
2351 looks_like_markdown_heading(trimmed)
2352 || looks_like_slash_command_example(trimmed)
2353 || looks_like_frontmatter_prompt_preset(trimmed)
2354 || looks_like_completed_backlog_archive(trimmed)
2355 || trimmed.starts_with("<!-- tsift:")
2356 || trimmed.starts_with("<!-- /tsift:")
2357 || looks_like_instruction_label(trimmed)
2358}
2359
2360fn looks_like_markdown_heading(text: &str) -> bool {
2361 let trimmed = text.trim_start();
2362 let heading_level = trimmed.chars().take_while(|ch| *ch == '#').count();
2363 heading_level > 0
2364 && heading_level <= 6
2365 && trimmed
2366 .chars()
2367 .nth(heading_level)
2368 .is_some_and(|ch| ch.is_whitespace())
2369}
2370
2371fn looks_like_slash_command_example(text: &str) -> bool {
2372 let trimmed = text.trim();
2373 trimmed.starts_with('/')
2374 && trimmed.contains('<')
2375 && trimmed.contains('>')
2376 && !trimmed.contains('`')
2377}
2378
2379fn looks_like_slash_prompt_target(text: &str) -> bool {
2380 let Some(first_token) = text.split_whitespace().next() else {
2381 return false;
2382 };
2383 first_token.starts_with('/') && !first_token[1..].contains('/')
2384}
2385
2386fn looks_like_instruction_label(text: &str) -> bool {
2387 let trimmed = text.trim();
2388 if !trimmed.starts_with("**") {
2389 return false;
2390 }
2391 let Some(label_end) = trimmed[2..].find("**") else {
2392 return false;
2393 };
2394 let label = &trimmed[..label_end + 4];
2395 if label.len() <= 4 {
2396 return false;
2397 }
2398 let remainder = trimmed[label_end + 4..]
2399 .trim_start_matches([' ', ':', '-', '—'])
2400 .trim_start();
2401 if remainder.is_empty() {
2402 return false;
2403 }
2404 let lower = remainder.to_ascii_lowercase();
2405 matches!(
2406 lower.split_whitespace().next(),
2407 Some("run")
2408 | Some("use")
2409 | Some("treat")
2410 | Some("respond")
2411 | Some("print")
2412 | Some("prefer")
2413 | Some("preserve")
2414 | Some("show")
2415 | Some("complete")
2416 | Some("append")
2417 | Some("when")
2418 | Some("if")
2419 )
2420}
2421
2422fn strip_common_prefixes(text: &str) -> &str {
2423 text.strip_prefix("❯ ")
2424 .or_else(|| text.strip_prefix("- "))
2425 .or_else(|| text.strip_prefix("* "))
2426 .or_else(|| text.strip_prefix("> "))
2427 .unwrap_or(text)
2428 .trim()
2429}
2430
2431fn looks_like_frontmatter_prompt_preset(text: &str) -> bool {
2432 let trimmed = strip_common_prefixes(text.trim());
2433 if trimmed == "prompt_presets:" || trimmed.starts_with("prompt_presets:") {
2434 return true;
2435 }
2436 let Some((key, _)) = trimmed.split_once(':') else {
2437 return false;
2438 };
2439 let key = key.trim().trim_matches(['"', '\'']);
2440 key.starts_with('#') && key.len() > 1 && key[1..].chars().all(is_prompt_preset_char)
2441}
2442
2443fn is_prompt_preset_char(ch: char) -> bool {
2444 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')
2445}
2446
2447fn looks_like_completed_backlog_archive(text: &str) -> bool {
2448 let stripped = strip_common_prefixes(text.trim());
2449 let Some(date) = stripped.get(..10) else {
2450 return false;
2451 };
2452 date.chars().enumerate().all(|(index, ch)| match index {
2453 4 | 7 => ch == '-',
2454 _ => ch.is_ascii_digit(),
2455 }) && stripped[10..].contains("[#")
2456}
2457
2458fn looks_like_command(text: &str) -> bool {
2459 if text.is_empty()
2460 || text.contains('\n')
2461 || text.contains("://")
2462 || text.starts_with('/')
2463 || text.starts_with("###")
2464 {
2465 return false;
2466 }
2467
2468 let head = text.split_whitespace().next().unwrap_or_default();
2469 matches!(
2470 head,
2471 "agent-doc"
2472 | "cargo"
2473 | "git"
2474 | "make"
2475 | "pytest"
2476 | "python"
2477 | "uv"
2478 | "tsift"
2479 | "npm"
2480 | "pnpm"
2481 | "yarn"
2482 | "bash"
2483 | "zsh"
2484 | "rg"
2485 | "grep"
2486 | "./scripts/run_benchmark.sh"
2487 ) || head.starts_with("./")
2488}
2489
2490fn detect_closeout(text: &str) -> Vec<(String, String)> {
2491 let mut out = Vec::new();
2492 let normalized = normalize_whitespace(strip_common_prefixes(text));
2493 let lower = normalized.to_ascii_lowercase();
2494
2495 if normalized.starts_with("document_cycle ") {
2496 let phase = extract_field(&normalized, "phase");
2497 let event = extract_field(&normalized, "event");
2498 if phase == Some("committed")
2499 && let Some(event) = event
2500 {
2501 out.push((
2502 "commit".to_string(),
2503 format!("document_cycle phase=committed event={event}"),
2504 ));
2505 }
2506 return dedupe_pairs(out);
2507 }
2508
2509 if lower.contains("verification passed") || lower.starts_with("verification in ") {
2510 out.push((
2511 "verification".to_string(),
2512 truncate_detail(&normalized, 220),
2513 ));
2514 }
2515 if lower.contains("cargo build")
2516 || lower.contains("make check")
2517 || lower.contains("cargo test")
2518 || lower.contains("pytest")
2519 {
2520 out.push((
2521 "verification".to_string(),
2522 truncate_detail(&normalized, 220),
2523 ));
2524 }
2525 if lower.contains("cargo install") || lower.contains("installed") {
2526 out.push(("install".to_string(), truncate_detail(&normalized, 220)));
2527 }
2528 if lower.contains("committed and pushed") {
2529 out.push(("push".to_string(), truncate_detail(&normalized, 220)));
2530 } else if lower.contains("committed") {
2531 out.push(("commit".to_string(), truncate_detail(&normalized, 220)));
2532 }
2533 if lower.contains("tsift --version") || lower.contains("tsift v0.") {
2534 out.push(("version".to_string(), truncate_detail(&normalized, 220)));
2535 }
2536 if lower.contains("agent-doc finalize") || lower.contains("session-check") {
2537 out.push(("closeout".to_string(), truncate_detail(&normalized, 220)));
2538 }
2539
2540 dedupe_pairs(out)
2541}
2542
2543fn is_closeout_runtime_event(event_name: &str, normalized: &str) -> bool {
2544 event_name == "document_cycle"
2545 || matches!(
2546 normalized,
2547 "preflight_started"
2548 | "response_captured"
2549 | "commit_staging"
2550 | "commit_success"
2551 | "commit_already_current"
2552 | "snapshot_save"
2553 | "write_origin"
2554 | "ipc_write_attempt"
2555 | "ipc_write_consumed"
2556 | "out_of_band_write"
2557 )
2558}
2559
2560fn dedupe_pairs(items: Vec<(String, String)>) -> Vec<(String, String)> {
2561 let mut seen = BTreeSet::new();
2562 let mut deduped = Vec::new();
2563 for item in items {
2564 if seen.insert(item.clone()) {
2565 deduped.push(item);
2566 }
2567 }
2568 deduped
2569}
2570
2571fn normalize_whitespace(raw: &str) -> String {
2572 raw.split_whitespace().collect::<Vec<_>>().join(" ")
2573}
2574
2575fn truncate_detail(text: &str, max_chars: usize) -> String {
2576 if text.chars().count() <= max_chars {
2577 return text.to_string();
2578 }
2579 let mut truncated = String::new();
2580 for ch in text.chars().take(max_chars.saturating_sub(1)) {
2581 truncated.push(ch);
2582 }
2583 truncated.push('…');
2584 truncated
2585}
2586
2587fn normalize_runtime_event(event_name: &str, detail: &str) -> String {
2588 if event_name == "document_cycle"
2589 && let Some(document_event) = extract_field(detail, "event")
2590 {
2591 return document_event.to_string();
2592 }
2593 if matches!(
2594 event_name,
2595 "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
2596 ) && let Some(mode) = extract_field(detail, "mode")
2597 {
2598 return format!("{event_name}:{mode}");
2599 }
2600 event_name.to_string()
2601}
2602
2603fn should_count_runtime_event(
2604 event_name: &str,
2605 detail: &str,
2606 normalized: &str,
2607 state: &mut CostState,
2608) -> bool {
2609 if event_name == "document_cycle"
2610 && let Some(cycle) = extract_field(detail, "cycle")
2611 {
2612 return state
2613 .seen_document_cycle_events
2614 .insert((cycle.to_string(), normalized.to_string()));
2615 }
2616 true
2617}
2618
2619fn prompt_cache_metadata(
2620 value: &Value,
2621 source: SessionCostSource,
2622) -> SessionCostPromptCacheMetadata {
2623 let provider = find_first_string_field(
2624 value,
2625 &[
2626 "provider",
2627 "model_provider",
2628 "provider_id",
2629 "model_provider_id",
2630 ],
2631 )
2632 .unwrap_or_else(|| default_prompt_cache_provider(source).to_string());
2633 let cache_key = find_first_string_field(
2634 value,
2635 &[
2636 "prompt_cache_key",
2637 "promptCacheKey",
2638 "cache_key",
2639 "cacheKey",
2640 ],
2641 );
2642 let routing_affinity = find_first_string_field(
2643 value,
2644 &[
2645 "routing_affinity",
2646 "routingAffinity",
2647 "replica",
2648 "replica_id",
2649 "replicaId",
2650 "deployment_id",
2651 "deploymentId",
2652 ],
2653 );
2654 let explicit_fingerprint = find_first_string_field(
2655 value,
2656 &[
2657 "stable_prefix_fingerprint",
2658 "stablePrefixFingerprint",
2659 "prefix_fingerprint",
2660 "prefixFingerprint",
2661 ],
2662 );
2663 let stable_prefix = find_first_string_field(
2664 value,
2665 &[
2666 "stable_prefix",
2667 "stablePrefix",
2668 "cached_prefix",
2669 "cachedPrefix",
2670 "prompt_prefix",
2671 "promptPrefix",
2672 ],
2673 );
2674 let mut breakpoints = Vec::new();
2675 collect_prompt_cache_breakpoints(value, "$", &mut breakpoints);
2676 breakpoints.sort();
2677 breakpoints.dedup();
2678 breakpoints.truncate(MAX_PROMPT_CACHE_BREAKPOINTS);
2679
2680 let stable_prefix_fingerprint = explicit_fingerprint.unwrap_or_else(|| {
2681 let mut material = vec![format!("provider={provider}")];
2682 if let Some(cache_key) = &cache_key {
2683 material.push(format!("cache_key={cache_key}"));
2684 }
2685 if let Some(stable_prefix) = &stable_prefix {
2686 material.push(format!("stable_prefix={stable_prefix}"));
2687 }
2688 for breakpoint in &breakpoints {
2689 material.push(format!("breakpoint={breakpoint}"));
2690 }
2691 stable_prompt_cache_fingerprint(&material.join("\n"))
2692 });
2693
2694 SessionCostPromptCacheMetadata {
2695 provider,
2696 cache_key,
2697 stable_prefix_fingerprint,
2698 breakpoints,
2699 routing_affinity,
2700 }
2701}
2702
2703fn default_prompt_cache_provider(source: SessionCostSource) -> &'static str {
2704 match source {
2705 SessionCostSource::ClaudeJsonl => "anthropic",
2706 SessionCostSource::CodexJsonl => "openai",
2707 SessionCostSource::AgentDocLog => "agent_doc_log",
2708 }
2709}
2710
2711fn find_first_string_field(value: &Value, keys: &[&str]) -> Option<String> {
2712 let mut matches = Vec::new();
2713 collect_string_field_matches(value, "$", keys, &mut matches);
2714 matches.sort_by(|left, right| left.0.cmp(&right.0));
2715 matches
2716 .into_iter()
2717 .map(|(_, value)| value)
2718 .find(|value| !value.trim().is_empty())
2719}
2720
2721fn collect_string_field_matches(
2722 value: &Value,
2723 path: &str,
2724 keys: &[&str],
2725 matches: &mut Vec<(String, String)>,
2726) {
2727 match value {
2728 Value::Object(object) => {
2729 for (key, child) in object {
2730 let child_path = json_child_path(path, key);
2731 if metadata_key_matches(key, keys)
2732 && let Some(text) = child.as_str()
2733 {
2734 matches.push((child_path.clone(), text.to_string()));
2735 }
2736 collect_string_field_matches(child, &child_path, keys, matches);
2737 }
2738 }
2739 Value::Array(items) => {
2740 for (index, child) in items.iter().enumerate() {
2741 let child_path = format!("{path}[{index}]");
2742 collect_string_field_matches(child, &child_path, keys, matches);
2743 }
2744 }
2745 _ => {}
2746 }
2747}
2748
2749fn collect_prompt_cache_breakpoints(value: &Value, path: &str, breakpoints: &mut Vec<String>) {
2750 match value {
2751 Value::Object(object) => {
2752 for (key, child) in object {
2753 let child_path = json_child_path(path, key);
2754 if metadata_key_matches(
2755 key,
2756 &[
2757 "cache_control",
2758 "cacheControl",
2759 "cache_breakpoint",
2760 "cacheBreakpoint",
2761 "prompt_cache_breakpoint",
2762 "promptCacheBreakpoint",
2763 ],
2764 ) {
2765 breakpoints.push(format!(
2766 "{}={}",
2767 child_path.trim_start_matches("$."),
2768 describe_prompt_cache_breakpoint(child)
2769 ));
2770 }
2771 collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
2772 }
2773 }
2774 Value::Array(items) => {
2775 for (index, child) in items.iter().enumerate() {
2776 let child_path = format!("{path}[{index}]");
2777 collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
2778 }
2779 }
2780 _ => {}
2781 }
2782}
2783
2784fn describe_prompt_cache_breakpoint(value: &Value) -> String {
2785 if let Some(text) = value.as_str() {
2786 return text.to_string();
2787 }
2788 if let Some(enabled) = value.as_bool() {
2789 return enabled.to_string();
2790 }
2791 if let Some(object) = value.as_object()
2792 && let Some(kind) = object.get("type").and_then(Value::as_str)
2793 {
2794 return format!("type:{kind}");
2795 }
2796 value.to_string()
2797}
2798
2799fn metadata_has_cache_control_breakpoint(metadata: &SessionCostPromptCacheMetadata) -> bool {
2800 metadata.breakpoints.iter().any(|breakpoint| {
2801 let key = breakpoint
2802 .split_once('=')
2803 .map_or(breakpoint.as_str(), |(key, _)| key);
2804 normalize_metadata_key(key).contains("cachecontrol")
2805 })
2806}
2807
2808fn is_anthropic_provider(provider: &str) -> bool {
2809 let provider = normalize_metadata_key(provider);
2810 provider.contains("anthropic") || provider.contains("claude")
2811}
2812
2813fn is_openai_provider(provider: &str) -> bool {
2814 let provider = normalize_metadata_key(provider);
2815 provider.contains("openai") || provider.contains("azureopenai") || provider.contains("codex")
2816}
2817
2818fn metadata_key_matches(key: &str, candidates: &[&str]) -> bool {
2819 let key = normalize_metadata_key(key);
2820 candidates
2821 .iter()
2822 .any(|candidate| key == normalize_metadata_key(candidate))
2823}
2824
2825fn normalize_metadata_key(key: &str) -> String {
2826 key.chars()
2827 .filter(|value| *value != '_' && *value != '-')
2828 .flat_map(char::to_lowercase)
2829 .collect()
2830}
2831
2832fn json_child_path(parent: &str, key: &str) -> String {
2833 if parent == "$" {
2834 format!("$.{key}")
2835 } else {
2836 format!("{parent}.{key}")
2837 }
2838}
2839
2840fn stable_prompt_cache_fingerprint(material: &str) -> String {
2841 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
2842 for byte in material.as_bytes() {
2843 hash ^= u64::from(*byte);
2844 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2845 }
2846 format!("spfx-{hash:016x}")
2847}
2848
2849fn usage_u64(value: &Value, key: &str) -> u64 {
2850 value.get(key).and_then(Value::as_u64).unwrap_or(0)
2851}
2852
2853fn codex_usage_totals(value: &Value) -> UsageTotals {
2854 UsageTotals {
2855 prompt_tokens: usage_u64(value, "input_tokens"),
2856 cached_input_tokens: usage_u64(value, "cached_input_tokens"),
2857 cache_creation_input_tokens: 0,
2858 output_tokens: usage_u64(value, "output_tokens"),
2859 reasoning_output_tokens: usage_u64(value, "reasoning_output_tokens"),
2860 total_tokens: usage_u64(value, "total_tokens"),
2861 }
2862}
2863
2864fn count_restart_family(restart_churn: &[RestartChurnSummary], family: &str) -> usize {
2865 restart_churn
2866 .iter()
2867 .find(|entry| entry.family == family)
2868 .map_or(0, |entry| entry.occurrences)
2869}
2870
2871fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
2872 let needle = format!("{key}=");
2873 let start = detail.find(&needle)? + needle.len();
2874 let remainder = &detail[start..];
2875 let end = remainder
2876 .find(char::is_whitespace)
2877 .unwrap_or(remainder.len());
2878 Some(remainder[..end].trim_matches('"'))
2879}
2880
2881#[cfg(test)]
2882mod tests {
2883 use super::*;
2884
2885 fn prompt_cache_adapter_status<'a>(
2886 plan: &'a SessionCostPromptCachePlan,
2887 provider: &str,
2888 ) -> Option<&'a str> {
2889 plan.provider_adapters
2890 .iter()
2891 .find(|adapter| adapter.provider == provider)
2892 .map(|adapter| adapter.status.as_str())
2893 }
2894
2895 #[test]
2896 fn auto_detects_claude_jsonl_and_dedupes_usage_by_message_id() {
2897 let input = concat!(
2898 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}}}"#,
2899 "\n",
2900 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}}}"#,
2901 "\n",
2902 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}}}"#,
2903 "\n"
2904 );
2905
2906 let report = compute(input, None).unwrap();
2907 assert_eq!(report.source, "claude_jsonl");
2908 assert_eq!(report.usage_samples, 2);
2909 assert_eq!(report.prompt_tokens, 2321);
2910 assert_eq!(report.cached_input_tokens, 2000);
2911 assert_eq!(report.cache_creation_input_tokens, 300);
2912 assert_eq!(report.output_tokens, 18);
2913 assert_eq!(report.total_tokens, 2339);
2914 assert_eq!(report.cached_input_ratio, Some(86.17));
2915 }
2916
2917 #[test]
2918 fn codex_jsonl_uses_cumulative_deltas_and_skips_duplicate_snapshots() {
2919 let input = concat!(
2920 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}}}}"#,
2921 "\n",
2922 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}}}}"#,
2923 "\n",
2924 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}}}}"#,
2925 "\n"
2926 );
2927
2928 let report = compute(input, Some("codex-jsonl")).unwrap();
2929 assert_eq!(report.usage_samples, 2);
2930 assert_eq!(report.prompt_tokens, 1600);
2931 assert_eq!(report.cached_input_tokens, 1400);
2932 assert_eq!(report.output_tokens, 90);
2933 assert_eq!(report.reasoning_output_tokens, 20);
2934 assert_eq!(report.total_tokens, 1690);
2935 assert_eq!(report.largest_turn_total_tokens, 1050);
2936 assert_eq!(report.largest_turns[0].total_tokens, 1050);
2937 assert_eq!(report.largest_turns[1].total_tokens, 640);
2938 }
2939
2940 #[test]
2941 fn codex_jsonl_prefers_last_usage_for_interleaved_cumulative_streams() {
2942 let input = concat!(
2943 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}}}}"#,
2944 "\n",
2945 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}}}}"#,
2946 "\n",
2947 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}}}}"#,
2948 "\n",
2949 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}}}}"#,
2950 "\n",
2951 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}}}}"#,
2952 "\n"
2953 );
2954
2955 let report = compute(input, Some("codex-jsonl")).unwrap();
2956 assert_eq!(report.usage_samples, 4);
2957 assert_eq!(report.prompt_tokens, 2500);
2958 assert_eq!(report.cached_input_tokens, 2200);
2959 assert_eq!(report.output_tokens, 135);
2960 assert_eq!(report.reasoning_output_tokens, 30);
2961 assert_eq!(report.total_tokens, 2635);
2962 assert_eq!(report.largest_turn_total_tokens, 1050);
2963 }
2964
2965 #[test]
2966 fn prompt_cache_plan_summarizes_effectiveness_over_time() {
2967 let input = concat!(
2968 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}}}}"#,
2969 "\n",
2970 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}}}}"#,
2971 "\n",
2972 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}}}}"#,
2973 "\n",
2974 );
2975
2976 let report = compute(input, Some("codex-jsonl")).unwrap();
2977 let analytics = report
2978 .prompt_cache_plan
2979 .as_ref()
2980 .and_then(|plan| plan.analytics.as_ref())
2981 .expect("prompt cache analytics should be present");
2982
2983 assert_eq!(analytics.sample_count, 3);
2984 assert!(!analytics.effective);
2985 assert_eq!(analytics.trend, "improving");
2986 assert_eq!(
2987 analytics.average_cached_input_ratio.as_deref(),
2988 Some("50.00%")
2989 );
2990 assert_eq!(
2991 analytics.first_cached_input_ratio.as_deref(),
2992 Some("10.00%")
2993 );
2994 assert_eq!(analytics.last_cached_input_ratio.as_deref(), Some("90.00%"));
2995 assert_eq!(
2996 analytics.cached_input_ratio_delta.as_deref(),
2997 Some("+80.00%")
2998 );
2999 assert_eq!(analytics.net_cached_input_tokens, 1500);
3000 assert_eq!(analytics.timeline.len(), 3);
3001 assert_eq!(
3002 analytics.timeline[2].cached_input_ratio.as_deref(),
3003 Some("90.00%")
3004 );
3005 }
3006
3007 #[test]
3008 fn prompt_cache_timeline_emits_attribution_metadata() {
3009 let input = concat!(
3010 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}}}"#,
3011 "\n",
3012 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}}}"#,
3013 "\n",
3014 );
3015
3016 let report = compute(input, Some("claude-jsonl")).unwrap();
3017 let plan = report
3018 .prompt_cache_plan
3019 .as_ref()
3020 .expect("prompt cache plan should be present");
3021 let analytics = report
3022 .prompt_cache_plan
3023 .as_ref()
3024 .and_then(|plan| plan.analytics.as_ref())
3025 .expect("prompt cache analytics should be present");
3026 let first = analytics.timeline[0]
3027 .prompt_cache_metadata
3028 .as_ref()
3029 .expect("timeline should include prompt cache metadata");
3030 let second = analytics.timeline[1]
3031 .prompt_cache_metadata
3032 .as_ref()
3033 .expect("timeline should include prompt cache metadata");
3034
3035 assert_eq!(first.provider, "anthropic");
3036 assert_eq!(first.cache_key.as_deref(), Some("agent-doc:tsift"));
3037 assert_eq!(first.routing_affinity.as_deref(), Some("replica-a"));
3038 assert!(
3039 first.breakpoints.iter().any(|breakpoint| {
3040 breakpoint == "message.content[0].cache_control=type:ephemeral"
3041 })
3042 );
3043 assert!(first.stable_prefix_fingerprint.starts_with("spfx-"));
3044 assert_eq!(
3045 first.stable_prefix_fingerprint,
3046 second.stable_prefix_fingerprint
3047 );
3048 assert_eq!(
3049 prompt_cache_adapter_status(plan, "anthropic"),
3050 Some("cache_control")
3051 );
3052 assert_eq!(
3053 prompt_cache_adapter_status(plan, "replica_local"),
3054 Some("routing_affinity")
3055 );
3056 }
3057
3058 #[test]
3059 fn prompt_cache_plan_marks_missing_provider_adapter_evidence() {
3060 let input = concat!(
3061 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}}}}"#,
3062 "\n",
3063 );
3064
3065 let report = compute(input, Some("codex-jsonl")).unwrap();
3066 let plan = report
3067 .prompt_cache_plan
3068 .as_ref()
3069 .expect("prompt cache plan should be present");
3070
3071 assert_eq!(
3072 prompt_cache_adapter_status(plan, "openai"),
3073 Some("missing_prompt_cache_key")
3074 );
3075 assert_eq!(
3076 prompt_cache_adapter_status(plan, "replica_local"),
3077 Some("missing_routing_affinity")
3078 );
3079 assert!(plan.actions.iter().any(|action| {
3080 action.kind == "fix_openai_prompt_cache_key"
3081 && action.guidance.contains("prompt_cache_key")
3082 }));
3083 assert!(plan.actions.iter().any(|action| {
3084 action.kind == "fix_replica_routing_affinity"
3085 && action.guidance.contains("same provider replica")
3086 }));
3087
3088 let anthropic = concat!(
3089 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}}}"#,
3090 "\n",
3091 );
3092 let report = compute(anthropic, Some("claude-jsonl")).unwrap();
3093 let plan = report
3094 .prompt_cache_plan
3095 .as_ref()
3096 .expect("prompt cache plan should be present");
3097 assert_eq!(
3098 prompt_cache_adapter_status(plan, "anthropic"),
3099 Some("missing_cache_control")
3100 );
3101 assert!(plan.actions.iter().any(|action| {
3102 action.kind == "fix_anthropic_cache_control"
3103 && action.guidance.contains("cache_control")
3104 }));
3105 }
3106
3107 #[test]
3108 fn prompt_cache_plan_marks_routing_affinity_churn() {
3109 let input = concat!(
3110 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}}}}"#,
3111 "\n",
3112 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}}}}"#,
3113 "\n",
3114 );
3115
3116 let report = compute(input, Some("codex-jsonl")).unwrap();
3117 let plan = report
3118 .prompt_cache_plan
3119 .as_ref()
3120 .expect("prompt cache plan should be present");
3121
3122 assert_eq!(
3123 prompt_cache_adapter_status(plan, "openai"),
3124 Some("prompt_cache_key")
3125 );
3126 assert_eq!(
3127 prompt_cache_adapter_status(plan, "replica_local"),
3128 Some("routing_affinity_churn")
3129 );
3130 assert!(
3131 plan.actions
3132 .iter()
3133 .any(|action| action.kind == "fix_replica_routing_affinity")
3134 );
3135 }
3136
3137 #[test]
3138 fn prompt_cache_plan_classifies_likely_invalidation_diagnostics() {
3139 let input = concat!(
3140 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}}}"#,
3141 "\n",
3142 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}}}"#,
3143 "\n",
3144 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}}}"#,
3145 "\n",
3146 );
3147
3148 let report = compute(input, Some("claude-jsonl")).unwrap();
3149 let diagnostics = &report
3150 .prompt_cache_plan
3151 .as_ref()
3152 .and_then(|plan| plan.analytics.as_ref())
3153 .expect("prompt cache analytics should be present")
3154 .diagnostics;
3155
3156 assert!(diagnostics.iter().any(|diagnostic| {
3157 diagnostic.kind == "cached_ratio_drop"
3158 && diagnostic.label == "2026-05-05T00:00:02Z"
3159 && diagnostic
3160 .likely_causes
3161 .iter()
3162 .any(|cause| cause.contains("prompt_cache_key"))
3163 }));
3164 assert!(diagnostics.iter().any(|diagnostic| {
3165 diagnostic.kind == "cache_creation_spike" && diagnostic.message.contains("60.00%")
3166 }));
3167 assert!(diagnostics.iter().any(|diagnostic| {
3168 diagnostic.kind == "read_create_regression" && diagnostic.message.contains("0.92x")
3169 }));
3170 }
3171
3172 #[test]
3173 fn prompt_cache_effectiveness_fixture_passes_thresholds() {
3174 let fixture = SessionCostPromptCacheEffectivenessFixture {
3175 schema_version: 1,
3176 description: "fixture".to_string(),
3177 cases: vec![SessionCostPromptCacheEffectivenessCase {
3178 name: "warm-codex-prefix".to_string(),
3179 source: "codex-jsonl".to_string(),
3180 input_lines: vec![
3181 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(),
3182 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(),
3183 ],
3184 minimum_cached_input_ratio: 90.0,
3185 minimum_net_cached_input_tokens: 40_000,
3186 maximum_read_create_regressions: 0,
3187 }],
3188 };
3189
3190 let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
3191
3192 assert!(report.pass);
3193 assert_eq!(report.totals.passed, 1);
3194 assert_eq!(report.totals.failed, 0);
3195 assert_eq!(report.cases[0].status, "pass");
3196 assert_eq!(report.cases[0].cached_input_ratio, Some(96.0));
3197 assert_eq!(report.cases[0].net_cached_input_tokens, 48_000);
3198 assert_eq!(report.cases[0].read_create_regressions, 0);
3199 }
3200
3201 #[test]
3202 fn prompt_cache_effectiveness_fixture_fails_missing_adapter_evidence() {
3203 let fixture = SessionCostPromptCacheEffectivenessFixture {
3204 schema_version: 1,
3205 description: "fixture".to_string(),
3206 cases: vec![
3207 SessionCostPromptCacheEffectivenessCase {
3208 name: "missing-openai-key".to_string(),
3209 source: "codex-jsonl".to_string(),
3210 input_lines: vec![
3211 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(),
3212 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(),
3213 ],
3214 minimum_cached_input_ratio: 90.0,
3215 minimum_net_cached_input_tokens: 40_000,
3216 maximum_read_create_regressions: 0,
3217 },
3218 SessionCostPromptCacheEffectivenessCase {
3219 name: "missing-anthropic-cache-control".to_string(),
3220 source: "claude-jsonl".to_string(),
3221 input_lines: vec![
3222 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(),
3223 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(),
3224 ],
3225 minimum_cached_input_ratio: 70.0,
3226 minimum_net_cached_input_tokens: 1,
3227 maximum_read_create_regressions: 0,
3228 },
3229 ],
3230 };
3231
3232 let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
3233
3234 assert!(!report.pass);
3235 assert_eq!(report.totals.failed, 2);
3236 assert!(report.cases[0].failures.iter().any(|failure| {
3237 failure.contains("OpenAI prompt_cache_key")
3238 && failure.contains("missing_prompt_cache_key")
3239 }));
3240 assert!(report.cases[0].failures.iter().any(|failure| {
3241 failure.contains("replica-local routing_affinity")
3242 && failure.contains("missing_routing_affinity")
3243 }));
3244 assert!(report.cases[1].failures.iter().any(|failure| {
3245 failure.contains("Anthropic cache_control") && failure.contains("missing_cache_control")
3246 }));
3247 }
3248
3249 #[test]
3250 fn prompt_cache_effectiveness_fixture_fails_read_create_regression() {
3251 let fixture = SessionCostPromptCacheEffectivenessFixture {
3252 schema_version: 1,
3253 description: "fixture".to_string(),
3254 cases: vec![SessionCostPromptCacheEffectivenessCase {
3255 name: "cold-rewrite".to_string(),
3256 source: "claude-jsonl".to_string(),
3257 input_lines: vec![
3258 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(),
3259 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(),
3260 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(),
3261 ],
3262 minimum_cached_input_ratio: 70.0,
3263 minimum_net_cached_input_tokens: 1,
3264 maximum_read_create_regressions: 0,
3265 }],
3266 };
3267
3268 let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
3269
3270 assert!(!report.pass);
3271 assert_eq!(report.totals.failed, 1);
3272 assert_eq!(report.cases[0].status, "fail");
3273 assert_eq!(report.cases[0].read_create_regressions, 1);
3274 assert!(
3275 report.cases[0]
3276 .failures
3277 .iter()
3278 .any(|failure| failure.contains("read_create_regressions"))
3279 );
3280 }
3281
3282 #[test]
3283 fn agent_doc_log_summarizes_runtime_churn() {
3284 let input = "\
3285[1776452736] claude_start mode=fresh restart_count=0
3286[1776528398] claude_start mode=fresh_restart restart_count=1
3287[1776528446] auto_trigger_timeout (no prompt after 30s)
3288[1776528450] ctrl_d_restart_fresh restart_count=2
3289[1776528582] claude_start mode=fresh_restart restart_count=2
3290[1776528599] codex_start mode=continue restart_count=3
3291[1776528601] user_quit_after_ctrl_d
3292[1776528602] commit_already_current file=tasks/software/tsift.md basis=head
3293[1776528603] commit_already_current file=tasks/software/tsift.md basis=head
3294[1776528604] commit_already_current file=tasks/software/tsift.md basis=head
3295";
3296
3297 let report = compute(input, Some("agent-doc-log")).unwrap();
3298 assert_eq!(report.source, "agent_doc_log");
3299 assert_eq!(report.usage_samples, 0);
3300 assert_eq!(report.runtime_event_groups, 7);
3301 assert_eq!(report.total_runtime_events, 10);
3302 assert_eq!(report.restart_churn_groups, 4);
3303 assert_eq!(report.max_restart_count, Some(3));
3304 assert!(
3305 report
3306 .runtime_events
3307 .iter()
3308 .any(|event| event.event == "claude_start:fresh_restart" && event.occurrences == 2)
3309 );
3310 assert!(
3311 report
3312 .runtime_events
3313 .iter()
3314 .any(|event| event.event == "auto_trigger_timeout" && event.occurrences == 1)
3315 );
3316 assert!(
3317 report
3318 .restart_churn
3319 .iter()
3320 .any(|entry| entry.family == "fresh_restart" && entry.occurrences == 3)
3321 );
3322 assert!(
3323 report
3324 .restart_churn
3325 .iter()
3326 .any(|entry| entry.family == "ctrl_d_restart_loop" && entry.occurrences == 1)
3327 );
3328 assert!(
3329 report
3330 .restart_churn
3331 .iter()
3332 .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 1)
3333 );
3334 assert!(
3335 report
3336 .guardrails
3337 .iter()
3338 .any(|guardrail| guardrail.kind == "restart_loop")
3339 );
3340 assert!(
3341 report
3342 .guardrails
3343 .iter()
3344 .any(|guardrail| guardrail.kind == "noop_closeout")
3345 );
3346 assert!(
3347 report
3348 .loop_clusters
3349 .iter()
3350 .any(|cluster| cluster.kind == "closeout_churn"
3351 && cluster.label == "commit_already_current"
3352 && cluster.occurrences == 3)
3353 );
3354 }
3355
3356 #[test]
3357 fn agent_doc_log_dedupes_document_cycle_runtime_events_by_cycle() {
3358 let input = "\
3359[1777603275] document_cycle phase=response_captured cycle=cycle-1 event=response_captured capture_id=cycle-1
3360[1777603276] document_cycle phase=committed cycle=cycle-1 event=commit_success capture_id=cycle-1
3361[1777603403] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
3362[1777603404] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
3363[1777603405] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
3364[1777603500] document_cycle phase=preflight_started cycle=cycle-2 event=preflight_started
3365[1777603600] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
3366[1777603601] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
3367[1777603700] document_cycle phase=committed cycle=cycle-3 event=commit_already_current
3368";
3369
3370 let report = compute(input, Some("agent-doc-log")).unwrap();
3371
3372 assert_eq!(report.total_runtime_events, 6);
3373 assert!(
3374 report
3375 .runtime_events
3376 .iter()
3377 .any(|event| event.event == "commit_already_current" && event.occurrences == 3)
3378 );
3379 assert!(
3380 report
3381 .runtime_events
3382 .iter()
3383 .any(|event| event.event == "commit_success" && event.occurrences == 1)
3384 );
3385 assert!(
3386 report
3387 .runtime_events
3388 .iter()
3389 .any(|event| event.event == "response_captured" && event.occurrences == 1)
3390 );
3391 assert!(
3392 report
3393 .guardrails
3394 .iter()
3395 .any(|guardrail| guardrail.kind == "noop_closeout")
3396 );
3397 assert!(
3398 report
3399 .loop_clusters
3400 .iter()
3401 .any(|cluster| cluster.kind == "closeout_churn"
3402 && cluster.label == "commit_already_current"
3403 && cluster.occurrences == 3)
3404 );
3405 }
3406
3407 #[test]
3408 fn codex_jsonl_surfaces_prompt_and_command_loop_clusters() {
3409 let input = concat!(
3410 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
3411 "\n",
3412 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
3413 "\n",
3414 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
3415 "\n",
3416 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
3417 "\n",
3418 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
3419 "\n",
3420 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
3421 "\n",
3422 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
3423 "\n",
3424 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
3425 "\n"
3426 );
3427
3428 let report = compute(input, Some("codex-jsonl")).unwrap();
3429
3430 assert!(
3431 report
3432 .loop_clusters
3433 .iter()
3434 .any(|cluster| cluster.kind == "prompt_repeat"
3435 && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
3436 && cluster.occurrences == 2)
3437 );
3438 assert!(
3439 report
3440 .loop_clusters
3441 .iter()
3442 .any(|cluster| cluster.kind == "command_bundle"
3443 && cluster.label == "cargo test -> cargo build --release"
3444 && cluster.occurrences == 2)
3445 );
3446 assert!(report.loop_clusters.iter().any(|cluster| {
3447 cluster.kind == "closeout_churn"
3448 && cluster
3449 .label
3450 .contains("Committed and pushed in `src/tsift`")
3451 && cluster.occurrences == 2
3452 }));
3453 }
3454
3455 #[test]
3456 fn codex_jsonl_surfaces_repeated_file_read_diagnostics() {
3457 let input = concat!(
3458 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
3459 "\n",
3460 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
3461 "\n",
3462 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
3463 "\n",
3464 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
3465 "\n"
3466 );
3467
3468 let report = compute(input, Some("codex-jsonl")).unwrap();
3469
3470 assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
3471 diagnostic.path == "src/session_cost.rs"
3472 && diagnostic.range == "1-220"
3473 && diagnostic.occurrences == 2
3474 && diagnostic.duplicate_estimated_tokens == 3_960
3475 && diagnostic.follow_up_commands.iter().any(|command| {
3476 command == "tsift source-read src/session_cost.rs --start 1 --lines 220 --budget normal"
3477 })
3478 }));
3479 assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
3480 diagnostic.path == "src/main.rs"
3481 && diagnostic.range == "full"
3482 && diagnostic.duplicate_estimated_tokens == 4_000
3483 && diagnostic
3484 .follow_up_commands
3485 .iter()
3486 .any(|command| command == "tsift summarize --file src/main.rs")
3487 }));
3488 }
3489
3490 #[test]
3491 fn claude_jsonl_surfaces_repeated_native_read_tool_diagnostics() {
3492 let input = concat!(
3493 r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
3494 "\n",
3495 r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
3496 "\n"
3497 );
3498
3499 let report = compute(input, Some("claude-jsonl")).unwrap();
3500
3501 assert_eq!(report.file_read_diagnostics.len(), 1);
3502 let diagnostic = &report.file_read_diagnostics[0];
3503 assert_eq!(diagnostic.path, "src/lib.rs");
3504 assert_eq!(diagnostic.range, "40-119");
3505 assert_eq!(diagnostic.occurrences, 2);
3506 assert_eq!(diagnostic.duplicate_estimated_tokens, 1_440);
3507 assert!(diagnostic.follow_up_commands.iter().any(|command| {
3508 command == "tsift source-read src/lib.rs --start 40 --lines 80 --budget normal"
3509 }));
3510 }
3511
3512 #[test]
3513 fn derive_guardrails_flags_large_prompt_turns() {
3514 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
3515 largest_prompt_turn_tokens: 140_000,
3516 largest_prompt_turn_label: Some("2026-05-05T00:00:01Z".to_string()),
3517 ..SessionCostGuardrailInput::default()
3518 });
3519
3520 assert!(
3521 guardrails
3522 .iter()
3523 .any(|guardrail| guardrail.kind == "prompt_budget")
3524 );
3525 }
3526
3527 #[test]
3528 fn derive_guardrails_flags_cached_resend_ratio() {
3529 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
3530 prompt_tokens: 80_000,
3531 cached_input_ratio: Some(96.0),
3532 ..SessionCostGuardrailInput::default()
3533 });
3534
3535 assert!(
3536 guardrails
3537 .iter()
3538 .any(|guardrail| guardrail.kind == "cache_resend")
3539 );
3540 }
3541
3542 #[test]
3543 fn derive_guardrails_ignores_restart_count_without_churn() {
3544 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
3545 max_restart_count: Some(3),
3546 ..SessionCostGuardrailInput::default()
3547 });
3548
3549 assert!(
3550 guardrails
3551 .iter()
3552 .all(|guardrail| guardrail.kind != "restart_loop")
3553 );
3554 }
3555}