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